@echothink-ui/inbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +5 -0
  2. package/dist/components/AgentDraftReviewPanel.d.ts +18 -0
  3. package/dist/components/ApprovalToSendPanel.d.ts +11 -0
  4. package/dist/components/AttachmentList.d.ts +8 -0
  5. package/dist/components/AttachmentPreview.d.ts +7 -0
  6. package/dist/components/EmailAutomationRulePanel.d.ts +8 -0
  7. package/dist/components/InboxShell.d.ts +10 -0
  8. package/dist/components/LabelManager.d.ts +9 -0
  9. package/dist/components/MailboxFolderList.d.ts +8 -0
  10. package/dist/components/MessageComposer.d.ts +13 -0
  11. package/dist/components/MessageFilterBar.d.ts +13 -0
  12. package/dist/components/MessageList.d.ts +8 -0
  13. package/dist/components/MessagePreview.d.ts +9 -0
  14. package/dist/components/MessageSearch.d.ts +11 -0
  15. package/dist/components/MessageThread.d.ts +22 -0
  16. package/dist/components/PriorityInboxView.d.ts +14 -0
  17. package/dist/components/RecipientPicker.d.ts +10 -0
  18. package/dist/components/ThreadSummaryPanel.d.ts +9 -0
  19. package/dist/index.cjs +1699 -0
  20. package/dist/index.cjs.map +1 -0
  21. package/dist/index.css +2155 -0
  22. package/dist/index.css.map +1 -0
  23. package/dist/index.d.ts +21 -0
  24. package/dist/index.js +1645 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/types.d.ts +75 -0
  27. package/dist/utils.d.ts +5 -0
  28. package/package.json +44 -0
  29. package/src/components/AgentDraftReviewPanel.tsx +128 -0
  30. package/src/components/ApprovalToSendPanel.tsx +118 -0
  31. package/src/components/AttachmentList.tsx +85 -0
  32. package/src/components/AttachmentPreview.tsx +207 -0
  33. package/src/components/EmailAutomationRulePanel.tsx +100 -0
  34. package/src/components/InboxShell.tsx +62 -0
  35. package/src/components/LabelManager.tsx +147 -0
  36. package/src/components/MailboxFolderList.tsx +66 -0
  37. package/src/components/MessageComposer.tsx +160 -0
  38. package/src/components/MessageFilterBar.test.tsx +34 -0
  39. package/src/components/MessageFilterBar.tsx +69 -0
  40. package/src/components/MessageList.tsx +101 -0
  41. package/src/components/MessagePreview.test.tsx +48 -0
  42. package/src/components/MessagePreview.tsx +84 -0
  43. package/src/components/MessageSearch.tsx +96 -0
  44. package/src/components/MessageThread.tsx +173 -0
  45. package/src/components/PriorityInboxView.tsx +74 -0
  46. package/src/components/RecipientPicker.tsx +181 -0
  47. package/src/components/ThreadSummaryPanel.tsx +107 -0
  48. package/src/index.test.tsx +276 -0
  49. package/src/index.tsx +60 -0
  50. package/src/styles.css +2523 -0
  51. package/src/types.ts +85 -0
  52. package/src/utils.ts +33 -0
