@proteos/ui 0.31.0 → 0.32.1
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,470 @@
|
|
|
1
|
+
import type { Conversation, Message, SendRecipient, UpdateDraftRequest } from '@proteos/sdk'
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
|
+
import { Button as AriaButton } from 'react-aria-components'
|
|
4
|
+
import { tv } from 'tailwind-variants'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../../lib/cn'
|
|
7
|
+
import { Button } from '../button'
|
|
8
|
+
import { type ComposerAttachment, type ComposerValue, MessageComposer } from '../message-composer'
|
|
9
|
+
import { registerDraftMessageActions } from './draft-message-actions-store'
|
|
10
|
+
import { publishDraftMessageOutcome } from './draft-message-outcome-store'
|
|
11
|
+
import type { DraftMessageClient } from './types'
|
|
12
|
+
|
|
13
|
+
/* =========================================================================
|
|
14
|
+
DraftMessageComposer — the human-review surface for an agent-drafted
|
|
15
|
+
outbound message (status=draft). Pass a draft's message id + an injected
|
|
16
|
+
client (createDraftMessageClient); it loads the draft into the shared
|
|
17
|
+
MessageComposer, lets the human edit body / subject-on-originate-drafts /
|
|
18
|
+
recipients-on-email-drafts, then Send (persists dirty edits first) or
|
|
19
|
+
Reject. Outcomes reach the host three ways: onSent/onRejected callbacks,
|
|
20
|
+
the keyed outcome store (useDraftMessageOutcome — sibling components), and
|
|
21
|
+
the actions registry (useDraftMessageActions — external Send/Reject
|
|
22
|
+
buttons, e.g. a task list's "Execute"; pair with showActions={false}).
|
|
23
|
+
|
|
24
|
+
Known v1 limits: attachments are display-only; a `failed` delivery is a
|
|
25
|
+
terminal surface here (host-side retry = a new send, matching the
|
|
26
|
+
timeline's failed-retry pattern).
|
|
27
|
+
========================================================================= */
|
|
28
|
+
|
|
29
|
+
export const draftMessageComposerStyles = tv({
|
|
30
|
+
slots: {
|
|
31
|
+
root: 'flex flex-col gap-1.5',
|
|
32
|
+
header: 'flex items-center gap-2 px-[18px] pt-2',
|
|
33
|
+
badge: [
|
|
34
|
+
'inline-flex items-center gap-1 rounded-pill border border-rule px-2 py-0.5',
|
|
35
|
+
'font-mono text-[10px] uppercase tracking-[0.08em] text-ink-3 bg-surface-2',
|
|
36
|
+
],
|
|
37
|
+
sender: 'text-[12.5px] text-ink-3 truncate',
|
|
38
|
+
actions: 'flex items-center justify-between gap-2 px-[18px] pb-3',
|
|
39
|
+
terminal: [
|
|
40
|
+
'mx-[18px] my-3 rounded-[10px] border border-rule bg-surface-2 px-3.5 py-2.5',
|
|
41
|
+
'text-[12.5px] text-ink-3',
|
|
42
|
+
],
|
|
43
|
+
error: 'px-[18px] font-mono text-[11px] text-danger',
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const styles = draftMessageComposerStyles()
|
|
48
|
+
|
|
49
|
+
type Phase = 'loading' | 'load-error' | 'ready' | 'terminal'
|
|
50
|
+
type Busy = 'idle' | 'saving' | 'sending' | 'rejecting'
|
|
51
|
+
type TerminalKind = 'sent' | 'rejected' | 'failed' | 'not-draft'
|
|
52
|
+
|
|
53
|
+
interface DraftSnapshot {
|
|
54
|
+
text: string
|
|
55
|
+
subject: string
|
|
56
|
+
to: SendRecipient[]
|
|
57
|
+
cc: SendRecipient[]
|
|
58
|
+
bcc: SendRecipient[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DraftMessageComposerProps {
|
|
62
|
+
/** The status=draft message to review. */
|
|
63
|
+
messageId: string
|
|
64
|
+
/** Injected fetch surface — see createDraftMessageClient. */
|
|
65
|
+
client: DraftMessageClient
|
|
66
|
+
/** Fires once the draft delivered (send resolved pending/sent). */
|
|
67
|
+
onSent?: (message: Message) => void
|
|
68
|
+
/** Fires once the draft was declined. */
|
|
69
|
+
onRejected?: (message: Message) => void
|
|
70
|
+
/**
|
|
71
|
+
* Default true. When false the internal Send / Save draft / Reject row is
|
|
72
|
+
* hidden — the composer becomes a pure preview/edit surface driven by
|
|
73
|
+
* external controls via useDraftMessageActions.
|
|
74
|
+
*/
|
|
75
|
+
showActions?: boolean
|
|
76
|
+
className?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Maps stored recipient snapshots back to editable send targets (contact
|
|
80
|
+
* addresses only; snapshots without an external id cannot be re-addressed). */
|
|
81
|
+
function sendRecipientsFromMessage(message: Message, role: 'to' | 'cc' | 'bcc'): SendRecipient[] {
|
|
82
|
+
return (message.recipients ?? [])
|
|
83
|
+
.filter((recipient) => recipient.role === role && !!recipient.external_id)
|
|
84
|
+
.map((recipient) => ({
|
|
85
|
+
kind: 'contact-address' as const,
|
|
86
|
+
external_id: recipient.external_id as string,
|
|
87
|
+
}))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function terminalKindFor(status: Message['status']): TerminalKind | null {
|
|
91
|
+
switch (status) {
|
|
92
|
+
case 'draft':
|
|
93
|
+
return null
|
|
94
|
+
case 'pending':
|
|
95
|
+
case 'sent':
|
|
96
|
+
return 'sent'
|
|
97
|
+
case 'rejected':
|
|
98
|
+
return 'rejected'
|
|
99
|
+
case 'failed':
|
|
100
|
+
return 'failed'
|
|
101
|
+
default:
|
|
102
|
+
return 'not-draft'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function DraftMessageComposer({
|
|
107
|
+
messageId,
|
|
108
|
+
client,
|
|
109
|
+
onSent,
|
|
110
|
+
onRejected,
|
|
111
|
+
showActions = true,
|
|
112
|
+
className,
|
|
113
|
+
}: DraftMessageComposerProps) {
|
|
114
|
+
const [phase, setPhase] = useState<Phase>('loading')
|
|
115
|
+
const [busy, setBusy] = useState<Busy>('idle')
|
|
116
|
+
const [terminalKind, setTerminalKind] = useState<TerminalKind>('not-draft')
|
|
117
|
+
const [message, setMessage] = useState<Message | null>(null)
|
|
118
|
+
const [conversation, setConversation] = useState<Conversation | null>(null)
|
|
119
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
120
|
+
const [actionError, setActionError] = useState<string | null>(null)
|
|
121
|
+
const [isConfirmingReject, setIsConfirmingReject] = useState(false)
|
|
122
|
+
const [value, setValue] = useState<ComposerValue>({ text: '' })
|
|
123
|
+
const [to, setTo] = useState<SendRecipient[]>([])
|
|
124
|
+
const [cc, setCc] = useState<SendRecipient[]>([])
|
|
125
|
+
const [bcc, setBcc] = useState<SendRecipient[]>([])
|
|
126
|
+
// State (not a ref): isDirty/buildPatch must recompute when a save moves it.
|
|
127
|
+
const [snapshot, setSnapshot] = useState<DraftSnapshot>({
|
|
128
|
+
text: '',
|
|
129
|
+
subject: '',
|
|
130
|
+
to: [],
|
|
131
|
+
cc: [],
|
|
132
|
+
bcc: [],
|
|
133
|
+
})
|
|
134
|
+
const [loadNonce, setLoadNonce] = useState(0)
|
|
135
|
+
|
|
136
|
+
const isEmail = message?.channel === 'email'
|
|
137
|
+
const isSubjectEditable = isEmail && conversation?.status === 'draft'
|
|
138
|
+
const areRecipientsEditable = isEmail && !conversation?.room_id
|
|
139
|
+
|
|
140
|
+
const seed = useCallback((draft: Message, thread: Conversation | null) => {
|
|
141
|
+
const text = draft.content.find((block) => block.type === 'text')?.text ?? ''
|
|
142
|
+
const subject = thread?.subject ?? ''
|
|
143
|
+
const seededTo = sendRecipientsFromMessage(draft, 'to')
|
|
144
|
+
const seededCc = sendRecipientsFromMessage(draft, 'cc')
|
|
145
|
+
const seededBcc = sendRecipientsFromMessage(draft, 'bcc')
|
|
146
|
+
setSnapshot({ text, subject, to: seededTo, cc: seededCc, bcc: seededBcc })
|
|
147
|
+
setValue({ text, ...(draft.channel === 'email' ? { subject } : {}) })
|
|
148
|
+
setTo(seededTo)
|
|
149
|
+
setCc(seededCc)
|
|
150
|
+
setBcc(seededBcc)
|
|
151
|
+
}, [])
|
|
152
|
+
|
|
153
|
+
// Load (and reload on retry): the message, then its conversation.
|
|
154
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: loadNonce is the retry trigger — bumping it re-runs the load.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
let isStale = false
|
|
157
|
+
setPhase('loading')
|
|
158
|
+
setLoadError(null)
|
|
159
|
+
setActionError(null)
|
|
160
|
+
client
|
|
161
|
+
.getMessage(messageId)
|
|
162
|
+
.then(async (draft) => {
|
|
163
|
+
const thread = draft.conversation_id
|
|
164
|
+
? await client.getConversation(draft.conversation_id)
|
|
165
|
+
: null
|
|
166
|
+
if (isStale) return
|
|
167
|
+
setMessage(draft)
|
|
168
|
+
setConversation(thread)
|
|
169
|
+
const terminal = terminalKindFor(draft.status)
|
|
170
|
+
if (terminal) {
|
|
171
|
+
setTerminalKind(terminal)
|
|
172
|
+
setPhase('terminal')
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
seed(draft, thread)
|
|
176
|
+
setPhase('ready')
|
|
177
|
+
})
|
|
178
|
+
.catch((error: unknown) => {
|
|
179
|
+
if (isStale) return
|
|
180
|
+
setLoadError(error instanceof Error ? error.message : 'Loading the draft failed')
|
|
181
|
+
setPhase('load-error')
|
|
182
|
+
})
|
|
183
|
+
return () => {
|
|
184
|
+
isStale = true
|
|
185
|
+
}
|
|
186
|
+
}, [client, messageId, seed, loadNonce])
|
|
187
|
+
|
|
188
|
+
const isDirty = useMemo(() => {
|
|
189
|
+
return (
|
|
190
|
+
value.text !== snapshot.text ||
|
|
191
|
+
(isSubjectEditable && (value.subject ?? '') !== snapshot.subject) ||
|
|
192
|
+
JSON.stringify(to) !== JSON.stringify(snapshot.to) ||
|
|
193
|
+
JSON.stringify(cc) !== JSON.stringify(snapshot.cc) ||
|
|
194
|
+
JSON.stringify(bcc) !== JSON.stringify(snapshot.bcc)
|
|
195
|
+
)
|
|
196
|
+
}, [value, to, cc, bcc, snapshot, isSubjectEditable])
|
|
197
|
+
|
|
198
|
+
const buildPatch = useCallback((): UpdateDraftRequest => {
|
|
199
|
+
const current = message as Message
|
|
200
|
+
const isBodyEdited = value.text !== snapshot.text
|
|
201
|
+
const hasRecipientEdit =
|
|
202
|
+
areRecipientsEditable &&
|
|
203
|
+
(JSON.stringify(to) !== JSON.stringify(snapshot.to) ||
|
|
204
|
+
JSON.stringify(cc) !== JSON.stringify(snapshot.cc) ||
|
|
205
|
+
JSON.stringify(bcc) !== JSON.stringify(snapshot.bcc))
|
|
206
|
+
const attachmentRefs = (current.attachments ?? []).map((attachment) => attachment.file)
|
|
207
|
+
return {
|
|
208
|
+
// Body edits collapse to one text block; an untouched body echoes the
|
|
209
|
+
// stored blocks so an agent-provided html part survives the edit.
|
|
210
|
+
content: isBodyEdited
|
|
211
|
+
? [{ type: 'text' as const, text: value.text.trim() }]
|
|
212
|
+
: current.content,
|
|
213
|
+
...(isSubjectEditable ? { subject: value.subject ?? '' } : {}),
|
|
214
|
+
// Presence-based replacement: any recipient edit ships ALL three groups
|
|
215
|
+
// verbatim — [] is an explicit clear, an omitted group keeps the stored
|
|
216
|
+
// one. Shipping only non-empty groups would silently resurrect removed
|
|
217
|
+
// recipients at send time.
|
|
218
|
+
...(hasRecipientEdit ? { to, cc, bcc } : {}),
|
|
219
|
+
...(attachmentRefs.length > 0 ? { attachments: attachmentRefs } : {}),
|
|
220
|
+
}
|
|
221
|
+
}, [message, value, to, cc, bcc, snapshot, areRecipientsEditable, isSubjectEditable])
|
|
222
|
+
|
|
223
|
+
const persistDirtyEdits = useCallback(async (): Promise<Message> => {
|
|
224
|
+
const current = message as Message
|
|
225
|
+
if (!isDirty) return current
|
|
226
|
+
const updated = await client.updateDraft(messageId, buildPatch())
|
|
227
|
+
setMessage(updated)
|
|
228
|
+
setConversation((thread) =>
|
|
229
|
+
thread && isSubjectEditable ? { ...thread, subject: value.subject ?? '' } : thread,
|
|
230
|
+
)
|
|
231
|
+
setSnapshot({ text: value.text, subject: value.subject ?? '', to, cc, bcc })
|
|
232
|
+
return updated
|
|
233
|
+
}, [message, isDirty, client, messageId, buildPatch, isSubjectEditable, value, to, cc, bcc])
|
|
234
|
+
|
|
235
|
+
const saveDraft = useCallback(async () => {
|
|
236
|
+
if (phase !== 'ready' || busy !== 'idle') return
|
|
237
|
+
setBusy('saving')
|
|
238
|
+
setActionError(null)
|
|
239
|
+
try {
|
|
240
|
+
await persistDirtyEdits()
|
|
241
|
+
} catch (error) {
|
|
242
|
+
setActionError(error instanceof Error ? error.message : 'Saving the draft failed')
|
|
243
|
+
} finally {
|
|
244
|
+
setBusy('idle')
|
|
245
|
+
}
|
|
246
|
+
}, [phase, busy, persistDirtyEdits])
|
|
247
|
+
|
|
248
|
+
const send = useCallback(async () => {
|
|
249
|
+
if (phase !== 'ready' || busy !== 'idle') return
|
|
250
|
+
// An originate draft addresses people directly — sending it with every
|
|
251
|
+
// recipient removed can never deliver. (Reply drafts may legitimately go
|
|
252
|
+
// recipient-less: they deliver into the thread.) Guards the actions
|
|
253
|
+
// registry too, which bypasses the composer's own canSend gating.
|
|
254
|
+
if (areRecipientsEditable && conversation?.status === 'draft' && to.length === 0) {
|
|
255
|
+
setActionError('Add at least one recipient before sending')
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
setBusy('sending')
|
|
259
|
+
setActionError(null)
|
|
260
|
+
try {
|
|
261
|
+
await persistDirtyEdits()
|
|
262
|
+
const result = await client.sendDraft(messageId)
|
|
263
|
+
setMessage(result)
|
|
264
|
+
if (result.status === 'failed') {
|
|
265
|
+
// Delivery failed AFTER the human approved: terminal here, no outcome
|
|
266
|
+
// published — the timeline's failed-retry pattern owns what's next.
|
|
267
|
+
setTerminalKind('failed')
|
|
268
|
+
setPhase('terminal')
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
setTerminalKind('sent')
|
|
272
|
+
setPhase('terminal')
|
|
273
|
+
publishDraftMessageOutcome(messageId, { type: 'sent', message: result })
|
|
274
|
+
onSent?.(result)
|
|
275
|
+
} catch (error) {
|
|
276
|
+
// Transport/validation failure keeps the edits — the text is the
|
|
277
|
+
// reviewer's work. Rethrow so the composer's onSend rejects too:
|
|
278
|
+
// MessageComposer only clears its value on a RESOLVED send.
|
|
279
|
+
setActionError(error instanceof Error ? error.message : 'Sending the draft failed')
|
|
280
|
+
throw error
|
|
281
|
+
} finally {
|
|
282
|
+
setBusy('idle')
|
|
283
|
+
}
|
|
284
|
+
}, [
|
|
285
|
+
phase,
|
|
286
|
+
busy,
|
|
287
|
+
persistDirtyEdits,
|
|
288
|
+
client,
|
|
289
|
+
messageId,
|
|
290
|
+
onSent,
|
|
291
|
+
areRecipientsEditable,
|
|
292
|
+
conversation,
|
|
293
|
+
to,
|
|
294
|
+
])
|
|
295
|
+
|
|
296
|
+
const reject = useCallback(
|
|
297
|
+
async (isConfirmed: boolean) => {
|
|
298
|
+
if (phase !== 'ready' || busy !== 'idle') return
|
|
299
|
+
if (!isConfirmed) {
|
|
300
|
+
setIsConfirmingReject(true)
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
setBusy('rejecting')
|
|
304
|
+
setActionError(null)
|
|
305
|
+
try {
|
|
306
|
+
// Deliberately ignores dirty edits — declining discards them.
|
|
307
|
+
const result = await client.rejectDraft(messageId)
|
|
308
|
+
setMessage(result)
|
|
309
|
+
setTerminalKind('rejected')
|
|
310
|
+
setPhase('terminal')
|
|
311
|
+
publishDraftMessageOutcome(messageId, { type: 'rejected', message: result })
|
|
312
|
+
onRejected?.(result)
|
|
313
|
+
} catch (error) {
|
|
314
|
+
setActionError(error instanceof Error ? error.message : 'Rejecting the draft failed')
|
|
315
|
+
} finally {
|
|
316
|
+
setBusy('idle')
|
|
317
|
+
setIsConfirmingReject(false)
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
[phase, busy, client, messageId, onRejected],
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
// Actions registry: external controls (task-list Execute buttons, any
|
|
324
|
+
// sibling in this realm) drive the mounted composer. Latest-closure refs so
|
|
325
|
+
// the registered handlers never go stale; re-registered on busy flips so
|
|
326
|
+
// subscribers see isBusy.
|
|
327
|
+
const sendRef = useRef(send)
|
|
328
|
+
const rejectRef = useRef(reject)
|
|
329
|
+
const saveRef = useRef(saveDraft)
|
|
330
|
+
sendRef.current = send
|
|
331
|
+
rejectRef.current = reject
|
|
332
|
+
saveRef.current = saveDraft
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (phase !== 'ready') return
|
|
335
|
+
return registerDraftMessageActions(messageId, {
|
|
336
|
+
// Failures surface via actionError/outcomes — external drivers get a
|
|
337
|
+
// settled promise, not a rejection to remember to catch.
|
|
338
|
+
send: () => sendRef.current().catch(() => undefined),
|
|
339
|
+
// External UIs own their confirmation — bypass the inline confirm.
|
|
340
|
+
reject: () => rejectRef.current(true),
|
|
341
|
+
saveDraft: () => saveRef.current(),
|
|
342
|
+
isBusy: busy !== 'idle',
|
|
343
|
+
})
|
|
344
|
+
}, [messageId, phase, busy])
|
|
345
|
+
|
|
346
|
+
const attachments = useMemo<ComposerAttachment[]>(
|
|
347
|
+
() =>
|
|
348
|
+
(message?.attachments ?? []).map((attachment) => ({
|
|
349
|
+
localId: attachment.id,
|
|
350
|
+
name: attachment.file.name || attachment.file.id,
|
|
351
|
+
status: 'ready' as const,
|
|
352
|
+
...(attachment.file.content_type ? { contentType: attachment.file.content_type } : {}),
|
|
353
|
+
})),
|
|
354
|
+
[message?.attachments],
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if (phase === 'loading') {
|
|
358
|
+
return (
|
|
359
|
+
<div className={cn(styles.root(), className)}>
|
|
360
|
+
<p className={styles.terminal()}>Loading draft…</p>
|
|
361
|
+
</div>
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (phase === 'load-error') {
|
|
366
|
+
return (
|
|
367
|
+
<div className={cn(styles.root(), className)}>
|
|
368
|
+
<p className={styles.terminal()}>
|
|
369
|
+
{loadError ?? 'Loading the draft failed.'}{' '}
|
|
370
|
+
<AriaButton
|
|
371
|
+
onPress={() => setLoadNonce((nonce) => nonce + 1)}
|
|
372
|
+
className="inline underline cursor-pointer outline-none focus-visible:shadow-[var(--shadow-focus)]"
|
|
373
|
+
>
|
|
374
|
+
retry
|
|
375
|
+
</AriaButton>
|
|
376
|
+
</p>
|
|
377
|
+
</div>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (phase === 'terminal' || !message) {
|
|
382
|
+
const line =
|
|
383
|
+
terminalKind === 'sent'
|
|
384
|
+
? 'This draft was sent.'
|
|
385
|
+
: terminalKind === 'rejected'
|
|
386
|
+
? 'This draft was rejected.'
|
|
387
|
+
: terminalKind === 'failed'
|
|
388
|
+
? `Sending failed${message?.error ? ` — ${message.error}` : ''}.`
|
|
389
|
+
: 'This message is not a reviewable draft.'
|
|
390
|
+
return (
|
|
391
|
+
<div className={cn(styles.root(), className)} data-draft-terminal={terminalKind}>
|
|
392
|
+
<p className={styles.terminal()}>{line}</p>
|
|
393
|
+
</div>
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<div className={cn(styles.root(), className)}>
|
|
399
|
+
<div className={styles.header()}>
|
|
400
|
+
<span className={styles.badge()}>Draft · awaiting review</span>
|
|
401
|
+
<span className={styles.sender()}>from {message.sender.name}</span>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<MessageComposer
|
|
405
|
+
mode={isEmail ? 'email' : 'chat'}
|
|
406
|
+
value={value}
|
|
407
|
+
onValueChange={setValue}
|
|
408
|
+
placeholder="Draft body…"
|
|
409
|
+
{...(isEmail && !isSubjectEditable ? { isSubjectReadOnly: true } : {})}
|
|
410
|
+
onSend={async () => send()}
|
|
411
|
+
{...(areRecipientsEditable && message.connection_id
|
|
412
|
+
? {
|
|
413
|
+
connectionId: message.connection_id,
|
|
414
|
+
searchRecipients: client.searchRecipients,
|
|
415
|
+
showToField: true,
|
|
416
|
+
showRecipientFields: true,
|
|
417
|
+
to,
|
|
418
|
+
cc,
|
|
419
|
+
bcc,
|
|
420
|
+
onToChange: setTo,
|
|
421
|
+
onCcChange: setCc,
|
|
422
|
+
onBccChange: setBcc,
|
|
423
|
+
}
|
|
424
|
+
: {})}
|
|
425
|
+
{...(attachments.length > 0 ? { attachments } : {})}
|
|
426
|
+
showSendButton={showActions}
|
|
427
|
+
/>
|
|
428
|
+
|
|
429
|
+
{actionError ? <p className={styles.error()}>{actionError} — your edits are kept.</p> : null}
|
|
430
|
+
|
|
431
|
+
{showActions ? (
|
|
432
|
+
<div className={styles.actions()}>
|
|
433
|
+
{isConfirmingReject ? (
|
|
434
|
+
<div className="flex items-center gap-2">
|
|
435
|
+
<span className="text-[12.5px] text-ink-3">Reject draft?</span>
|
|
436
|
+
<Button
|
|
437
|
+
size="sm"
|
|
438
|
+
variant="ghost"
|
|
439
|
+
onPress={() => void reject(true)}
|
|
440
|
+
isDisabled={busy !== 'idle'}
|
|
441
|
+
>
|
|
442
|
+
Confirm reject
|
|
443
|
+
</Button>
|
|
444
|
+
<Button size="sm" variant="subtle" onPress={() => setIsConfirmingReject(false)}>
|
|
445
|
+
Cancel
|
|
446
|
+
</Button>
|
|
447
|
+
</div>
|
|
448
|
+
) : (
|
|
449
|
+
<Button
|
|
450
|
+
size="sm"
|
|
451
|
+
variant="ghost"
|
|
452
|
+
onPress={() => void reject(false)}
|
|
453
|
+
isDisabled={busy !== 'idle'}
|
|
454
|
+
>
|
|
455
|
+
Reject
|
|
456
|
+
</Button>
|
|
457
|
+
)}
|
|
458
|
+
<Button
|
|
459
|
+
size="sm"
|
|
460
|
+
variant="subtle"
|
|
461
|
+
onPress={() => void saveDraft()}
|
|
462
|
+
isDisabled={busy !== 'idle' || !isDirty}
|
|
463
|
+
>
|
|
464
|
+
{busy === 'saving' ? 'Saving…' : 'Save draft'}
|
|
465
|
+
</Button>
|
|
466
|
+
</div>
|
|
467
|
+
) : null}
|
|
468
|
+
</div>
|
|
469
|
+
)
|
|
470
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
__resetDraftMessageOutcomes,
|
|
6
|
+
publishDraftMessageOutcome,
|
|
7
|
+
subscribeDraftMessageOutcome,
|
|
8
|
+
useDraftMessageOutcome,
|
|
9
|
+
} from './draft-message-outcome-store'
|
|
10
|
+
import type { DraftMessageOutcome } from './types'
|
|
11
|
+
|
|
12
|
+
const sentOutcome = (id: string): DraftMessageOutcome => ({
|
|
13
|
+
type: 'sent',
|
|
14
|
+
message: { id } as DraftMessageOutcome['message'],
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('draft-message-outcome-store', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
__resetDraftMessageOutcomes()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('notifies subscribers of the message id, not others', () => {
|
|
23
|
+
const onMine = vi.fn()
|
|
24
|
+
const onOther = vi.fn()
|
|
25
|
+
subscribeDraftMessageOutcome('m-1', onMine)
|
|
26
|
+
subscribeDraftMessageOutcome('m-2', onOther)
|
|
27
|
+
|
|
28
|
+
publishDraftMessageOutcome('m-1', sentOutcome('m-1'))
|
|
29
|
+
|
|
30
|
+
expect(onMine).toHaveBeenCalledTimes(1)
|
|
31
|
+
expect(onOther).not.toHaveBeenCalled()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('replays the last outcome to a late subscriber', () => {
|
|
35
|
+
publishDraftMessageOutcome('m-1', sentOutcome('m-1'))
|
|
36
|
+
|
|
37
|
+
const { result } = renderHook(() => useDraftMessageOutcome('m-1'))
|
|
38
|
+
expect(result.current?.type).toBe('sent')
|
|
39
|
+
expect(result.current?.message.id).toBe('m-1')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('re-renders the hook when the outcome lands', () => {
|
|
43
|
+
const { result } = renderHook(() => useDraftMessageOutcome('m-9'))
|
|
44
|
+
expect(result.current).toBeNull()
|
|
45
|
+
|
|
46
|
+
act(() => {
|
|
47
|
+
publishDraftMessageOutcome('m-9', { type: 'rejected', message: { id: 'm-9' } as never })
|
|
48
|
+
})
|
|
49
|
+
expect(result.current?.type).toBe('rejected')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('unsubscribes cleanly', () => {
|
|
53
|
+
const listener = vi.fn()
|
|
54
|
+
const unsubscribe = subscribeDraftMessageOutcome('m-1', listener)
|
|
55
|
+
unsubscribe()
|
|
56
|
+
publishDraftMessageOutcome('m-1', sentOutcome('m-1'))
|
|
57
|
+
expect(listener).not.toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { DraftMessageOutcome } from './types'
|
|
4
|
+
|
|
5
|
+
/* =========================================================================
|
|
6
|
+
Draft-outcome pub/sub — how SIBLING components learn a draft was sent or
|
|
7
|
+
rejected without prop drilling (the show-details-store pattern, keyed by
|
|
8
|
+
message id, plus last-value replay so a subscriber mounting AFTER the
|
|
9
|
+
outcome still sees it).
|
|
10
|
+
|
|
11
|
+
Scope: a per-JS-realm module singleton. In the web app that is the whole
|
|
12
|
+
page. In the component runtime the import map makes @proteos/ui a
|
|
13
|
+
singleton PER IFRAME — sibling components inside the SAME custom component
|
|
14
|
+
share outcomes; separate custom components are separate iframes and do
|
|
15
|
+
not (cross-iframe reaction stays host-side via query invalidation).
|
|
16
|
+
========================================================================= */
|
|
17
|
+
|
|
18
|
+
const listenersByMessageId = new Map<string, Set<() => void>>()
|
|
19
|
+
const lastOutcomeByMessageId = new Map<string, DraftMessageOutcome>()
|
|
20
|
+
|
|
21
|
+
export function publishDraftMessageOutcome(messageId: string, outcome: DraftMessageOutcome): void {
|
|
22
|
+
lastOutcomeByMessageId.set(messageId, outcome)
|
|
23
|
+
for (const listener of listenersByMessageId.get(messageId) ?? []) listener()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function subscribeDraftMessageOutcome(messageId: string, listener: () => void): () => void {
|
|
27
|
+
let listeners = listenersByMessageId.get(messageId)
|
|
28
|
+
if (!listeners) {
|
|
29
|
+
listeners = new Set()
|
|
30
|
+
listenersByMessageId.set(messageId, listeners)
|
|
31
|
+
}
|
|
32
|
+
listeners.add(listener)
|
|
33
|
+
return () => {
|
|
34
|
+
listeners.delete(listener)
|
|
35
|
+
if (listeners.size === 0) listenersByMessageId.delete(messageId)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The last outcome for this draft, or null while it is still under review.
|
|
41
|
+
* Re-renders when the outcome lands — including for subscribers that mounted
|
|
42
|
+
* after it (last-value replay).
|
|
43
|
+
*/
|
|
44
|
+
export function useDraftMessageOutcome(messageId: string): DraftMessageOutcome | null {
|
|
45
|
+
return useSyncExternalStore(
|
|
46
|
+
(listener) => subscribeDraftMessageOutcome(messageId, listener),
|
|
47
|
+
() => lastOutcomeByMessageId.get(messageId) ?? null,
|
|
48
|
+
() => null,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Test-only escape hatch — clears every stored outcome and listener. */
|
|
53
|
+
export function __resetDraftMessageOutcomes(): void {
|
|
54
|
+
listenersByMessageId.clear()
|
|
55
|
+
lastOutcomeByMessageId.clear()
|
|
56
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { createDraftMessageClient } from './create-draft-message-client'
|
|
2
|
+
export {
|
|
3
|
+
__resetDraftMessageActions,
|
|
4
|
+
type DraftMessageActions,
|
|
5
|
+
registerDraftMessageActions,
|
|
6
|
+
useDraftMessageActions,
|
|
7
|
+
} from './draft-message-actions-store'
|
|
8
|
+
export {
|
|
9
|
+
DraftMessageComposer,
|
|
10
|
+
type DraftMessageComposerProps,
|
|
11
|
+
draftMessageComposerStyles,
|
|
12
|
+
} from './draft-message-composer'
|
|
13
|
+
export {
|
|
14
|
+
__resetDraftMessageOutcomes,
|
|
15
|
+
publishDraftMessageOutcome,
|
|
16
|
+
subscribeDraftMessageOutcome,
|
|
17
|
+
useDraftMessageOutcome,
|
|
18
|
+
} from './draft-message-outcome-store'
|
|
19
|
+
export type { DraftMessageClient, DraftMessageOutcome } from './types'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ContactAddress, Conversation, Message, UpdateDraftRequest } from '@proteos/sdk'
|
|
2
|
+
|
|
3
|
+
/* =========================================================================
|
|
4
|
+
DraftMessageComposer contracts — types-only @proteos/sdk imports, so the
|
|
5
|
+
lib stays fetch-agnostic (the data-table InfiniteQueryLike precedent): the
|
|
6
|
+
host injects the client, the component never talks to the network itself.
|
|
7
|
+
========================================================================= */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The minimal fetch surface DraftMessageComposer needs, injected per host:
|
|
11
|
+
* the web app builds it from `useSdk().conversation`, custom components from
|
|
12
|
+
* `useSdk().conversations` — both via {@link createDraftMessageClient}.
|
|
13
|
+
*/
|
|
14
|
+
export interface DraftMessageClient {
|
|
15
|
+
/** One message with projections — also resolves terminal states by id. */
|
|
16
|
+
getMessage(messageId: string): Promise<Message>
|
|
17
|
+
/** The draft's conversation: subject, status (draft ⇒ subject editable), room. */
|
|
18
|
+
getConversation(conversationId: string): Promise<Conversation>
|
|
19
|
+
/** Full-replacement draft edit (PUT semantics — send the complete state). */
|
|
20
|
+
updateDraft(messageId: string, patch: UpdateDraftRequest): Promise<Message>
|
|
21
|
+
/** Deliver the draft (draft → pending → sent/failed). 409 on a lost race. */
|
|
22
|
+
sendDraft(messageId: string): Promise<Message>
|
|
23
|
+
/** Decline the draft (draft → rejected, terminal). 409 on a lost race. */
|
|
24
|
+
rejectDraft(messageId: string): Promise<Message>
|
|
25
|
+
/** Directory search backing the To/Cc/Bcc pickers (email drafts only). */
|
|
26
|
+
searchRecipients(connectionId: string, q: string): Promise<ContactAddress[]>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What happened to a reviewed draft — the payload of the outcome store. */
|
|
30
|
+
export type DraftMessageOutcome =
|
|
31
|
+
| { type: 'sent'; message: Message }
|
|
32
|
+
| { type: 'rejected'; message: Message }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
ComposerAttachment,
|
|
3
|
+
ComposerConnectionPicker,
|
|
4
|
+
ComposerSendInput,
|
|
5
|
+
ComposerValue,
|
|
6
|
+
MessageComposerProps,
|
|
7
|
+
SearchRecipients,
|
|
8
|
+
} from './message-composer'
|
|
9
|
+
export {
|
|
10
|
+
MAX_COMPOSER_ATTACHMENT_BYTES,
|
|
11
|
+
MessageComposer,
|
|
12
|
+
messageComposerStyles,
|
|
13
|
+
} from './message-composer'
|