@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.
- package/README.md +5 -0
- package/dist/components/AgentDraftReviewPanel.d.ts +18 -0
- package/dist/components/ApprovalToSendPanel.d.ts +11 -0
- package/dist/components/AttachmentList.d.ts +8 -0
- package/dist/components/AttachmentPreview.d.ts +7 -0
- package/dist/components/EmailAutomationRulePanel.d.ts +8 -0
- package/dist/components/InboxShell.d.ts +10 -0
- package/dist/components/LabelManager.d.ts +9 -0
- package/dist/components/MailboxFolderList.d.ts +8 -0
- package/dist/components/MessageComposer.d.ts +13 -0
- package/dist/components/MessageFilterBar.d.ts +13 -0
- package/dist/components/MessageList.d.ts +8 -0
- package/dist/components/MessagePreview.d.ts +9 -0
- package/dist/components/MessageSearch.d.ts +11 -0
- package/dist/components/MessageThread.d.ts +22 -0
- package/dist/components/PriorityInboxView.d.ts +14 -0
- package/dist/components/RecipientPicker.d.ts +10 -0
- package/dist/components/ThreadSummaryPanel.d.ts +9 -0
- package/dist/index.cjs +1699 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +2155 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1645 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +75 -0
- package/dist/utils.d.ts +5 -0
- package/package.json +44 -0
- package/src/components/AgentDraftReviewPanel.tsx +128 -0
- package/src/components/ApprovalToSendPanel.tsx +118 -0
- package/src/components/AttachmentList.tsx +85 -0
- package/src/components/AttachmentPreview.tsx +207 -0
- package/src/components/EmailAutomationRulePanel.tsx +100 -0
- package/src/components/InboxShell.tsx +62 -0
- package/src/components/LabelManager.tsx +147 -0
- package/src/components/MailboxFolderList.tsx +66 -0
- package/src/components/MessageComposer.tsx +160 -0
- package/src/components/MessageFilterBar.test.tsx +34 -0
- package/src/components/MessageFilterBar.tsx +69 -0
- package/src/components/MessageList.tsx +101 -0
- package/src/components/MessagePreview.test.tsx +48 -0
- package/src/components/MessagePreview.tsx +84 -0
- package/src/components/MessageSearch.tsx +96 -0
- package/src/components/MessageThread.tsx +173 -0
- package/src/components/PriorityInboxView.tsx +74 -0
- package/src/components/RecipientPicker.tsx +181 -0
- package/src/components/ThreadSummaryPanel.tsx +107 -0
- package/src/index.test.tsx +276 -0
- package/src/index.tsx +60 -0
- package/src/styles.css +2523 -0
- package/src/types.ts +85 -0
- package/src/utils.ts +33 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { Attachment, ThreadMessage } from "../types";
|
|
3
|
+
import { formatDateTime } from "../utils";
|
|
4
|
+
import { AttachmentList } from "./AttachmentList";
|
|
5
|
+
|
|
6
|
+
export interface MessagePreviewProps extends React.HTMLAttributes<HTMLElement> {
|
|
7
|
+
message: ThreadMessage;
|
|
8
|
+
showSubject?: boolean;
|
|
9
|
+
onPreviewAttachment?: (attachment: Attachment) => void;
|
|
10
|
+
onDownloadAttachment?: (attachment: Attachment) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function MessagePreview({
|
|
14
|
+
message,
|
|
15
|
+
showSubject = true,
|
|
16
|
+
onPreviewAttachment,
|
|
17
|
+
onDownloadAttachment,
|
|
18
|
+
className,
|
|
19
|
+
"aria-label": ariaLabel,
|
|
20
|
+
"aria-labelledby": ariaLabelledBy,
|
|
21
|
+
...props
|
|
22
|
+
}: MessagePreviewProps) {
|
|
23
|
+
const subjectId = React.useId();
|
|
24
|
+
const attachmentsHeadingId = React.useId();
|
|
25
|
+
const visibleSubject = showSubject ? message.subject : undefined;
|
|
26
|
+
const labelledBy = ariaLabel
|
|
27
|
+
? ariaLabelledBy
|
|
28
|
+
: ariaLabelledBy ?? (visibleSubject ? subjectId : undefined);
|
|
29
|
+
const computedAriaLabel = ariaLabel ?? (labelledBy ? undefined : "Message preview");
|
|
30
|
+
const hasAttachments = Boolean(message.attachments?.length);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<article
|
|
34
|
+
{...props}
|
|
35
|
+
className={["eth-inbox-message-preview", className].filter(Boolean).join(" ")}
|
|
36
|
+
data-eth-component="MessagePreview"
|
|
37
|
+
aria-label={computedAriaLabel}
|
|
38
|
+
aria-labelledby={labelledBy}
|
|
39
|
+
>
|
|
40
|
+
<header className="eth-inbox-message-preview__header">
|
|
41
|
+
<div className="eth-inbox-message-preview__heading">
|
|
42
|
+
{visibleSubject ? <h3 id={subjectId}>{visibleSubject}</h3> : null}
|
|
43
|
+
<dl className="eth-inbox-message-preview__recipients" aria-label="Message routing">
|
|
44
|
+
<div>
|
|
45
|
+
<dt>From</dt>
|
|
46
|
+
<dd>{message.from}</dd>
|
|
47
|
+
</div>
|
|
48
|
+
<div>
|
|
49
|
+
<dt>To</dt>
|
|
50
|
+
<dd>{formatRecipients(message.to)}</dd>
|
|
51
|
+
</div>
|
|
52
|
+
{message.cc?.length ? (
|
|
53
|
+
<div>
|
|
54
|
+
<dt>Cc</dt>
|
|
55
|
+
<dd>{formatRecipients(message.cc)}</dd>
|
|
56
|
+
</div>
|
|
57
|
+
) : null}
|
|
58
|
+
</dl>
|
|
59
|
+
</div>
|
|
60
|
+
<time dateTime={message.date}>{formatDateTime(message.date)}</time>
|
|
61
|
+
</header>
|
|
62
|
+
<div className="eth-inbox-message-preview__body">
|
|
63
|
+
<p>{message.body}</p>
|
|
64
|
+
</div>
|
|
65
|
+
{hasAttachments ? (
|
|
66
|
+
<section
|
|
67
|
+
className="eth-inbox-message-preview__attachments"
|
|
68
|
+
aria-labelledby={attachmentsHeadingId}
|
|
69
|
+
>
|
|
70
|
+
<h4 id={attachmentsHeadingId}>Attachments</h4>
|
|
71
|
+
<AttachmentList
|
|
72
|
+
attachments={message.attachments}
|
|
73
|
+
onPreview={onPreviewAttachment}
|
|
74
|
+
onDownload={onDownloadAttachment}
|
|
75
|
+
/>
|
|
76
|
+
</section>
|
|
77
|
+
) : null}
|
|
78
|
+
</article>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatRecipients(recipients: string[]) {
|
|
83
|
+
return recipients.length ? recipients.join(", ") : "No recipients";
|
|
84
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { IconButton, SearchInput } from "@echothink-ui/core";
|
|
3
|
+
import { ChevronRightIcon } from "@echothink-ui/icons";
|
|
4
|
+
|
|
5
|
+
export interface MessageSearchProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
6
|
+
value?: string;
|
|
7
|
+
onChange?: (value: string) => void;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
resultCount?: number;
|
|
10
|
+
activeResultIndex?: number;
|
|
11
|
+
onPreviousResult?: () => void;
|
|
12
|
+
onNextResult?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function MessageSearch({
|
|
16
|
+
value = "",
|
|
17
|
+
onChange,
|
|
18
|
+
placeholder = "Search messages",
|
|
19
|
+
resultCount,
|
|
20
|
+
activeResultIndex = 0,
|
|
21
|
+
onPreviousResult,
|
|
22
|
+
onNextResult,
|
|
23
|
+
className,
|
|
24
|
+
role,
|
|
25
|
+
"aria-label": ariaLabel,
|
|
26
|
+
...props
|
|
27
|
+
}: MessageSearchProps) {
|
|
28
|
+
const hasResultCount = typeof resultCount === "number";
|
|
29
|
+
const normalizedResultCount =
|
|
30
|
+
hasResultCount && Number.isFinite(resultCount) ? Math.max(0, Math.trunc(resultCount)) : 0;
|
|
31
|
+
const normalizedResultIndex = Number.isFinite(activeResultIndex)
|
|
32
|
+
? Math.trunc(activeResultIndex)
|
|
33
|
+
: 0;
|
|
34
|
+
const hasResults = normalizedResultCount > 0;
|
|
35
|
+
const currentResult = hasResults
|
|
36
|
+
? Math.min(Math.max(normalizedResultIndex, 0), normalizedResultCount - 1) + 1
|
|
37
|
+
: 0;
|
|
38
|
+
const canGoPrevious = hasResults && currentResult > 1 && Boolean(onPreviousResult);
|
|
39
|
+
const canGoNext = hasResults && currentResult < normalizedResultCount && Boolean(onNextResult);
|
|
40
|
+
const resultNoun = normalizedResultCount === 1 ? "result" : "results";
|
|
41
|
+
const resultLabel = hasResults
|
|
42
|
+
? `${currentResult} of ${normalizedResultCount} ${resultNoun}`
|
|
43
|
+
: "No results";
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
{...props}
|
|
48
|
+
className={["eth-inbox-search", className].filter(Boolean).join(" ")}
|
|
49
|
+
data-eth-component="MessageSearch"
|
|
50
|
+
role={role ?? "search"}
|
|
51
|
+
aria-label={ariaLabel ?? "Message search"}
|
|
52
|
+
>
|
|
53
|
+
<div className="eth-inbox-search__input">
|
|
54
|
+
<SearchInput
|
|
55
|
+
value={value}
|
|
56
|
+
placeholder={placeholder}
|
|
57
|
+
labelText={placeholder}
|
|
58
|
+
onChange={(event) => onChange?.(event.currentTarget.value)}
|
|
59
|
+
onClear={() => onChange?.("")}
|
|
60
|
+
className="eth-inbox-search__control"
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
{hasResultCount ? (
|
|
64
|
+
<div className="eth-inbox-search__results">
|
|
65
|
+
<span className="eth-inbox-search__count" aria-live="polite">
|
|
66
|
+
{resultLabel}
|
|
67
|
+
</span>
|
|
68
|
+
<div
|
|
69
|
+
className="eth-inbox-search__nav"
|
|
70
|
+
role="group"
|
|
71
|
+
aria-label="Search result navigation"
|
|
72
|
+
>
|
|
73
|
+
<IconButton
|
|
74
|
+
className="eth-inbox-search__nav-button eth-inbox-search__nav-button--previous"
|
|
75
|
+
density="compact"
|
|
76
|
+
intent="ghost"
|
|
77
|
+
label="Previous result"
|
|
78
|
+
icon={<ChevronRightIcon size={16} />}
|
|
79
|
+
disabled={!canGoPrevious}
|
|
80
|
+
onClick={onPreviousResult}
|
|
81
|
+
/>
|
|
82
|
+
<IconButton
|
|
83
|
+
className="eth-inbox-search__nav-button"
|
|
84
|
+
density="compact"
|
|
85
|
+
intent="ghost"
|
|
86
|
+
label="Next result"
|
|
87
|
+
icon={<ChevronRightIcon size={16} />}
|
|
88
|
+
disabled={!canGoNext}
|
|
89
|
+
onClick={onNextResult}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
) : null}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Badge, Button, StatusDot } from "@echothink-ui/core";
|
|
3
|
+
import type { EthDensity, EthIntent, EthOperationalStatus } from "@echothink-ui/core";
|
|
4
|
+
import type { Attachment, MessageThreadModel, ThreadMessage } from "../types";
|
|
5
|
+
import { formatDateTime } from "../utils";
|
|
6
|
+
import { MessagePreview } from "./MessagePreview";
|
|
7
|
+
|
|
8
|
+
export interface MessageThreadAction {
|
|
9
|
+
id: string;
|
|
10
|
+
label: React.ReactNode;
|
|
11
|
+
icon?: React.ReactNode;
|
|
12
|
+
intent?: EthIntent;
|
|
13
|
+
density?: EthDensity;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
onSelect?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MessageThreadProps extends React.HTMLAttributes<HTMLElement> {
|
|
19
|
+
thread?: MessageThreadModel;
|
|
20
|
+
summaryRef?: React.ReactNode;
|
|
21
|
+
status?: EthOperationalStatus;
|
|
22
|
+
statusLabel?: React.ReactNode;
|
|
23
|
+
actions?: MessageThreadAction[];
|
|
24
|
+
onPreviewAttachment?: (attachment: Attachment) => void;
|
|
25
|
+
onDownloadAttachment?: (attachment: Attachment) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function MessageThread({
|
|
29
|
+
thread,
|
|
30
|
+
summaryRef,
|
|
31
|
+
status,
|
|
32
|
+
statusLabel,
|
|
33
|
+
actions = [],
|
|
34
|
+
onPreviewAttachment,
|
|
35
|
+
onDownloadAttachment,
|
|
36
|
+
className,
|
|
37
|
+
...props
|
|
38
|
+
}: MessageThreadProps) {
|
|
39
|
+
const messages = React.useMemo(
|
|
40
|
+
() =>
|
|
41
|
+
[...(thread?.messages ?? [])].sort(
|
|
42
|
+
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
43
|
+
),
|
|
44
|
+
[thread?.messages]
|
|
45
|
+
);
|
|
46
|
+
const older = messages.slice(0, Math.max(0, messages.length - 3));
|
|
47
|
+
const recent = messages.slice(Math.max(0, messages.length - 3));
|
|
48
|
+
const participantCount = countParticipants(messages);
|
|
49
|
+
const attachmentCount = messages.reduce(
|
|
50
|
+
(count, message) => count + (message.attachments?.length ?? 0),
|
|
51
|
+
0
|
|
52
|
+
);
|
|
53
|
+
const latestMessage = messages.at(-1);
|
|
54
|
+
const messageLabel = formatCount(messages.length, "message");
|
|
55
|
+
const participantLabel = formatCount(participantCount, "participant");
|
|
56
|
+
const attachmentLabel = formatCount(attachmentCount, "attachment");
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<article
|
|
60
|
+
{...props}
|
|
61
|
+
className={`eth-inbox-thread ${className ?? ""}`}
|
|
62
|
+
data-eth-component="MessageThread"
|
|
63
|
+
>
|
|
64
|
+
<header className="eth-inbox-thread__header">
|
|
65
|
+
<div className="eth-inbox-thread__heading">
|
|
66
|
+
<span className="eth-inbox-thread__eyebrow">Conversation thread</span>
|
|
67
|
+
<h2>{thread?.subject ?? "Thread"}</h2>
|
|
68
|
+
<ul className="eth-inbox-thread__meta" aria-label="Thread metadata">
|
|
69
|
+
<li>
|
|
70
|
+
<Badge severity="neutral">{messageLabel}</Badge>
|
|
71
|
+
</li>
|
|
72
|
+
<li>
|
|
73
|
+
<Badge severity="neutral">{participantLabel}</Badge>
|
|
74
|
+
</li>
|
|
75
|
+
{attachmentCount ? (
|
|
76
|
+
<li>
|
|
77
|
+
<Badge severity="info">{attachmentLabel}</Badge>
|
|
78
|
+
</li>
|
|
79
|
+
) : null}
|
|
80
|
+
{status ? (
|
|
81
|
+
<li>
|
|
82
|
+
<StatusDot status={status} label={statusLabel ?? statusToLabel(status)} />
|
|
83
|
+
</li>
|
|
84
|
+
) : null}
|
|
85
|
+
{latestMessage ? (
|
|
86
|
+
<li className="eth-inbox-thread__updated">
|
|
87
|
+
Last activity{" "}
|
|
88
|
+
<time dateTime={latestMessage.date}>{formatDateTime(latestMessage.date)}</time>
|
|
89
|
+
</li>
|
|
90
|
+
) : null}
|
|
91
|
+
</ul>
|
|
92
|
+
</div>
|
|
93
|
+
{actions.length ? (
|
|
94
|
+
<div className="eth-inbox-thread__actions" role="group" aria-label="Thread actions">
|
|
95
|
+
{actions.map((action) => (
|
|
96
|
+
<Button
|
|
97
|
+
key={action.id}
|
|
98
|
+
intent={action.intent ?? "secondary"}
|
|
99
|
+
density={action.density ?? "compact"}
|
|
100
|
+
disabled={action.disabled}
|
|
101
|
+
icon={action.icon}
|
|
102
|
+
onClick={action.onSelect}
|
|
103
|
+
>
|
|
104
|
+
{action.label}
|
|
105
|
+
</Button>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
) : null}
|
|
109
|
+
</header>
|
|
110
|
+
{summaryRef ? (
|
|
111
|
+
<aside className="eth-inbox-thread__summary" aria-label="Thread summary">
|
|
112
|
+
<span className="eth-inbox-thread__summary-label">Thread summary</span>
|
|
113
|
+
<div className="eth-inbox-thread__summary-body">{summaryRef}</div>
|
|
114
|
+
</aside>
|
|
115
|
+
) : null}
|
|
116
|
+
{!messages.length ? (
|
|
117
|
+
<div className="eth-inbox-thread__empty" role="status">
|
|
118
|
+
<strong>No messages in this thread</strong>
|
|
119
|
+
<span>Replies, summaries, and attachment activity will appear here.</span>
|
|
120
|
+
</div>
|
|
121
|
+
) : null}
|
|
122
|
+
{older.length ? (
|
|
123
|
+
<details className="eth-inbox-thread__older">
|
|
124
|
+
<summary>{formatCount(older.length, "older message")}</summary>
|
|
125
|
+
{older.map((message) => (
|
|
126
|
+
<MessagePreview
|
|
127
|
+
key={message.id}
|
|
128
|
+
message={message}
|
|
129
|
+
showSubject={false}
|
|
130
|
+
onPreviewAttachment={onPreviewAttachment}
|
|
131
|
+
onDownloadAttachment={onDownloadAttachment}
|
|
132
|
+
/>
|
|
133
|
+
))}
|
|
134
|
+
</details>
|
|
135
|
+
) : null}
|
|
136
|
+
<div className="eth-inbox-thread__messages">
|
|
137
|
+
{recent.map((message) => (
|
|
138
|
+
<MessagePreview
|
|
139
|
+
key={message.id}
|
|
140
|
+
message={message}
|
|
141
|
+
showSubject={false}
|
|
142
|
+
onPreviewAttachment={onPreviewAttachment}
|
|
143
|
+
onDownloadAttachment={onDownloadAttachment}
|
|
144
|
+
/>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
</article>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function countParticipants(messages: ThreadMessage[]) {
|
|
152
|
+
const participants = new Set<string>();
|
|
153
|
+
|
|
154
|
+
messages.forEach((message) => {
|
|
155
|
+
participants.add(message.from);
|
|
156
|
+
message.to.forEach((recipient) => participants.add(recipient));
|
|
157
|
+
message.cc?.forEach((recipient) => participants.add(recipient));
|
|
158
|
+
message.bcc?.forEach((recipient) => participants.add(recipient));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return participants.size;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function formatCount(value: number, singular: string) {
|
|
165
|
+
return `${value} ${value === 1 ? singular : `${singular}s`}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function statusToLabel(status: EthOperationalStatus) {
|
|
169
|
+
return status
|
|
170
|
+
.split("-")
|
|
171
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
172
|
+
.join(" ");
|
|
173
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { InboxThread } from "../types";
|
|
3
|
+
import { MessageList } from "./MessageList";
|
|
4
|
+
|
|
5
|
+
export interface PriorityInboxGroup {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
description?: React.ReactNode;
|
|
9
|
+
threads: InboxThread[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PriorityInboxViewProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
|
13
|
+
groups?: PriorityInboxGroup[];
|
|
14
|
+
selectedThreadId?: string;
|
|
15
|
+
onSelect?: (threadId: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function PriorityInboxView({
|
|
19
|
+
groups = [],
|
|
20
|
+
selectedThreadId,
|
|
21
|
+
onSelect,
|
|
22
|
+
className,
|
|
23
|
+
...props
|
|
24
|
+
}: PriorityInboxViewProps) {
|
|
25
|
+
const hasGroups = groups.length > 0;
|
|
26
|
+
const idBase = React.useId();
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
{...props}
|
|
31
|
+
className={`eth-inbox-priority ${className ?? ""}`}
|
|
32
|
+
data-eth-component="PriorityInboxView"
|
|
33
|
+
>
|
|
34
|
+
{hasGroups ? (
|
|
35
|
+
groups.map((group, index) => {
|
|
36
|
+
const headingId = `${idBase}-group-${index}-heading`;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<section
|
|
40
|
+
key={group.id}
|
|
41
|
+
className="eth-inbox-priority__group"
|
|
42
|
+
aria-labelledby={headingId}
|
|
43
|
+
>
|
|
44
|
+
<header className="eth-inbox-priority__group-header">
|
|
45
|
+
<div className="eth-inbox-priority__heading">
|
|
46
|
+
<h3 id={headingId}>{group.title}</h3>
|
|
47
|
+
{group.description ? (
|
|
48
|
+
<p className="eth-inbox-priority__description">{group.description}</p>
|
|
49
|
+
) : null}
|
|
50
|
+
</div>
|
|
51
|
+
<span className="eth-inbox-priority__count">
|
|
52
|
+
{formatThreadCount(group.threads.length)}
|
|
53
|
+
</span>
|
|
54
|
+
</header>
|
|
55
|
+
<MessageList
|
|
56
|
+
threads={group.threads}
|
|
57
|
+
selectedThreadId={selectedThreadId}
|
|
58
|
+
onSelect={onSelect}
|
|
59
|
+
/>
|
|
60
|
+
</section>
|
|
61
|
+
);
|
|
62
|
+
})
|
|
63
|
+
) : (
|
|
64
|
+
<div className="eth-inbox-priority__empty" role="status" aria-label="No priority threads">
|
|
65
|
+
No priority threads
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatThreadCount(count: number) {
|
|
73
|
+
return `${count} ${count === 1 ? "thread" : "threads"}`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Button, SearchInput, Tag, TextInput } from "@echothink-ui/core";
|
|
3
|
+
import type { RecipientSuggestion } from "../types";
|
|
4
|
+
|
|
5
|
+
export interface RecipientPickerProps
|
|
6
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
7
|
+
value?: string[];
|
|
8
|
+
onChange?: (value: string[]) => void;
|
|
9
|
+
onSearch?: (q: string) => void;
|
|
10
|
+
suggestions?: RecipientSuggestion[];
|
|
11
|
+
defaultSuggestionsOpen?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function RecipientPicker({
|
|
15
|
+
value = [],
|
|
16
|
+
onChange,
|
|
17
|
+
onSearch,
|
|
18
|
+
suggestions = [],
|
|
19
|
+
defaultSuggestionsOpen = false,
|
|
20
|
+
className,
|
|
21
|
+
...props
|
|
22
|
+
}: RecipientPickerProps) {
|
|
23
|
+
const listboxId = React.useId().replace(/:/g, "");
|
|
24
|
+
const [query, setQuery] = React.useState("");
|
|
25
|
+
const [manualValue, setManualValue] = React.useState("");
|
|
26
|
+
const [suggestionsOpen, setSuggestionsOpen] = React.useState(defaultSuggestionsOpen);
|
|
27
|
+
|
|
28
|
+
const normalizedQuery = query.trim().toLocaleLowerCase();
|
|
29
|
+
const visibleSuggestions = normalizedQuery
|
|
30
|
+
? suggestions.filter((suggestion) =>
|
|
31
|
+
[suggestion.label, suggestion.email].some((field) =>
|
|
32
|
+
field.toLocaleLowerCase().includes(normalizedQuery)
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
: suggestions;
|
|
36
|
+
const showSuggestions = suggestionsOpen && (visibleSuggestions.length > 0 || Boolean(query));
|
|
37
|
+
|
|
38
|
+
const add = (email: string) => {
|
|
39
|
+
const normalizedEmail = email.trim();
|
|
40
|
+
|
|
41
|
+
if (!normalizedEmail || value.includes(normalizedEmail)) return false;
|
|
42
|
+
|
|
43
|
+
onChange?.([...value, normalizedEmail]);
|
|
44
|
+
setManualValue("");
|
|
45
|
+
setQuery("");
|
|
46
|
+
return true;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const updateQuery = (nextQuery: string) => {
|
|
50
|
+
setQuery(nextQuery);
|
|
51
|
+
onSearch?.(nextQuery);
|
|
52
|
+
setSuggestionsOpen(Boolean(nextQuery || suggestions.length));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
{...props}
|
|
58
|
+
className={`eth-inbox-recipient-picker ${className ?? ""}`}
|
|
59
|
+
data-eth-component="RecipientPicker"
|
|
60
|
+
onBlur={(event) => {
|
|
61
|
+
props.onBlur?.(event);
|
|
62
|
+
|
|
63
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
64
|
+
setSuggestionsOpen(false);
|
|
65
|
+
}
|
|
66
|
+
}}
|
|
67
|
+
onKeyDown={(event) => {
|
|
68
|
+
props.onKeyDown?.(event);
|
|
69
|
+
|
|
70
|
+
if (!event.defaultPrevented && event.key === "Escape") {
|
|
71
|
+
setSuggestionsOpen(false);
|
|
72
|
+
}
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<div className="eth-inbox-recipient-picker__selected" aria-label="Selected recipients">
|
|
76
|
+
<span className="eth-inbox-recipient-picker__label">To</span>
|
|
77
|
+
<div className="eth-inbox-recipient-picker__chips">
|
|
78
|
+
{value.length ? (
|
|
79
|
+
value.map((email) => (
|
|
80
|
+
<Tag
|
|
81
|
+
key={email}
|
|
82
|
+
removable
|
|
83
|
+
onRemove={() => onChange?.(value.filter((item) => item !== email))}
|
|
84
|
+
>
|
|
85
|
+
{email}
|
|
86
|
+
</Tag>
|
|
87
|
+
))
|
|
88
|
+
) : (
|
|
89
|
+
<span className="eth-inbox-recipient-picker__placeholder">No recipients selected</span>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
<SearchInput
|
|
94
|
+
aria-controls={showSuggestions ? listboxId : undefined}
|
|
95
|
+
aria-expanded={showSuggestions}
|
|
96
|
+
aria-haspopup="listbox"
|
|
97
|
+
className="eth-inbox-recipient-picker__search"
|
|
98
|
+
labelText="Search recipients"
|
|
99
|
+
value={query}
|
|
100
|
+
placeholder="Search recipients"
|
|
101
|
+
onFocus={() => setSuggestionsOpen(Boolean(suggestions.length))}
|
|
102
|
+
onChange={(event) => updateQuery(event.currentTarget.value)}
|
|
103
|
+
onClear={() => {
|
|
104
|
+
updateQuery("");
|
|
105
|
+
setSuggestionsOpen(false);
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
<div className="eth-inbox-recipient-picker__manual">
|
|
109
|
+
<TextInput
|
|
110
|
+
className="eth-inbox-recipient-picker__manual-input"
|
|
111
|
+
density="compact"
|
|
112
|
+
hideLabel
|
|
113
|
+
labelText="Recipient email"
|
|
114
|
+
type="email"
|
|
115
|
+
value={manualValue}
|
|
116
|
+
placeholder="name@company.com"
|
|
117
|
+
onChange={(event) => setManualValue(event.currentTarget.value)}
|
|
118
|
+
onKeyDown={(event) => {
|
|
119
|
+
if (event.key === "Enter") {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
add(manualValue);
|
|
122
|
+
}
|
|
123
|
+
}}
|
|
124
|
+
/>
|
|
125
|
+
<Button
|
|
126
|
+
density="compact"
|
|
127
|
+
disabled={!manualValue.trim()}
|
|
128
|
+
intent="primary"
|
|
129
|
+
onClick={() => add(manualValue)}
|
|
130
|
+
>
|
|
131
|
+
Add recipient
|
|
132
|
+
</Button>
|
|
133
|
+
</div>
|
|
134
|
+
{showSuggestions ? (
|
|
135
|
+
<div
|
|
136
|
+
className="eth-inbox-recipient-picker__suggestions"
|
|
137
|
+
id={listboxId}
|
|
138
|
+
role="listbox"
|
|
139
|
+
aria-label="Recipient suggestions"
|
|
140
|
+
aria-multiselectable
|
|
141
|
+
>
|
|
142
|
+
<div className="eth-inbox-recipient-picker__suggestions-header">
|
|
143
|
+
<span>Suggested recipients</span>
|
|
144
|
+
<strong>
|
|
145
|
+
{visibleSuggestions.length} result{visibleSuggestions.length === 1 ? "" : "s"}
|
|
146
|
+
</strong>
|
|
147
|
+
</div>
|
|
148
|
+
{visibleSuggestions.length ? (
|
|
149
|
+
<ul className="eth-inbox-recipient-picker__suggestions-list">
|
|
150
|
+
{visibleSuggestions.map((suggestion) => {
|
|
151
|
+
const isSelected = value.includes(suggestion.email);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<li key={suggestion.id}>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
role="option"
|
|
158
|
+
aria-selected={isSelected}
|
|
159
|
+
aria-disabled={isSelected ? true : undefined}
|
|
160
|
+
onClick={() => add(suggestion.email)}
|
|
161
|
+
>
|
|
162
|
+
<span className="eth-inbox-recipient-picker__suggestion-copy">
|
|
163
|
+
<strong>{suggestion.label}</strong>
|
|
164
|
+
<span>{suggestion.email}</span>
|
|
165
|
+
</span>
|
|
166
|
+
<span className="eth-inbox-recipient-picker__suggestion-status">
|
|
167
|
+
{isSelected ? "Selected" : "Add"}
|
|
168
|
+
</span>
|
|
169
|
+
</button>
|
|
170
|
+
</li>
|
|
171
|
+
);
|
|
172
|
+
})}
|
|
173
|
+
</ul>
|
|
174
|
+
) : (
|
|
175
|
+
<p className="eth-inbox-recipient-picker__empty">No matching recipients</p>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
) : null}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|