@@ -0,0 +1,147 @@
1
+ import * as React from "react";
2
+ import { Button, FormField, Panel, TextInput } from "@echothink-ui/core";
3
+ import { EditIcon, PlusIcon } from "@echothink-ui/icons";
4
+ import type { MailLabel } from "../types";
5
+
6
+ export interface LabelManagerProps extends React.HTMLAttributes<HTMLDivElement> {
7
+ labels?: MailLabel[];
8
+ onCreate?: (label: string) => void;
9
+ onRename?: (id: string, label: string) => void;
10
+ onDelete?: (id: string) => void;
11
+ }
12
+
13
+ export function LabelManager({
14
+ labels = [],
15
+ onCreate,
16
+ onRename,
17
+ onDelete,
18
+ className,
19
+ ...props
20
+ }: LabelManagerProps) {
21
+ const [newLabel, setNewLabel] = React.useState("");
22
+ const [edits, setEdits] = React.useState<Record<string, string>>({});
23
+ const trackedLabels = labels.filter((label) => typeof label.count === "number");
24
+ const trackedMessageCount = trackedLabels.reduce((total, label) => total + (label.count ?? 0), 0);
25
+ const labelSummary = `${labels.length.toLocaleString()} ${labels.length === 1 ? "label" : "labels"}`;
26
+ const subtitle = labels.length
27
+ ? trackedLabels.length
28
+ ? `${labelSummary} · ${trackedMessageCount.toLocaleString()} tracked messages`
29
+ : `${labelSummary} configured for mailbox categories and automation tags.`
30
+ : "Create categories and automation tags for mailbox workflows.";
31
+
32
+ const submitNewLabel = (event: React.FormEvent<HTMLFormElement>) => {
33
+ event.preventDefault();
34
+
35
+ const nextLabel = newLabel.trim();
36
+ if (!nextLabel || !onCreate) return;
37
+
38
+ onCreate(nextLabel);
39
+ setNewLabel("");
40
+ };
41
+
42
+ return (
43
+ <Panel
44
+ {...props}
45
+ className={`eth-inbox-label-manager ${className ?? ""}`}
46
+ title="Labels"
47
+ subtitle={subtitle}
48
+ data-eth-component="LabelManager"
49
+ >
50
+ {labels.length ? (
51
+ <div className="eth-inbox-label-manager__list" role="table" aria-label="Managed labels">
52
+ <div className="eth-inbox-label-manager__table-head" role="row">
53
+ <span role="columnheader">Label</span>
54
+ <span role="columnheader">Messages</span>
55
+ <span role="columnheader">Actions</span>
56
+ </div>
57
+ {labels.map((label) => {
58
+ const draft = edits[label.id] ?? label.label;
59
+ const trimmedDraft = draft.trim();
60
+ const countLabel =
61
+ typeof label.count === "number"
62
+ ? `${label.count.toLocaleString()} ${label.count === 1 ? "message" : "messages"}`
63
+ : "Message count not tracked";
64
+
65
+ return (
66
+ <div key={label.id} className="eth-inbox-label-manager__row" role="row">
67
+ <div className="eth-inbox-label-manager__name-cell" role="cell">
68
+ <TextInput
69
+ aria-label={`Label name for ${label.label}`}
70
+ density="compact"
71
+ placeholder="Label name"
72
+ value={draft}
73
+ onChange={(event) =>
74
+ setEdits((current) => ({
75
+ ...current,
76
+ [label.id]: event.currentTarget.value
77
+ }))
78
+ }
79
+ />
80
+ </div>
81
+ <div
82
+ className="eth-inbox-label-manager__count-cell"
83
+ role="cell"
84
+ aria-label={countLabel}
85
+ >
86
+ {typeof label.count === "number" ? (
87
+ <>
88
+ <strong>{label.count.toLocaleString()}</strong>
89
+ <span>{label.count === 1 ? "message" : "messages"}</span>
90
+ </>
91
+ ) : (
92
+ <span className="eth-inbox-label-manager__count-muted">Not tracked</span>
93
+ )}
94
+ </div>
95
+ <div className="eth-inbox-label-manager__actions" role="cell">
96
+ <Button
97
+ density="compact"
98
+ disabled={!onRename || !trimmedDraft}
99
+ icon={<EditIcon size={16} />}
100
+ intent="secondary"
101
+ onClick={() => {
102
+ if (trimmedDraft) onRename?.(label.id, trimmedDraft);
103
+ }}
104
+ >
105
+ Rename
106
+ </Button>
107
+ <Button
108
+ density="compact"
109
+ disabled={!onDelete}
110
+ intent="danger"
111
+ onClick={() => onDelete?.(label.id)}
112
+ >
113
+ Delete
114
+ </Button>
115
+ </div>
116
+ </div>
117
+ );
118
+ })}
119
+ </div>
120
+ ) : (
121
+ <p className="eth-inbox-label-manager__empty">
122
+ No labels configured. Create a label to route mailbox views and automation rules.
123
+ </p>
124
+ )}
125
+ <form className="eth-inbox-label-manager__create" onSubmit={submitNewLabel}>
126
+ <FormField
127
+ label="New label"
128
+ helperText="Use concise names for categories, filters, and automation rules."
129
+ >
130
+ <TextInput
131
+ density="compact"
132
+ placeholder="e.g. renewal-risk"
133
+ value={newLabel}
134
+ onChange={(event) => setNewLabel(event.currentTarget.value)}
135
+ />
136
+ </FormField>
137
+ <Button
138
+ disabled={!onCreate || !newLabel.trim()}
139
+ icon={<PlusIcon size={16} />}
140
+ type="submit"
141
+ >
142
+ Create
143
+ </Button>
144
+ </form>
145
+ </Panel>
146
+ );
147
+ }
@@ -0,0 +1,66 @@
1
+ import * as React from "react";
2
+ import { Badge } from "@echothink-ui/core";
3
+ import type { MailboxFolder } from "../types";
4
+
5
+ export interface MailboxFolderListProps extends Omit<
6
+ React.HTMLAttributes<HTMLElement>,
7
+ "onSelect"
8
+ > {
9
+ folders?: MailboxFolder[];
10
+ activeFolderId?: string;
11
+ onSelect?: (id: string) => void;
12
+ }
13
+
14
+ export function MailboxFolderList({
15
+ folders = [],
16
+ activeFolderId,
17
+ onSelect,
18
+ className,
19
+ "aria-label": ariaLabel = "Mailbox folders",
20
+ ...props
21
+ }: MailboxFolderListProps) {
22
+ return (
23
+ <nav
24
+ {...props}
25
+ className={["eth-inbox-folders", className].filter(Boolean).join(" ")}
26
+ data-eth-component="MailboxFolderList"
27
+ aria-label={ariaLabel}
28
+ >
29
+ <ul className="eth-inbox-folders__list">
30
+ {folders.map((folder) => {
31
+ const isActive = activeFolderId === folder.id;
32
+
33
+ return (
34
+ <li key={folder.id}>
35
+ <button
36
+ type="button"
37
+ className={[
38
+ "eth-inbox-folders__item",
39
+ isActive ? "eth-inbox-folders__item--active" : "",
40
+ folder.smart ? "eth-inbox-folders__item--smart" : ""
41
+ ]
42
+ .filter(Boolean)
43
+ .join(" ")}
44
+ aria-current={isActive ? "page" : undefined}
45
+ onClick={() => onSelect?.(folder.id)}
46
+ title={folder.label}
47
+ >
48
+ {folder.icon ? (
49
+ <span className="eth-inbox-folders__icon" aria-hidden="true">
50
+ {folder.icon}
51
+ </span>
52
+ ) : null}
53
+ <span className="eth-inbox-folders__label">{folder.label}</span>
54
+ {typeof folder.count === "number" ? (
55
+ <Badge className="eth-inbox-folders__count" aria-label={`${folder.count} items`}>
56
+ {folder.count}
57
+ </Badge>
58
+ ) : null}
59
+ </button>
60
+ </li>
61
+ );
62
+ })}
63
+ </ul>
64
+ </nav>
65
+ );
66
+ }
@@ -0,0 +1,160 @@
1
+ import * as React from "react";
2
+ import { Button, FormField, Textarea, TextInput } from "@echothink-ui/core";
3
+ import { DocumentIcon, MessageIcon } from "@echothink-ui/icons";
4
+ import type { MessageDraft } from "../types";
5
+ import { inputToList, listToInput } from "../utils";
6
+ import { AttachmentList } from "./AttachmentList";
7
+
8
+ export interface MessageComposerProps extends Omit<
9
+ React.FormHTMLAttributes<HTMLFormElement>,
10
+ "onChange" | "onSubmit"
11
+ > {
12
+ draft?: MessageDraft;
13
+ onChange?: (draft: MessageDraft) => void;
14
+ onSend?: (draft: MessageDraft) => void;
15
+ onDiscard?: () => void;
16
+ onAttach?: () => void;
17
+ recipients?: React.ReactNode;
18
+ attachments?: React.ReactNode;
19
+ statusText?: React.ReactNode;
20
+ }
21
+
22
+ export function MessageComposer({
23
+ draft = {},
24
+ onChange,
25
+ onSend,
26
+ onDiscard,
27
+ onAttach,
28
+ recipients,
29
+ attachments,
30
+ statusText,
31
+ className,
32
+ ...props
33
+ }: MessageComposerProps) {
34
+ const [localDraft, setLocalDraft] = React.useState<MessageDraft>(draft);
35
+
36
+ React.useEffect(() => setLocalDraft(draft), [draft]);
37
+
38
+ const update = (next: MessageDraft) => {
39
+ setLocalDraft(next);
40
+ onChange?.(next);
41
+ };
42
+
43
+ const draftAttachments = localDraft.attachments ?? [];
44
+ const hasAttachmentsRegion = Boolean(attachments || draftAttachments.length || onAttach);
45
+
46
+ return (
47
+ <form
48
+ {...props}
49
+ className={["eth-inbox-composer", className].filter(Boolean).join(" ")}
50
+ data-eth-component="MessageComposer"
51
+ onSubmit={(event) => {
52
+ event.preventDefault();
53
+ onSend?.(localDraft);
54
+ }}
55
+ >
56
+ <div className="eth-inbox-composer__fields">
57
+ {recipients ?? (
58
+ <div className="eth-inbox-composer__address-fields" role="group" aria-label="Recipients">
59
+ <FormField
60
+ className="eth-inbox-composer__field eth-inbox-composer__field--address"
61
+ label="To"
62
+ >
63
+ <TextInput
64
+ density="compact"
65
+ placeholder="name@company.com"
66
+ value={listToInput(localDraft.to)}
67
+ onChange={(event) =>
68
+ update({ ...localDraft, to: inputToList(event.currentTarget.value) })
69
+ }
70
+ />
71
+ </FormField>
72
+ <FormField
73
+ className="eth-inbox-composer__field eth-inbox-composer__field--address"
74
+ label="CC"
75
+ >
76
+ <TextInput
77
+ density="compact"
78
+ placeholder="Optional"
79
+ value={listToInput(localDraft.cc)}
80
+ onChange={(event) =>
81
+ update({ ...localDraft, cc: inputToList(event.currentTarget.value) })
82
+ }
83
+ />
84
+ </FormField>
85
+ <FormField
86
+ className="eth-inbox-composer__field eth-inbox-composer__field--address"
87
+ label="BCC"
88
+ >
89
+ <TextInput
90
+ density="compact"
91
+ placeholder="Optional"
92
+ value={listToInput(localDraft.bcc)}
93
+ onChange={(event) =>
94
+ update({ ...localDraft, bcc: inputToList(event.currentTarget.value) })
95
+ }
96
+ />
97
+ </FormField>
98
+ </div>
99
+ )}
100
+ <FormField
101
+ className="eth-inbox-composer__field eth-inbox-composer__field--subject"
102
+ label="Subject"
103
+ >
104
+ <TextInput
105
+ density="compact"
106
+ value={localDraft.subject ?? ""}
107
+ onChange={(event) => update({ ...localDraft, subject: event.currentTarget.value })}
108
+ />
109
+ </FormField>
110
+ <FormField
111
+ className="eth-inbox-composer__field eth-inbox-composer__field--body"
112
+ label="Body"
113
+ >
114
+ <Textarea
115
+ value={localDraft.body ?? ""}
116
+ rows={10}
117
+ onChange={(event) => update({ ...localDraft, body: event.currentTarget.value })}
118
+ />
119
+ </FormField>
120
+ </div>
121
+
122
+ {hasAttachmentsRegion ? (
123
+ <section className="eth-inbox-composer__attachments" aria-label="Draft attachments">
124
+ <div className="eth-inbox-composer__attachments-header">
125
+ <span>Attachments</span>
126
+ {onAttach ? (
127
+ <Button
128
+ type="button"
129
+ intent="tertiary"
130
+ density="compact"
131
+ icon={<DocumentIcon size={16} />}
132
+ onClick={onAttach}
133
+ >
134
+ Attach file
135
+ </Button>
136
+ ) : null}
137
+ </div>
138
+ {attachments ??
139
+ (draftAttachments.length ? (
140
+ <AttachmentList attachments={draftAttachments} />
141
+ ) : (
142
+ <p className="eth-inbox-composer__empty-attachments">No attachments added</p>
143
+ ))}
144
+ </section>
145
+ ) : null}
146
+
147
+ <div className="eth-inbox-composer__footer">
148
+ {statusText ? <p className="eth-inbox-composer__status">{statusText}</p> : null}
149
+ <div className="eth-inbox-composer__actions">
150
+ <Button type="submit" icon={<MessageIcon size={16} />}>
151
+ Send
152
+ </Button>
153
+ <Button type="button" intent="tertiary" onClick={onDiscard}>
154
+ Discard
155
+ </Button>
156
+ </div>
157
+ </div>
158
+ </form>
159
+ );
160
+ }
@@ -0,0 +1,34 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { MessageFilterBar, type MessageFilter } from "./MessageFilterBar";
4
+
5
+ const filters = [
6
+ { id: "priority", label: "High priority", kind: "priority", active: true },
7
+ { id: "sender", label: "Jane Doe", kind: "sender" },
8
+ { id: "archived", label: "Archived", kind: "status", disabled: true }
9
+ ] satisfies MessageFilter[];
10
+
11
+ describe("@echothink-ui/inbox MessageFilterBar", () => {
12
+ it("renders typed filter buttons with pressed state semantics", () => {
13
+ render(<MessageFilterBar filters={filters} />);
14
+
15
+ const priority = screen.getByRole("button", { name: "Priority: High priority" });
16
+ expect(priority.getAttribute("aria-pressed")).toBe("true");
17
+ expect(screen.getByText("Priority")).toBeTruthy();
18
+ expect(screen.getByText("High priority")).toBeTruthy();
19
+
20
+ const sender = screen.getByRole("button", { name: "Sender: Jane Doe" });
21
+ expect(sender.getAttribute("aria-pressed")).toBe("false");
22
+ });
23
+
24
+ it("toggles enabled filters and leaves disabled filters inert", () => {
25
+ const onToggle = vi.fn();
26
+ render(<MessageFilterBar filters={filters} onToggle={onToggle} />);
27
+
28
+ fireEvent.click(screen.getByRole("button", { name: "Sender: Jane Doe" }));
29
+ expect(onToggle).toHaveBeenCalledWith("sender", true);
30
+
31
+ fireEvent.click(screen.getByRole("button", { name: "Status: Archived" }));
32
+ expect(onToggle).toHaveBeenCalledTimes(1);
33
+ });
34
+ });
@@ -0,0 +1,69 @@
1
+ import * as React from "react";
2
+
3
+ export interface MessageFilter {
4
+ id: string;
5
+ label: string;
6
+ active?: boolean;
7
+ disabled?: boolean;
8
+ kind?: "priority" | "sender" | "date" | "label" | "unread" | "status";
9
+ }
10
+
11
+ export interface MessageFilterBarProps
12
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onToggle"> {
13
+ filters?: MessageFilter[];
14
+ onToggle?: (id: string, active: boolean) => void;
15
+ }
16
+
17
+ const filterKindLabels: Record<NonNullable<MessageFilter["kind"]>, string> = {
18
+ priority: "Priority",
19
+ sender: "Sender",
20
+ date: "Date",
21
+ label: "Label",
22
+ unread: "Read",
23
+ status: "Status"
24
+ };
25
+
26
+ export function MessageFilterBar({
27
+ filters = [],
28
+ onToggle,
29
+ className,
30
+ "aria-label": ariaLabel = "Message filters",
31
+ ...props
32
+ }: MessageFilterBarProps) {
33
+ return (
34
+ <div
35
+ {...props}
36
+ className={`eth-inbox-filter-bar ${className ?? ""}`}
37
+ data-eth-component="MessageFilterBar"
38
+ role="toolbar"
39
+ aria-label={ariaLabel}
40
+ >
41
+ {filters.map((filter) => {
42
+ const kindLabel = filter.kind ? filterKindLabels[filter.kind] : undefined;
43
+ const accessibleLabel = kindLabel ? `${kindLabel}: ${filter.label}` : filter.label;
44
+
45
+ return (
46
+ <button
47
+ key={filter.id}
48
+ type="button"
49
+ className={`eth-inbox-filter-bar__chip ${
50
+ filter.active ? "eth-inbox-filter-bar__chip--active" : ""
51
+ } ${filter.disabled ? "eth-inbox-filter-bar__chip--disabled" : ""}`}
52
+ aria-label={accessibleLabel}
53
+ aria-pressed={Boolean(filter.active)}
54
+ data-filter-kind={filter.kind}
55
+ disabled={filter.disabled}
56
+ onClick={() => onToggle?.(filter.id, !Boolean(filter.active))}
57
+ >
58
+ {kindLabel ? (
59
+ <span className="eth-inbox-filter-bar__kind" aria-hidden="true">
60
+ {kindLabel}
61
+ </span>
62
+ ) : null}
63
+ <span className="eth-inbox-filter-bar__label">{filter.label}</span>
64
+ </button>
65
+ );
66
+ })}
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,101 @@
1
+ import * as React from "react";
2
+ import { Badge, StatusDot, Tag, statusLabel } from "@echothink-ui/core";
3
+ import type { InboxThread } from "../types";
4
+ import { formatDateTime, prioritySeverity } from "../utils";
5
+
6
+ export interface MessageListProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
7
+ threads?: InboxThread[];
8
+ selectedThreadId?: string;
9
+ onSelect?: (threadId: string) => void;
10
+ }
11
+
12
+ export function MessageList({
13
+ threads = [],
14
+ selectedThreadId,
15
+ onSelect,
16
+ className,
17
+ ...props
18
+ }: MessageListProps) {
19
+ const hasThreads = threads.length > 0;
20
+
21
+ return (
22
+ <div
23
+ {...props}
24
+ className={`eth-inbox-message-list ${className ?? ""}`}
25
+ role="listbox"
26
+ aria-label="Message threads"
27
+ data-eth-component="MessageList"
28
+ >
29
+ {hasThreads ? (
30
+ threads.map((thread, index) => {
31
+ const receivedAtLabel = formatDateTime(thread.receivedAt);
32
+ const selected = selectedThreadId === thread.id;
33
+ const hasMeta = Boolean(thread.priority || thread.labels?.length || thread.status);
34
+
35
+ return (
36
+ <button
37
+ key={thread.id}
38
+ type="button"
39
+ role="option"
40
+ aria-label={[
41
+ thread.unread ? "Unread" : "Read",
42
+ `From ${thread.from}`,
43
+ thread.subject,
44
+ `Received ${receivedAtLabel}`,
45
+ thread.priority ? `${thread.priority} priority` : null,
46
+ thread.status ? statusLabel(thread.status) : null
47
+ ]
48
+ .filter(Boolean)
49
+ .join(", ")}
50
+ aria-posinset={index + 1}
51
+ aria-selected={selected}
52
+ aria-setsize={threads.length}
53
+ className={`eth-inbox-message-list__row ${
54
+ thread.unread ? "eth-inbox-message-list__row--unread" : ""
55
+ }`}
56
+ onClick={() => onSelect?.(thread.id)}
57
+ >
58
+ <span className="eth-inbox-message-list__selection" aria-hidden="true" />
59
+ <span className="eth-inbox-message-list__content">
60
+ <span className="eth-inbox-message-list__header">
61
+ <span className="eth-inbox-message-list__from-group">
62
+ <span className="eth-inbox-message-list__unread" aria-hidden="true" />
63
+ <span className="eth-inbox-message-list__from">{thread.from}</span>
64
+ </span>
65
+ <time className="eth-inbox-message-list__date" dateTime={thread.receivedAt}>
66
+ {receivedAtLabel}
67
+ </time>
68
+ </span>
69
+ <strong className="eth-inbox-message-list__subject">{thread.subject}</strong>
70
+ <span className="eth-inbox-message-list__preview">{thread.preview}</span>
71
+ {hasMeta ? (
72
+ <span className="eth-inbox-message-list__meta">
73
+ {thread.priority ? (
74
+ <Badge severity={prioritySeverity(thread.priority)}>
75
+ {thread.priority}
76
+ </Badge>
77
+ ) : null}
78
+ {thread.labels?.map((label) => (
79
+ <Tag key={label}>{label}</Tag>
80
+ ))}
81
+ {thread.status ? (
82
+ <StatusDot status={thread.status} label={statusLabel(thread.status)} />
83
+ ) : null}
84
+ </span>
85
+ ) : null}
86
+ </span>
87
+ </button>
88
+ );
89
+ })
90
+ ) : (
91
+ <div
92
+ className="eth-inbox-message-list__empty"
93
+ role="status"
94
+ aria-label="No message threads"
95
+ >
96
+ No message threads
97
+ </div>
98
+ )}
99
+ </div>
100
+ );
101
+ }
@@ -0,0 +1,48 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { MessagePreview } from "./MessagePreview";
4
+ import type { ThreadMessage } from "../types";
5
+
6
+ const message: ThreadMessage = {
7
+ id: "m1",
8
+ from: "Jane Doe",
9
+ to: ["you@echothink.example"],
10
+ cc: ["launch-ops@echothink.example"],
11
+ date: "2026-05-27T10:42:00Z",
12
+ subject: "Q3 launch - final brief",
13
+ body: "Please review the attached campaign brief and approve the messaging direction.",
14
+ attachments: [{ id: "a1", name: "brief.pdf", size: 240_000, mimeType: "application/pdf" }]
15
+ };
16
+
17
+ describe("@echothink-ui/inbox MessagePreview", () => {
18
+ it("renders the selected message as an article with structured routing metadata", () => {
19
+ render(<MessagePreview message={message} />);
20
+
21
+ expect(screen.getByRole("article", { name: "Q3 launch - final brief" })).toBeTruthy();
22
+ expect(screen.getByText("From")).toBeTruthy();
23
+ expect(screen.getByText("Jane Doe")).toBeTruthy();
24
+ expect(screen.getByText("To")).toBeTruthy();
25
+ expect(screen.getByText("you@echothink.example")).toBeTruthy();
26
+ expect(screen.getByText("Cc")).toBeTruthy();
27
+ expect(screen.getByText("launch-ops@echothink.example")).toBeTruthy();
28
+ });
29
+
30
+ it("wires attachment preview and download actions from the message pane", () => {
31
+ const onPreviewAttachment = vi.fn();
32
+ const onDownloadAttachment = vi.fn();
33
+
34
+ render(
35
+ <MessagePreview
36
+ message={message}
37
+ onPreviewAttachment={onPreviewAttachment}
38
+ onDownloadAttachment={onDownloadAttachment}
39
+ />
40
+ );
41
+
42
+ fireEvent.click(screen.getByRole("button", { name: "Preview brief.pdf" }));
43
+ fireEvent.click(screen.getByRole("button", { name: "Download brief.pdf" }));
44
+
45
+ expect(onPreviewAttachment).toHaveBeenCalledWith(message.attachments![0]);
46
+ expect(onDownloadAttachment).toHaveBeenCalledWith(message.attachments![0]);
47
+ });
48
+ });