@proteos/ui 0.31.0 → 0.32.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/dist/index.cjs +1302 -516
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +259 -1
- package/dist/index.d.ts +259 -1
- package/dist/index.js +1292 -520
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/draft-message-composer/create-draft-message-client.ts +27 -0
- package/src/components/draft-message-composer/draft-message-actions-store.test.ts +62 -0
- package/src/components/draft-message-composer/draft-message-actions-store.ts +81 -0
- package/src/components/draft-message-composer/draft-message-composer.stories.tsx +175 -0
- package/src/components/draft-message-composer/draft-message-composer.test.tsx +276 -0
- package/src/components/draft-message-composer/draft-message-composer.tsx +470 -0
- package/src/components/draft-message-composer/draft-message-outcome-store.test.ts +59 -0
- package/src/components/draft-message-composer/draft-message-outcome-store.ts +56 -0
- package/src/components/draft-message-composer/index.ts +19 -0
- package/src/components/draft-message-composer/types.ts +32 -0
- package/src/components/message-composer/index.ts +13 -0
- package/src/components/message-composer/message-composer.stories.tsx +155 -0
- package/src/components/message-composer/message-composer.test.tsx +320 -0
- package/src/components/message-composer/message-composer.tsx +480 -0
- package/src/components/recipient-select/index.ts +2 -0
- package/src/components/recipient-select/recipient-select.test.tsx +139 -0
- package/src/components/recipient-select/recipient-select.tsx +94 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import type { ContactAddress, SendRecipient } from '@proteos/sdk'
|
|
2
|
+
import { Paperclip, SendHorizontal } from 'lucide-react'
|
|
3
|
+
import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { Button as AriaButton, FileTrigger } from 'react-aria-components'
|
|
5
|
+
import { tv } from 'tailwind-variants'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../../lib/cn'
|
|
8
|
+
import { AttachmentChip } from '../attachment-chip/attachment-chip'
|
|
9
|
+
import { Button } from '../button/button'
|
|
10
|
+
import { RecipientSelect } from '../recipient-select/recipient-select'
|
|
11
|
+
import { Select } from '../select/select'
|
|
12
|
+
|
|
13
|
+
/* =========================================================================
|
|
14
|
+
MessageComposer — shared chrome (Field contract: rule border, inset
|
|
15
|
+
shadow, accent + halo on focus), channel-aware behavior:
|
|
16
|
+
chat : ↵ send, ⇧↵ newline
|
|
17
|
+
email : subject row, ⌘↵ send
|
|
18
|
+
The active binding is printed as a mono hint next to send. A failed send
|
|
19
|
+
NEVER clears the input. Disabled states replace the composer with honest
|
|
20
|
+
copy.
|
|
21
|
+
|
|
22
|
+
Fetch-agnostic: text + subject are controlled (`value`/`onValueChange` —
|
|
23
|
+
draft persistence lives with the host), the To/Cc/Bcc directory search is
|
|
24
|
+
injected (`searchRecipients`), and attachments are presentational — the
|
|
25
|
+
host owns its upload queue and maps it onto `ComposerAttachment`.
|
|
26
|
+
========================================================================= */
|
|
27
|
+
|
|
28
|
+
/** Controlled text + subject. Draft persistence lives with the host. */
|
|
29
|
+
export interface ComposerValue {
|
|
30
|
+
text: string
|
|
31
|
+
subject?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ComposerSendInput {
|
|
35
|
+
text: string
|
|
36
|
+
subject?: string
|
|
37
|
+
/** Only when the composer owns addressing (`showToField` — compose). */
|
|
38
|
+
to?: SendRecipient[]
|
|
39
|
+
cc?: SendRecipient[]
|
|
40
|
+
bcc?: SendRecipient[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The compose view's connection switcher, rendered as the composer's top row. */
|
|
44
|
+
export interface ComposerConnectionPicker {
|
|
45
|
+
options: ReadonlyArray<{
|
|
46
|
+
id: string
|
|
47
|
+
/** Rich render (channel icon + name); `textValue` carries the plain name. */
|
|
48
|
+
label: ReactNode
|
|
49
|
+
textValue: string
|
|
50
|
+
}>
|
|
51
|
+
selectedId: string
|
|
52
|
+
onSelect: (connectionId: string) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Presentational attachment chip input — hosts map their own upload model onto it. */
|
|
56
|
+
export interface ComposerAttachment {
|
|
57
|
+
localId: string
|
|
58
|
+
name: string
|
|
59
|
+
/** Bytes when known — server-loaded attachments may not know their size. */
|
|
60
|
+
sizeInBytes?: number
|
|
61
|
+
contentType?: string
|
|
62
|
+
status: 'uploading' | 'ready' | 'error'
|
|
63
|
+
errorMessage?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Directory search backing the To/Cc/Bcc pickers — injected per host (the lib never fetches). */
|
|
67
|
+
export type SearchRecipients = (connectionId: string, q: string) => Promise<ContactAddress[]>
|
|
68
|
+
|
|
69
|
+
/** Per-file cap for conversation-channel sends (the backend rejects larger
|
|
70
|
+
* with `attachment_too_large`; Slack additionally caps at 10 files/message,
|
|
71
|
+
* surfaced through the send error line). */
|
|
72
|
+
export const MAX_COMPOSER_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
|
73
|
+
|
|
74
|
+
/** Outer box chrome — exported so hosts can re-apply the same shell. */
|
|
75
|
+
export const messageComposerStyles = tv({
|
|
76
|
+
base: [
|
|
77
|
+
'rounded-[10px] border border-rule bg-surface shadow-[var(--shadow-inset)]',
|
|
78
|
+
'transition-[border-color,box-shadow] duration-150',
|
|
79
|
+
'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]',
|
|
80
|
+
],
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/** Shared row-label chrome: fixed width so the Via/To/Cc/Bcc controls all
|
|
84
|
+
* start at the same x (Subject keeps its own natural label). */
|
|
85
|
+
const ROW_LABEL = 'w-7 shrink-0 font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4'
|
|
86
|
+
|
|
87
|
+
export interface MessageComposerProps {
|
|
88
|
+
mode: 'chat' | 'email'
|
|
89
|
+
/** Controlled text+subject (draft persistence lives with the host). */
|
|
90
|
+
value: ComposerValue
|
|
91
|
+
onValueChange: (value: ComposerValue) => void
|
|
92
|
+
placeholder: string
|
|
93
|
+
/** Email replies arrive with `re: <subject>` prefilled. */
|
|
94
|
+
subjectPrefill?: string
|
|
95
|
+
/** When true (email mode), the Subject row renders the subject as static
|
|
96
|
+
* text — reply drafts where the subject is derived from the thread and
|
|
97
|
+
* must not be edited. The Cc/Bcc reveal stays functional. Default false. */
|
|
98
|
+
isSubjectReadOnly?: boolean
|
|
99
|
+
/** When set, the composer is replaced by this banner (ended conversation,
|
|
100
|
+
* read-only transcript). */
|
|
101
|
+
disabledReason?: ReactNode
|
|
102
|
+
onSend: (input: ComposerSendInput) => Promise<unknown>
|
|
103
|
+
/** Connection whose directory backs the To/Cc/Bcc pickers. */
|
|
104
|
+
connectionId?: string
|
|
105
|
+
/** Compose only: a connection switcher as the composer's top row. */
|
|
106
|
+
connectionPicker?: ComposerConnectionPicker
|
|
107
|
+
/** Compose only: the composer owns addressing — a To recipient row; the
|
|
108
|
+
* picks arrive on `ComposerSendInput.to` and gate send. */
|
|
109
|
+
showToField?: boolean
|
|
110
|
+
/** Email originate only: reveal Cc/Bcc recipient rows. */
|
|
111
|
+
showRecipientFields?: boolean
|
|
112
|
+
/** Required for the To/Cc/Bcc rows to render (with `connectionId`). */
|
|
113
|
+
searchRecipients?: SearchRecipients
|
|
114
|
+
/** Controlled-if-provided recipients; internal state otherwise. Internal
|
|
115
|
+
* recipient state resets when `connectionId` changes and clears after a
|
|
116
|
+
* successful send — controlled parents own their own resets. */
|
|
117
|
+
to?: SendRecipient[]
|
|
118
|
+
cc?: SendRecipient[]
|
|
119
|
+
bcc?: SendRecipient[]
|
|
120
|
+
onToChange?: (next: SendRecipient[]) => void
|
|
121
|
+
onCcChange?: (next: SendRecipient[]) => void
|
|
122
|
+
onBccChange?: (next: SendRecipient[]) => void
|
|
123
|
+
/** The host-owned pending queue, rendered as chips above the input. */
|
|
124
|
+
attachments?: ComposerAttachment[]
|
|
125
|
+
/** When set, the paperclip renders and picked files flow to the host. */
|
|
126
|
+
onAddFiles?: (files: File[]) => void
|
|
127
|
+
onRemoveAttachment?: (localId: string) => void
|
|
128
|
+
/** True while any attachment is still uploading — blocks send. */
|
|
129
|
+
hasUploadingAttachments?: boolean
|
|
130
|
+
/**
|
|
131
|
+
* Default true. When false: the send button AND the keybinding hint are
|
|
132
|
+
* hidden and the ↵/⌘↵ submit combo is disabled — submission is fully
|
|
133
|
+
* delegated to an external driver (e.g. the draft review surface).
|
|
134
|
+
*/
|
|
135
|
+
showSendButton?: boolean
|
|
136
|
+
/** Composes onto the outer box chrome (`messageComposerStyles`). */
|
|
137
|
+
className?: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function MessageComposer({
|
|
141
|
+
mode,
|
|
142
|
+
value,
|
|
143
|
+
onValueChange,
|
|
144
|
+
placeholder,
|
|
145
|
+
subjectPrefill,
|
|
146
|
+
isSubjectReadOnly,
|
|
147
|
+
disabledReason,
|
|
148
|
+
onSend,
|
|
149
|
+
connectionId,
|
|
150
|
+
connectionPicker,
|
|
151
|
+
showToField,
|
|
152
|
+
showRecipientFields,
|
|
153
|
+
searchRecipients,
|
|
154
|
+
to: toProp,
|
|
155
|
+
cc: ccProp,
|
|
156
|
+
bcc: bccProp,
|
|
157
|
+
onToChange,
|
|
158
|
+
onCcChange,
|
|
159
|
+
onBccChange,
|
|
160
|
+
attachments,
|
|
161
|
+
onAddFiles,
|
|
162
|
+
onRemoveAttachment,
|
|
163
|
+
hasUploadingAttachments,
|
|
164
|
+
showSendButton,
|
|
165
|
+
className,
|
|
166
|
+
}: MessageComposerProps) {
|
|
167
|
+
const [isSending, setIsSending] = useState(false)
|
|
168
|
+
const [sendError, setSendError] = useState<string | null>(null)
|
|
169
|
+
const [internalTo, setInternalTo] = useState<SendRecipient[]>([])
|
|
170
|
+
const [internalCc, setInternalCc] = useState<SendRecipient[]>([])
|
|
171
|
+
const [internalBcc, setInternalBcc] = useState<SendRecipient[]>([])
|
|
172
|
+
const [showCcBcc, setShowCcBcc] = useState(false)
|
|
173
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
174
|
+
|
|
175
|
+
// Controlled-if-provided, per group: a provided prop wins over internal
|
|
176
|
+
// state; setters mirror into internal state only when uncontrolled.
|
|
177
|
+
const to = toProp ?? internalTo
|
|
178
|
+
const cc = ccProp ?? internalCc
|
|
179
|
+
const bcc = bccProp ?? internalBcc
|
|
180
|
+
const setTo = (next: SendRecipient[]) => {
|
|
181
|
+
if (toProp === undefined) setInternalTo(next)
|
|
182
|
+
onToChange?.(next)
|
|
183
|
+
}
|
|
184
|
+
const setCc = (next: SendRecipient[]) => {
|
|
185
|
+
if (ccProp === undefined) setInternalCc(next)
|
|
186
|
+
onCcChange?.(next)
|
|
187
|
+
}
|
|
188
|
+
const setBcc = (next: SendRecipient[]) => {
|
|
189
|
+
if (bccProp === undefined) setInternalBcc(next)
|
|
190
|
+
onBccChange?.(next)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The recipient rows need a directory to search — both halves, or nothing.
|
|
194
|
+
const directory =
|
|
195
|
+
searchRecipients !== undefined && connectionId ? { searchRecipients, connectionId } : undefined
|
|
196
|
+
const canPickRecipients =
|
|
197
|
+
mode === 'email' && (showRecipientFields ?? false) && directory !== undefined
|
|
198
|
+
const needsTo = (showToField ?? false) && directory !== undefined
|
|
199
|
+
const isSendButtonShown = showSendButton ?? true
|
|
200
|
+
|
|
201
|
+
// INTERNAL recipients belong to one connection's directory — switching (the
|
|
202
|
+
// compose picker) drops them. Controlled parents own their own resets.
|
|
203
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: connectionId is the trigger — the pickers must reset exactly when the directory changes.
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
setInternalTo([])
|
|
206
|
+
setInternalCc([])
|
|
207
|
+
setInternalBcc([])
|
|
208
|
+
}, [connectionId])
|
|
209
|
+
|
|
210
|
+
const subject = value.subject ?? subjectPrefill ?? ''
|
|
211
|
+
const readyCount = (attachments ?? []).filter(
|
|
212
|
+
(attachment) => attachment.status === 'ready',
|
|
213
|
+
).length
|
|
214
|
+
const canSend =
|
|
215
|
+
(value.text.trim() !== '' || readyCount > 0) &&
|
|
216
|
+
(mode !== 'email' || subject.trim() !== '') &&
|
|
217
|
+
(!needsTo || to.length > 0) &&
|
|
218
|
+
!(hasUploadingAttachments ?? false)
|
|
219
|
+
|
|
220
|
+
const submit = useCallback(async () => {
|
|
221
|
+
if (!canSend || isSending) return
|
|
222
|
+
setIsSending(true)
|
|
223
|
+
setSendError(null)
|
|
224
|
+
try {
|
|
225
|
+
await onSend({
|
|
226
|
+
text: value.text.trim(),
|
|
227
|
+
...(mode === 'email' ? { subject: subject.trim() } : {}),
|
|
228
|
+
...(needsTo ? { to } : {}),
|
|
229
|
+
...(cc.length > 0 ? { cc } : {}),
|
|
230
|
+
...(bcc.length > 0 ? { bcc } : {}),
|
|
231
|
+
})
|
|
232
|
+
// Success: hand the cleared value back (subject key omitted) and drop
|
|
233
|
+
// INTERNAL recipients only — controlled recipients stay the parent's.
|
|
234
|
+
onValueChange({ text: '' })
|
|
235
|
+
setInternalTo([])
|
|
236
|
+
setInternalCc([])
|
|
237
|
+
setInternalBcc([])
|
|
238
|
+
} catch (error) {
|
|
239
|
+
// Never clear the input on failure — the text is the user's work.
|
|
240
|
+
setSendError(error instanceof Error ? error.message : 'Send failed')
|
|
241
|
+
} finally {
|
|
242
|
+
setIsSending(false)
|
|
243
|
+
}
|
|
244
|
+
}, [canSend, isSending, onSend, value.text, mode, subject, needsTo, to, cc, bcc, onValueChange])
|
|
245
|
+
|
|
246
|
+
if (disabledReason) {
|
|
247
|
+
return (
|
|
248
|
+
<div className="px-[18px] py-3">
|
|
249
|
+
<p
|
|
250
|
+
className={cn(
|
|
251
|
+
'rounded-[10px] border border-rule bg-surface-2 px-3.5 py-2.5',
|
|
252
|
+
'text-[12.5px] text-ink-3 text-center',
|
|
253
|
+
)}
|
|
254
|
+
>
|
|
255
|
+
{disabledReason}
|
|
256
|
+
</p>
|
|
257
|
+
</div>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
262
|
+
if (!isSendButtonShown) return
|
|
263
|
+
const isSendCombo =
|
|
264
|
+
mode === 'email'
|
|
265
|
+
? event.key === 'Enter' && (event.metaKey || event.ctrlKey)
|
|
266
|
+
: event.key === 'Enter' && !event.shiftKey
|
|
267
|
+
if (isSendCombo) {
|
|
268
|
+
event.preventDefault()
|
|
269
|
+
void submit()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const ccBccReveal =
|
|
274
|
+
canPickRecipients && !showCcBcc ? (
|
|
275
|
+
<AriaButton
|
|
276
|
+
onPress={() => setShowCcBcc(true)}
|
|
277
|
+
className="shrink-0 font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4 cursor-pointer outline-none hover:text-ink-3 focus-visible:shadow-[var(--shadow-focus)] rounded"
|
|
278
|
+
>
|
|
279
|
+
Cc/Bcc
|
|
280
|
+
</AriaButton>
|
|
281
|
+
) : null
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<div className="px-[18px] pb-3.5 pt-1">
|
|
285
|
+
<div className={cn(messageComposerStyles(), className)}>
|
|
286
|
+
{connectionPicker ? (
|
|
287
|
+
<div className="flex items-center gap-2 border-b border-rule-2 px-3.5 py-1.5">
|
|
288
|
+
<span className={ROW_LABEL}>Via</span>
|
|
289
|
+
<Select
|
|
290
|
+
aria-label="Connection"
|
|
291
|
+
size="sm"
|
|
292
|
+
options={connectionPicker.options.map((option) => ({
|
|
293
|
+
id: option.id,
|
|
294
|
+
label: option.label,
|
|
295
|
+
textValue: option.textValue,
|
|
296
|
+
}))}
|
|
297
|
+
selectedKey={connectionPicker.selectedId}
|
|
298
|
+
onSelectionChange={(key) => connectionPicker.onSelect(String(key))}
|
|
299
|
+
triggerClassName="font-mono text-[11px]"
|
|
300
|
+
popoverClassName="min-w-[var(--trigger-width)] w-max max-w-80"
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
303
|
+
) : null}
|
|
304
|
+
|
|
305
|
+
{mode === 'email' ? (
|
|
306
|
+
isSubjectReadOnly ? (
|
|
307
|
+
<div className="flex items-center gap-2 border-b border-rule-2 px-3.5 py-1.5">
|
|
308
|
+
<span className="font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4">
|
|
309
|
+
Subject
|
|
310
|
+
</span>
|
|
311
|
+
<span className="flex-1 min-w-0 truncate text-[13px] text-ink">{subject}</span>
|
|
312
|
+
{ccBccReveal}
|
|
313
|
+
</div>
|
|
314
|
+
) : (
|
|
315
|
+
<label className="flex items-center gap-2 border-b border-rule-2 px-3.5 py-1.5">
|
|
316
|
+
<span className="font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4">
|
|
317
|
+
Subject
|
|
318
|
+
</span>
|
|
319
|
+
<input
|
|
320
|
+
value={subject}
|
|
321
|
+
onChange={(event) => onValueChange({ ...value, subject: event.target.value })}
|
|
322
|
+
placeholder="Subject"
|
|
323
|
+
className="flex-1 bg-transparent outline-none text-[13px] text-ink placeholder:text-ink-4"
|
|
324
|
+
/>
|
|
325
|
+
{ccBccReveal}
|
|
326
|
+
</label>
|
|
327
|
+
)
|
|
328
|
+
) : null}
|
|
329
|
+
|
|
330
|
+
{needsTo && directory ? (
|
|
331
|
+
<div className="flex items-center gap-2 border-b border-rule-2 px-3.5 py-1.5">
|
|
332
|
+
<span className={ROW_LABEL}>To</span>
|
|
333
|
+
<div className="flex-1 min-w-0">
|
|
334
|
+
<RecipientSelect
|
|
335
|
+
connectionId={directory.connectionId}
|
|
336
|
+
searchRecipients={directory.searchRecipients}
|
|
337
|
+
placeholder="Pick recipients…"
|
|
338
|
+
value={to}
|
|
339
|
+
onChange={setTo}
|
|
340
|
+
/>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
) : null}
|
|
344
|
+
|
|
345
|
+
{canPickRecipients && directory && showCcBcc ? (
|
|
346
|
+
<>
|
|
347
|
+
<div className="flex items-center gap-2 border-b border-rule-2 px-3.5 py-1.5">
|
|
348
|
+
<span className={ROW_LABEL}>Cc</span>
|
|
349
|
+
<div className="flex-1 min-w-0">
|
|
350
|
+
<RecipientSelect
|
|
351
|
+
connectionId={directory.connectionId}
|
|
352
|
+
searchRecipients={directory.searchRecipients}
|
|
353
|
+
placeholder="Add cc…"
|
|
354
|
+
value={cc}
|
|
355
|
+
onChange={setCc}
|
|
356
|
+
/>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
<div className="flex items-center gap-2 border-b border-rule-2 px-3.5 py-1.5">
|
|
360
|
+
<span className={ROW_LABEL}>Bcc</span>
|
|
361
|
+
<div className="flex-1 min-w-0">
|
|
362
|
+
<RecipientSelect
|
|
363
|
+
connectionId={directory.connectionId}
|
|
364
|
+
searchRecipients={directory.searchRecipients}
|
|
365
|
+
placeholder="Add bcc…"
|
|
366
|
+
value={bcc}
|
|
367
|
+
onChange={setBcc}
|
|
368
|
+
/>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
</>
|
|
372
|
+
) : null}
|
|
373
|
+
|
|
374
|
+
{attachments && attachments.length > 0 ? (
|
|
375
|
+
// Single horizontally-scrollable row so the chips never grow the composer tall.
|
|
376
|
+
<div className="flex gap-1.5 overflow-x-auto px-3.5 pt-2 pb-0.5">
|
|
377
|
+
{attachments.map((attachment) => (
|
|
378
|
+
<AttachmentChip
|
|
379
|
+
key={attachment.localId}
|
|
380
|
+
name={attachment.name}
|
|
381
|
+
status={attachment.status}
|
|
382
|
+
{...(attachment.sizeInBytes !== undefined
|
|
383
|
+
? { sizeInBytes: attachment.sizeInBytes }
|
|
384
|
+
: {})}
|
|
385
|
+
{...(attachment.contentType ? { contentType: attachment.contentType } : {})}
|
|
386
|
+
{...(attachment.errorMessage ? { errorMessage: attachment.errorMessage } : {})}
|
|
387
|
+
{...(onRemoveAttachment
|
|
388
|
+
? { onRemove: () => onRemoveAttachment(attachment.localId) }
|
|
389
|
+
: {})}
|
|
390
|
+
className="shrink-0"
|
|
391
|
+
/>
|
|
392
|
+
))}
|
|
393
|
+
</div>
|
|
394
|
+
) : null}
|
|
395
|
+
|
|
396
|
+
{/* Stacked like Ask Proteos: the input is its own full-width row (typing
|
|
397
|
+
grows it upward), the actions sit in a row beneath — clip on the left,
|
|
398
|
+
send on the right — so the text always starts at the box's left edge. */}
|
|
399
|
+
<div className="flex flex-col gap-2 px-3.5 py-2">
|
|
400
|
+
<textarea
|
|
401
|
+
ref={textareaRef}
|
|
402
|
+
value={value.text}
|
|
403
|
+
placeholder={placeholder}
|
|
404
|
+
rows={1}
|
|
405
|
+
onChange={(event) => {
|
|
406
|
+
onValueChange({ ...value, text: event.target.value })
|
|
407
|
+
autoGrow(event.currentTarget)
|
|
408
|
+
}}
|
|
409
|
+
onKeyDown={onKeyDown}
|
|
410
|
+
className={cn(
|
|
411
|
+
'w-full resize-none bg-transparent outline-none',
|
|
412
|
+
'min-h-[40px] max-h-48 text-[14px] leading-[1.5] text-ink placeholder:text-ink-4',
|
|
413
|
+
)}
|
|
414
|
+
data-conversations-composer
|
|
415
|
+
/>
|
|
416
|
+
|
|
417
|
+
<div className="flex items-center justify-between gap-2">
|
|
418
|
+
{onAddFiles ? (
|
|
419
|
+
<FileTrigger
|
|
420
|
+
allowsMultiple
|
|
421
|
+
onSelect={(files) => {
|
|
422
|
+
if (files) onAddFiles(Array.from(files))
|
|
423
|
+
}}
|
|
424
|
+
>
|
|
425
|
+
<Button
|
|
426
|
+
variant="subtle"
|
|
427
|
+
size="icon-md"
|
|
428
|
+
aria-label="Attach files"
|
|
429
|
+
className="shrink-0"
|
|
430
|
+
>
|
|
431
|
+
<Paperclip className="size-4" aria-hidden />
|
|
432
|
+
</Button>
|
|
433
|
+
</FileTrigger>
|
|
434
|
+
) : (
|
|
435
|
+
<span />
|
|
436
|
+
)}
|
|
437
|
+
|
|
438
|
+
{isSendButtonShown ? (
|
|
439
|
+
<div className="flex items-center gap-2">
|
|
440
|
+
<span className="font-mono text-[10px] text-ink-4 select-none" aria-hidden>
|
|
441
|
+
{mode === 'email' ? '⌘↵' : '↵'}
|
|
442
|
+
</span>
|
|
443
|
+
<AriaButton
|
|
444
|
+
aria-label="Send"
|
|
445
|
+
isDisabled={!canSend || isSending}
|
|
446
|
+
onPress={() => void submit()}
|
|
447
|
+
className={cn(
|
|
448
|
+
'grid place-items-center size-8 rounded-[8px] cursor-pointer',
|
|
449
|
+
'bg-accent text-tile-fg transition-opacity duration-150',
|
|
450
|
+
'disabled:opacity-40 disabled:cursor-default',
|
|
451
|
+
'outline-none focus-visible:shadow-[var(--shadow-focus)]',
|
|
452
|
+
)}
|
|
453
|
+
>
|
|
454
|
+
<SendHorizontal className="size-3.5" />
|
|
455
|
+
</AriaButton>
|
|
456
|
+
</div>
|
|
457
|
+
) : null}
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
{sendError ? (
|
|
463
|
+
<p className="mt-1.5 font-mono text-[11px] text-danger">
|
|
464
|
+
{sendError} — your message is kept above.{' '}
|
|
465
|
+
<AriaButton
|
|
466
|
+
onPress={() => void submit()}
|
|
467
|
+
className="inline underline cursor-pointer outline-none focus-visible:shadow-[var(--shadow-focus)]"
|
|
468
|
+
>
|
|
469
|
+
retry
|
|
470
|
+
</AriaButton>
|
|
471
|
+
</p>
|
|
472
|
+
) : null}
|
|
473
|
+
</div>
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function autoGrow(node: HTMLTextAreaElement) {
|
|
478
|
+
node.style.height = 'auto'
|
|
479
|
+
node.style.height = `${Math.min(node.scrollHeight, 192)}px`
|
|
480
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ContactAddress, SendRecipient } from '@proteos/sdk'
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { RecipientSelect } from './recipient-select'
|
|
8
|
+
|
|
9
|
+
function contactAddress(overrides: Partial<ContactAddress> = {}): ContactAddress {
|
|
10
|
+
return {
|
|
11
|
+
id: 'addr-1',
|
|
12
|
+
value: 'alice@example.com',
|
|
13
|
+
raw_value: 'alice@example.com',
|
|
14
|
+
name: 'Alice',
|
|
15
|
+
...overrides,
|
|
16
|
+
} as ContactAddress
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function deferred<T>() {
|
|
20
|
+
let resolve!: (value: T) => void
|
|
21
|
+
const promise = new Promise<T>((r) => {
|
|
22
|
+
resolve = r
|
|
23
|
+
})
|
|
24
|
+
return { promise, resolve }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Harness({
|
|
28
|
+
searchRecipients,
|
|
29
|
+
onChange,
|
|
30
|
+
initial = [],
|
|
31
|
+
}: {
|
|
32
|
+
searchRecipients: (connectionId: string, q: string) => Promise<ContactAddress[]>
|
|
33
|
+
onChange?: (next: SendRecipient[]) => void
|
|
34
|
+
initial?: SendRecipient[]
|
|
35
|
+
}) {
|
|
36
|
+
const [value, setValue] = useState<SendRecipient[]>(initial)
|
|
37
|
+
return (
|
|
38
|
+
<RecipientSelect
|
|
39
|
+
connectionId="conn-1"
|
|
40
|
+
searchRecipients={searchRecipients}
|
|
41
|
+
placeholder="Pick recipients…"
|
|
42
|
+
value={value}
|
|
43
|
+
onChange={(next) => {
|
|
44
|
+
setValue(next)
|
|
45
|
+
onChange?.(next)
|
|
46
|
+
}}
|
|
47
|
+
/>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('RecipientSelect', () => {
|
|
52
|
+
it('loads the directory on mount and emits contact-address recipients on pick', async () => {
|
|
53
|
+
const searchRecipients = vi
|
|
54
|
+
.fn()
|
|
55
|
+
.mockResolvedValue([
|
|
56
|
+
contactAddress(),
|
|
57
|
+
contactAddress({ id: 'addr-2', value: 'bob@example.com', raw_value: '', name: 'Bob' }),
|
|
58
|
+
])
|
|
59
|
+
const onChange = vi.fn()
|
|
60
|
+
render(<Harness searchRecipients={searchRecipients} onChange={onChange} />)
|
|
61
|
+
|
|
62
|
+
await waitFor(() => expect(searchRecipients).toHaveBeenCalledWith('conn-1', ''))
|
|
63
|
+
|
|
64
|
+
await userEvent.click(screen.getByRole('combobox'))
|
|
65
|
+
await userEvent.click(await screen.findByRole('option', { name: 'Alice' }))
|
|
66
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
67
|
+
{ kind: 'contact-address', external_id: 'alice@example.com' },
|
|
68
|
+
])
|
|
69
|
+
// Bob has no raw_value — the canonical value is the identifier.
|
|
70
|
+
await userEvent.click(screen.getByRole('option', { name: 'Bob' }))
|
|
71
|
+
expect(onChange).toHaveBeenLastCalledWith([
|
|
72
|
+
{ kind: 'contact-address', external_id: 'alice@example.com' },
|
|
73
|
+
{ kind: 'contact-address', external_id: 'bob@example.com' },
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('debounces the search text into the injected search', async () => {
|
|
78
|
+
const searchRecipients = vi.fn().mockResolvedValue([contactAddress()])
|
|
79
|
+
render(<Harness searchRecipients={searchRecipients} />)
|
|
80
|
+
|
|
81
|
+
await userEvent.click(screen.getByRole('combobox'))
|
|
82
|
+
await userEvent.type(screen.getByLabelText('Search options'), 'ali')
|
|
83
|
+
|
|
84
|
+
await waitFor(() => expect(searchRecipients).toHaveBeenCalledWith('conn-1', 'ali'))
|
|
85
|
+
// Debounced: never one call per keystroke ('a', 'al').
|
|
86
|
+
expect(searchRecipients).not.toHaveBeenCalledWith('conn-1', 'a')
|
|
87
|
+
expect(searchRecipients).not.toHaveBeenCalledWith('conn-1', 'al')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('always includes already-selected ids so their chips render off-page', async () => {
|
|
91
|
+
const searchRecipients = vi.fn().mockResolvedValue([])
|
|
92
|
+
render(
|
|
93
|
+
<Harness
|
|
94
|
+
searchRecipients={searchRecipients}
|
|
95
|
+
initial={[{ kind: 'contact-address', external_id: 'ghost@example.com' }]}
|
|
96
|
+
/>,
|
|
97
|
+
)
|
|
98
|
+
expect(await screen.findByText('ghost@example.com')).toBeInTheDocument()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('ignores stale responses that resolve after a newer search', async () => {
|
|
102
|
+
const initial = deferred<ContactAddress[]>()
|
|
103
|
+
const searchRecipients = vi
|
|
104
|
+
.fn()
|
|
105
|
+
.mockImplementationOnce(() => initial.promise)
|
|
106
|
+
.mockResolvedValue([contactAddress()])
|
|
107
|
+
render(<Harness searchRecipients={searchRecipients} />)
|
|
108
|
+
|
|
109
|
+
await userEvent.click(screen.getByRole('combobox'))
|
|
110
|
+
await userEvent.type(screen.getByLabelText('Search options'), 'ali')
|
|
111
|
+
// The newer 'ali' search lands first…
|
|
112
|
+
await screen.findByRole('option', { name: 'Alice' })
|
|
113
|
+
|
|
114
|
+
// …then the stale mount-time search resolves and must be dropped.
|
|
115
|
+
initial.resolve([
|
|
116
|
+
contactAddress({ id: 'addr-9', value: 'stale@example.com', name: 'Alistair Stale' }),
|
|
117
|
+
])
|
|
118
|
+
await waitFor(() =>
|
|
119
|
+
expect(screen.queryByRole('option', { name: 'Alistair Stale' })).not.toBeInTheDocument(),
|
|
120
|
+
)
|
|
121
|
+
expect(screen.getByRole('option', { name: 'Alice' })).toBeInTheDocument()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('keeps the previous options when a search fails (best-effort)', async () => {
|
|
125
|
+
const searchRecipients = vi
|
|
126
|
+
.fn()
|
|
127
|
+
.mockResolvedValueOnce([contactAddress()])
|
|
128
|
+
.mockRejectedValue(new Error('directory_unavailable'))
|
|
129
|
+
render(<Harness searchRecipients={searchRecipients} />)
|
|
130
|
+
|
|
131
|
+
await userEvent.click(screen.getByRole('combobox'))
|
|
132
|
+
await screen.findByRole('option', { name: 'Alice' })
|
|
133
|
+
|
|
134
|
+
// 'ali' still matches Alice locally, so the kept option stays visible.
|
|
135
|
+
await userEvent.type(screen.getByLabelText('Search options'), 'ali')
|
|
136
|
+
await waitFor(() => expect(searchRecipients).toHaveBeenCalledWith('conn-1', 'ali'))
|
|
137
|
+
expect(screen.getByRole('option', { name: 'Alice' })).toBeInTheDocument()
|
|
138
|
+
})
|
|
139
|
+
})
|