@open-mercato/ui 0.6.5-develop.4384.1.ce2ec6eaaa → 0.6.5-develop.4393.1.de282b5dfd
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/.turbo/turbo-build.log +2 -2
- package/dist/backend/CrudForm.js +27 -7
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +11 -4
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +4 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +212 -0
- package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
- package/dist/backend/messages/index.js +4 -0
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
- package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
- package/dist/backend/notifications/NotificationDispatcher.js +1 -0
- package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
- package/dist/backend/notifications/useNotificationsPoll.js +7 -0
- package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
- package/dist/backend/notifications/useNotificationsSse.js +8 -0
- package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +33 -7
- package/src/backend/DataTable.tsx +34 -4
- package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -2
- package/src/backend/icons/lucideRegistry.generated.tsx +4 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
- package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
- package/src/backend/messages/index.ts +12 -0
- package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
- package/src/backend/notifications/NotificationDispatcher.ts +2 -0
- package/src/backend/notifications/useNotificationsPoll.ts +5 -0
- package/src/backend/notifications/useNotificationsSse.ts +6 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { ArrowDownLeft, ArrowUpRight, Check, Mail, RefreshCw, Reply, TriangleAlert } from 'lucide-react'
|
|
5
|
+
import { Button } from '../../primitives/button'
|
|
6
|
+
import { Badge } from '../../primitives/badge'
|
|
7
|
+
import { Spinner } from '../../primitives/spinner'
|
|
8
|
+
import { Alert, AlertDescription } from '../../primitives/alert'
|
|
9
|
+
import { EmptyState } from '../EmptyState'
|
|
10
|
+
import { formatDateTime } from '@open-mercato/shared/lib/time'
|
|
11
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
12
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
13
|
+
|
|
14
|
+
export type EmailThreadDirection = 'inbound' | 'outbound'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Optimistic delivery status for a just-sent outbound message. Server-confirmed
|
|
18
|
+
* messages leave this `undefined` and render without a status indicator.
|
|
19
|
+
*/
|
|
20
|
+
export type EmailThreadMessageStatus = 'sending' | 'sent' | 'failed'
|
|
21
|
+
|
|
22
|
+
export type EmailThreadMessage = {
|
|
23
|
+
id: string
|
|
24
|
+
messageId: string | null
|
|
25
|
+
rfcMessageId: string | null
|
|
26
|
+
references: string[]
|
|
27
|
+
direction: EmailThreadDirection
|
|
28
|
+
fromName: string | null
|
|
29
|
+
fromEmail: string | null
|
|
30
|
+
to: string[]
|
|
31
|
+
cc: string[]
|
|
32
|
+
subject: string | null
|
|
33
|
+
bodyText: string | null
|
|
34
|
+
sentAt: string
|
|
35
|
+
providerKey: string | null
|
|
36
|
+
/** Optimistic send status; absent on server-confirmed messages. */
|
|
37
|
+
status?: EmailThreadMessageStatus
|
|
38
|
+
/** Human-readable failure reason shown when `status === 'failed'`. */
|
|
39
|
+
statusError?: string | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type EmailThread = {
|
|
43
|
+
threadKey: string
|
|
44
|
+
subject: string | null
|
|
45
|
+
preview: string | null
|
|
46
|
+
participants: string[]
|
|
47
|
+
lastMessageAt: string
|
|
48
|
+
messageCount: number
|
|
49
|
+
providerKey: string | null
|
|
50
|
+
lastDirection: EmailThreadDirection
|
|
51
|
+
messages: EmailThreadMessage[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type EmailThreadsPanelProps = {
|
|
55
|
+
threads: EmailThread[]
|
|
56
|
+
loading?: boolean
|
|
57
|
+
error?: string | null
|
|
58
|
+
/** When false, compose/reply controls are hidden and `composeDisabledHint` is shown. */
|
|
59
|
+
canCompose?: boolean
|
|
60
|
+
composeDisabledHint?: React.ReactNode
|
|
61
|
+
onComposeNew?: () => void
|
|
62
|
+
onReply?: (thread: EmailThread) => void
|
|
63
|
+
onRefresh?: () => void
|
|
64
|
+
/** Invoked when the user retries a message whose `status === 'failed'`. */
|
|
65
|
+
onRetry?: (message: EmailThreadMessage) => void
|
|
66
|
+
className?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatWhen(value: string): string {
|
|
70
|
+
return formatDateTime(value) ?? value
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Entity-agnostic Gmail-style email thread viewer. The host supplies the
|
|
75
|
+
* already-fetched threads plus compose/reply/refresh handlers; this component
|
|
76
|
+
* owns only the master/detail layout, selection, and conversation rendering, so
|
|
77
|
+
* it can be dropped onto the CRM Person page, a Company page, or any module.
|
|
78
|
+
*/
|
|
79
|
+
export function EmailThreadsPanel({
|
|
80
|
+
threads,
|
|
81
|
+
loading = false,
|
|
82
|
+
error = null,
|
|
83
|
+
canCompose = false,
|
|
84
|
+
composeDisabledHint,
|
|
85
|
+
onComposeNew,
|
|
86
|
+
onReply,
|
|
87
|
+
onRefresh,
|
|
88
|
+
onRetry,
|
|
89
|
+
className,
|
|
90
|
+
}: EmailThreadsPanelProps) {
|
|
91
|
+
const t = useT()
|
|
92
|
+
const [selectedKey, setSelectedKey] = React.useState<string | null>(null)
|
|
93
|
+
|
|
94
|
+
// Keep a valid selection as the thread set changes (refresh, new email).
|
|
95
|
+
React.useEffect(() => {
|
|
96
|
+
if (threads.length === 0) {
|
|
97
|
+
setSelectedKey(null)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
setSelectedKey((current) =>
|
|
101
|
+
current && threads.some((thread) => thread.threadKey === current)
|
|
102
|
+
? current
|
|
103
|
+
: threads[0].threadKey,
|
|
104
|
+
)
|
|
105
|
+
}, [threads])
|
|
106
|
+
|
|
107
|
+
const selected = React.useMemo(
|
|
108
|
+
() => threads.find((thread) => thread.threadKey === selectedKey) ?? null,
|
|
109
|
+
[threads, selectedKey],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className={cn('flex flex-col gap-4', className)}>
|
|
114
|
+
<div className="flex items-center justify-between gap-2">
|
|
115
|
+
<div className="text-sm text-muted-foreground">
|
|
116
|
+
{t('ui.email.threads.count', '{count} conversations', { count: threads.length })}
|
|
117
|
+
</div>
|
|
118
|
+
<div className="flex items-center gap-2">
|
|
119
|
+
{onRefresh ? (
|
|
120
|
+
<Button
|
|
121
|
+
type="button"
|
|
122
|
+
variant="outline"
|
|
123
|
+
size="sm"
|
|
124
|
+
className="gap-2"
|
|
125
|
+
onClick={onRefresh}
|
|
126
|
+
disabled={loading}
|
|
127
|
+
>
|
|
128
|
+
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
|
129
|
+
{t('ui.email.threads.refresh', 'Refresh')}
|
|
130
|
+
</Button>
|
|
131
|
+
) : null}
|
|
132
|
+
{canCompose && onComposeNew ? (
|
|
133
|
+
<Button type="button" size="sm" className="gap-2" onClick={onComposeNew}>
|
|
134
|
+
<Mail className="h-4 w-4" />
|
|
135
|
+
{t('ui.email.threads.new', 'New email')}
|
|
136
|
+
</Button>
|
|
137
|
+
) : null}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{!canCompose && composeDisabledHint ? <div>{composeDisabledHint}</div> : null}
|
|
142
|
+
|
|
143
|
+
{error ? (
|
|
144
|
+
<Alert variant="destructive">
|
|
145
|
+
<AlertDescription>{error}</AlertDescription>
|
|
146
|
+
</Alert>
|
|
147
|
+
) : null}
|
|
148
|
+
|
|
149
|
+
{loading && threads.length === 0 ? (
|
|
150
|
+
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
|
151
|
+
<Spinner className="h-4 w-4" />
|
|
152
|
+
{t('ui.email.threads.loading', 'Loading conversations…')}
|
|
153
|
+
</div>
|
|
154
|
+
) : threads.length === 0 ? (
|
|
155
|
+
<EmptyState
|
|
156
|
+
icon={<Mail className="h-6 w-6" />}
|
|
157
|
+
title={t('ui.email.threads.empty.title', 'No emails yet')}
|
|
158
|
+
description={t(
|
|
159
|
+
'ui.email.threads.empty.description',
|
|
160
|
+
'Emails you send or receive with this contact will appear here, grouped into conversations.',
|
|
161
|
+
)}
|
|
162
|
+
/>
|
|
163
|
+
) : (
|
|
164
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
165
|
+
{/* Master: thread list */}
|
|
166
|
+
<div className="md:col-span-1">
|
|
167
|
+
<ul className="max-h-96 divide-y divide-border overflow-y-auto rounded-md border border-border">
|
|
168
|
+
{threads.map((thread) => {
|
|
169
|
+
const isSelected = thread.threadKey === selectedKey
|
|
170
|
+
return (
|
|
171
|
+
<li key={thread.threadKey}>
|
|
172
|
+
<Button
|
|
173
|
+
type="button"
|
|
174
|
+
variant="ghost"
|
|
175
|
+
onClick={() => setSelectedKey(thread.threadKey)}
|
|
176
|
+
className={cn(
|
|
177
|
+
'h-auto w-full flex-col items-stretch justify-start gap-1 whitespace-normal rounded-none p-3 text-left hover:bg-muted',
|
|
178
|
+
isSelected ? 'bg-muted' : 'bg-transparent',
|
|
179
|
+
)}
|
|
180
|
+
aria-current={isSelected}
|
|
181
|
+
>
|
|
182
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
183
|
+
<span className="truncate text-sm font-medium text-foreground">
|
|
184
|
+
{thread.subject || t('ui.email.threads.noSubject', '(no subject)')}
|
|
185
|
+
</span>
|
|
186
|
+
<span className="shrink-0 text-xs text-muted-foreground">
|
|
187
|
+
{formatWhen(thread.lastMessageAt)}
|
|
188
|
+
</span>
|
|
189
|
+
</div>
|
|
190
|
+
<div className="flex items-center justify-between gap-2">
|
|
191
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
192
|
+
{thread.participants.join(', ') ||
|
|
193
|
+
t('ui.email.threads.unknownParticipant', 'Unknown sender')}
|
|
194
|
+
</span>
|
|
195
|
+
<Badge variant="secondary" className="shrink-0">
|
|
196
|
+
{thread.messageCount}
|
|
197
|
+
</Badge>
|
|
198
|
+
</div>
|
|
199
|
+
{thread.preview ? (
|
|
200
|
+
<span className="line-clamp-1 text-xs text-muted-foreground">
|
|
201
|
+
{thread.preview}
|
|
202
|
+
</span>
|
|
203
|
+
) : null}
|
|
204
|
+
</Button>
|
|
205
|
+
</li>
|
|
206
|
+
)
|
|
207
|
+
})}
|
|
208
|
+
</ul>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* Detail: conversation */}
|
|
212
|
+
<div className="md:col-span-2">
|
|
213
|
+
{selected ? (
|
|
214
|
+
<div className="flex flex-col gap-3 rounded-md border border-border p-4">
|
|
215
|
+
<div className="flex items-start justify-between gap-2">
|
|
216
|
+
<h3 className="text-base font-semibold text-foreground">
|
|
217
|
+
{selected.subject || t('ui.email.threads.noSubject', '(no subject)')}
|
|
218
|
+
</h3>
|
|
219
|
+
{canCompose && onReply ? (
|
|
220
|
+
<Button
|
|
221
|
+
type="button"
|
|
222
|
+
variant="outline"
|
|
223
|
+
size="sm"
|
|
224
|
+
className="shrink-0 gap-2"
|
|
225
|
+
onClick={() => onReply(selected)}
|
|
226
|
+
>
|
|
227
|
+
<Reply className="h-4 w-4" />
|
|
228
|
+
{t('ui.email.threads.reply', 'Reply')}
|
|
229
|
+
</Button>
|
|
230
|
+
) : null}
|
|
231
|
+
</div>
|
|
232
|
+
<div className="flex flex-col gap-3">
|
|
233
|
+
{selected.messages.map((message) => (
|
|
234
|
+
<EmailMessageCard key={message.id} message={message} onRetry={onRetry} />
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
) : (
|
|
239
|
+
<div className="flex items-center justify-center rounded-md border border-border p-10 text-sm text-muted-foreground">
|
|
240
|
+
{t('ui.email.threads.selectPrompt', 'Select a conversation to read it.')}
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function EmailMessageCard({
|
|
251
|
+
message,
|
|
252
|
+
onRetry,
|
|
253
|
+
}: {
|
|
254
|
+
message: EmailThreadMessage
|
|
255
|
+
onRetry?: (message: EmailThreadMessage) => void
|
|
256
|
+
}) {
|
|
257
|
+
const t = useT()
|
|
258
|
+
const isOutbound = message.direction === 'outbound'
|
|
259
|
+
const fromLabel = message.fromName
|
|
260
|
+
? `${message.fromName}${message.fromEmail ? ` <${message.fromEmail}>` : ''}`
|
|
261
|
+
: message.fromEmail ?? t('ui.email.threads.unknownParticipant', 'Unknown sender')
|
|
262
|
+
return (
|
|
263
|
+
<div
|
|
264
|
+
className={`rounded-md border border-border p-3 ${isOutbound ? 'bg-accent' : 'bg-muted'}`}
|
|
265
|
+
>
|
|
266
|
+
<div className="mb-2 flex items-start justify-between gap-2">
|
|
267
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
268
|
+
{isOutbound ? (
|
|
269
|
+
<ArrowUpRight className="h-3.5 w-3.5" />
|
|
270
|
+
) : (
|
|
271
|
+
<ArrowDownLeft className="h-3.5 w-3.5" />
|
|
272
|
+
)}
|
|
273
|
+
<span className="font-medium text-foreground">
|
|
274
|
+
{isOutbound ? t('ui.email.threads.you', 'You') : fromLabel}
|
|
275
|
+
</span>
|
|
276
|
+
{message.to.length > 0 ? (
|
|
277
|
+
<span className="truncate">
|
|
278
|
+
{t('ui.email.threads.toLabel', 'to {recipients}', {
|
|
279
|
+
recipients: message.to.join(', '),
|
|
280
|
+
})}
|
|
281
|
+
</span>
|
|
282
|
+
) : null}
|
|
283
|
+
</div>
|
|
284
|
+
<span className="shrink-0 text-xs text-muted-foreground">{formatWhen(message.sentAt)}</span>
|
|
285
|
+
</div>
|
|
286
|
+
<div className="whitespace-pre-wrap break-words text-sm text-foreground">
|
|
287
|
+
{message.bodyText || t('ui.email.threads.noBody', '(no content)')}
|
|
288
|
+
</div>
|
|
289
|
+
<EmailMessageStatus message={message} onRetry={onRetry} />
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Renders the optimistic send-status footer for an outbound message: a spinner
|
|
296
|
+
* while sending, a success check once confirmed, or an error with a Retry action
|
|
297
|
+
* on delivery failure. Server-confirmed messages have no `status` and render
|
|
298
|
+
* nothing here.
|
|
299
|
+
*/
|
|
300
|
+
function EmailMessageStatus({
|
|
301
|
+
message,
|
|
302
|
+
onRetry,
|
|
303
|
+
}: {
|
|
304
|
+
message: EmailThreadMessage
|
|
305
|
+
onRetry?: (message: EmailThreadMessage) => void
|
|
306
|
+
}) {
|
|
307
|
+
const t = useT()
|
|
308
|
+
if (message.status === 'sending') {
|
|
309
|
+
return (
|
|
310
|
+
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground" aria-live="polite">
|
|
311
|
+
<Spinner className="h-3 w-3" />
|
|
312
|
+
{t('ui.email.threads.status.sending', 'Sending…')}
|
|
313
|
+
</div>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
if (message.status === 'sent') {
|
|
317
|
+
return (
|
|
318
|
+
<div className="mt-2 flex items-center gap-1.5 text-xs text-status-success-text" aria-live="polite">
|
|
319
|
+
<Check className="h-3.5 w-3.5 text-status-success-icon" />
|
|
320
|
+
{t('ui.email.threads.status.sent', 'Sent')}
|
|
321
|
+
</div>
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
if (message.status === 'failed') {
|
|
325
|
+
return (
|
|
326
|
+
<div
|
|
327
|
+
className="mt-2 flex flex-wrap items-center gap-2 text-xs text-status-error-text"
|
|
328
|
+
aria-live="polite"
|
|
329
|
+
>
|
|
330
|
+
<span className="flex items-center gap-1.5">
|
|
331
|
+
<TriangleAlert className="h-3.5 w-3.5 text-status-error-icon" />
|
|
332
|
+
{message.statusError || t('ui.email.threads.status.failed', 'Not delivered')}
|
|
333
|
+
</span>
|
|
334
|
+
{onRetry ? (
|
|
335
|
+
<Button
|
|
336
|
+
type="button"
|
|
337
|
+
variant="outline"
|
|
338
|
+
size="sm"
|
|
339
|
+
className="h-auto px-2 py-0.5 text-xs"
|
|
340
|
+
onClick={() => onRetry(message)}
|
|
341
|
+
>
|
|
342
|
+
{t('ui.email.threads.status.retry', 'Retry')}
|
|
343
|
+
</Button>
|
|
344
|
+
) : null}
|
|
345
|
+
</div>
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
return null
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export default EmailThreadsPanel
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { mergeOptimisticEmailThreads, type OptimisticEmailMessage } from '../mergeOptimisticEmailThreads'
|
|
2
|
+
import type { EmailThread, EmailThreadMessage } from '../EmailThreadsPanel'
|
|
3
|
+
|
|
4
|
+
function message(overrides: Partial<EmailThreadMessage> & { id: string }): EmailThreadMessage {
|
|
5
|
+
return {
|
|
6
|
+
messageId: overrides.id,
|
|
7
|
+
rfcMessageId: null,
|
|
8
|
+
references: [],
|
|
9
|
+
direction: 'outbound',
|
|
10
|
+
fromName: null,
|
|
11
|
+
fromEmail: null,
|
|
12
|
+
to: ['contact@example.com'],
|
|
13
|
+
cc: [],
|
|
14
|
+
subject: 'Hello',
|
|
15
|
+
bodyText: 'Body',
|
|
16
|
+
sentAt: '2026-06-02T10:00:00.000Z',
|
|
17
|
+
providerKey: 'gmail',
|
|
18
|
+
...overrides,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function thread(overrides: Partial<EmailThread> & { threadKey: string }): EmailThread {
|
|
23
|
+
const messages = overrides.messages ?? [message({ id: `${overrides.threadKey}-m1` })]
|
|
24
|
+
const last = messages[messages.length - 1]
|
|
25
|
+
return {
|
|
26
|
+
subject: 'Hello',
|
|
27
|
+
preview: 'Body',
|
|
28
|
+
participants: ['contact@example.com'],
|
|
29
|
+
lastMessageAt: last.sentAt,
|
|
30
|
+
messageCount: messages.length,
|
|
31
|
+
providerKey: 'gmail',
|
|
32
|
+
lastDirection: last.direction,
|
|
33
|
+
...overrides,
|
|
34
|
+
messages,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function optimistic(
|
|
39
|
+
overrides: Partial<EmailThreadMessage> & { id: string; threadKey: string },
|
|
40
|
+
): OptimisticEmailMessage {
|
|
41
|
+
const { threadKey, ...rest } = overrides
|
|
42
|
+
return { ...message(rest), threadKey, status: 'sending' }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('mergeOptimisticEmailThreads', () => {
|
|
46
|
+
it('returns the server threads unchanged when there are no optimistic messages', () => {
|
|
47
|
+
const server = [thread({ threadKey: 't1' })]
|
|
48
|
+
expect(mergeOptimisticEmailThreads(server, [])).toBe(server)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('appends an optimistic message to its matching server thread, sorted by sentAt', () => {
|
|
52
|
+
const server = [
|
|
53
|
+
thread({
|
|
54
|
+
threadKey: 't1',
|
|
55
|
+
messages: [message({ id: 'srv-1', messageId: 'srv-1', sentAt: '2026-06-02T09:00:00.000Z' })],
|
|
56
|
+
}),
|
|
57
|
+
]
|
|
58
|
+
const opt = optimistic({
|
|
59
|
+
id: 'optimistic:c1',
|
|
60
|
+
messageId: 'msg-new',
|
|
61
|
+
threadKey: 't1',
|
|
62
|
+
sentAt: '2026-06-02T11:00:00.000Z',
|
|
63
|
+
})
|
|
64
|
+
const merged = mergeOptimisticEmailThreads(server, [opt])
|
|
65
|
+
expect(merged).toHaveLength(1)
|
|
66
|
+
expect(merged[0].messages.map((m) => m.id)).toEqual(['srv-1', 'optimistic:c1'])
|
|
67
|
+
expect(merged[0].messageCount).toBe(2)
|
|
68
|
+
expect(merged[0].lastMessageAt).toBe('2026-06-02T11:00:00.000Z')
|
|
69
|
+
expect(merged[0].messages[1].status).toBe('sending')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('drops the optimistic copy once the server thread contains the same messageId (no duplicate)', () => {
|
|
73
|
+
const server = [
|
|
74
|
+
thread({
|
|
75
|
+
threadKey: 't1',
|
|
76
|
+
messages: [message({ id: 'srv-row', messageId: 'msg-1' })],
|
|
77
|
+
}),
|
|
78
|
+
]
|
|
79
|
+
const opt = optimistic({ id: 'optimistic:c1', messageId: 'msg-1', threadKey: 't1' })
|
|
80
|
+
const merged = mergeOptimisticEmailThreads(server, [opt])
|
|
81
|
+
expect(merged).toBe(server) // nothing left to overlay
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('creates a new thread when the optimistic threadKey matches no server thread', () => {
|
|
85
|
+
const server = [
|
|
86
|
+
thread({ threadKey: 't1', messages: [message({ id: 's1', messageId: 's1', sentAt: '2026-06-02T08:00:00.000Z' })] }),
|
|
87
|
+
]
|
|
88
|
+
const opt = optimistic({
|
|
89
|
+
id: 'optimistic:c1',
|
|
90
|
+
messageId: 'msg-new',
|
|
91
|
+
threadKey: 'new-thread',
|
|
92
|
+
subject: 'Fresh',
|
|
93
|
+
sentAt: '2026-06-02T12:00:00.000Z',
|
|
94
|
+
})
|
|
95
|
+
const merged = mergeOptimisticEmailThreads(server, [opt])
|
|
96
|
+
expect(merged).toHaveLength(2)
|
|
97
|
+
// Newest thread (the optimistic one) sorts first.
|
|
98
|
+
expect(merged[0].threadKey).toBe('new-thread')
|
|
99
|
+
expect(merged[0].subject).toBe('Fresh')
|
|
100
|
+
expect(merged[0].messages[0].status).toBe('sending')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('preserves a failed status and keeps it visible until the server has the message', () => {
|
|
104
|
+
const server = [thread({ threadKey: 't1', messages: [message({ id: 's1', messageId: 's1' })] })]
|
|
105
|
+
const opt = optimistic({
|
|
106
|
+
id: 'optimistic:c1',
|
|
107
|
+
messageId: 'msg-failed',
|
|
108
|
+
threadKey: 't1',
|
|
109
|
+
sentAt: '2026-06-02T13:00:00.000Z',
|
|
110
|
+
})
|
|
111
|
+
opt.status = 'failed'
|
|
112
|
+
opt.statusError = 'SMTP rejected'
|
|
113
|
+
const merged = mergeOptimisticEmailThreads(server, [opt])
|
|
114
|
+
const last = merged[0].messages[merged[0].messages.length - 1]
|
|
115
|
+
expect(last.status).toBe('failed')
|
|
116
|
+
expect(last.statusError).toBe('SMTP rejected')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('does not mutate the input arrays', () => {
|
|
120
|
+
const server = [thread({ threadKey: 't1', messages: [message({ id: 's1', messageId: 's1' })] })]
|
|
121
|
+
const serverSnapshot = JSON.parse(JSON.stringify(server))
|
|
122
|
+
const opt = optimistic({ id: 'optimistic:c1', messageId: 'msg-new', threadKey: 't1' })
|
|
123
|
+
mergeOptimisticEmailThreads(server, [opt])
|
|
124
|
+
expect(server).toEqual(serverSnapshot)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -22,3 +22,15 @@ export type { MessagePollItem, UseMessagesPollResult } from './useMessagesPoll'
|
|
|
22
22
|
|
|
23
23
|
export { MessageObjectPreview } from './MessageObjectPreview'
|
|
24
24
|
export { MessageObjectDetail } from './MessageObjectDetail'
|
|
25
|
+
|
|
26
|
+
export { EmailThreadsPanel } from './EmailThreadsPanel'
|
|
27
|
+
export type {
|
|
28
|
+
EmailThread,
|
|
29
|
+
EmailThreadMessage,
|
|
30
|
+
EmailThreadMessageStatus,
|
|
31
|
+
EmailThreadDirection,
|
|
32
|
+
EmailThreadsPanelProps,
|
|
33
|
+
} from './EmailThreadsPanel'
|
|
34
|
+
|
|
35
|
+
export { mergeOptimisticEmailThreads } from './mergeOptimisticEmailThreads'
|
|
36
|
+
export type { OptimisticEmailMessage } from './mergeOptimisticEmailThreads'
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { EmailThread, EmailThreadMessage } from './EmailThreadsPanel'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An optimistic (client-side, not-yet-server-confirmed) outbound message plus
|
|
5
|
+
* the key of the thread it belongs to. `message.messageId` is the Open Mercato
|
|
6
|
+
* Message id returned by the send API — it is the reconciliation key against the
|
|
7
|
+
* server-fetched threads.
|
|
8
|
+
*/
|
|
9
|
+
export type OptimisticEmailMessage = EmailThreadMessage & { threadKey: string }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Overlay optimistic "sending" / "sent" / "failed" messages onto the
|
|
13
|
+
* server-fetched threads so a just-sent email appears immediately and is then
|
|
14
|
+
* replaced by the real record once the delivery worker links it.
|
|
15
|
+
*
|
|
16
|
+
* Reconciliation is keyed on `messageId`: when a server thread already contains a
|
|
17
|
+
* message with the same `messageId` as an optimistic one, the optimistic copy is
|
|
18
|
+
* dropped (the server record wins) so the message never appears twice. Ordering
|
|
19
|
+
* matches the server build: messages ascending by `sentAt`, threads descending by
|
|
20
|
+
* `lastMessageAt`. The input arrays are never mutated.
|
|
21
|
+
*/
|
|
22
|
+
export function mergeOptimisticEmailThreads(
|
|
23
|
+
serverThreads: EmailThread[],
|
|
24
|
+
optimistic: OptimisticEmailMessage[],
|
|
25
|
+
): EmailThread[] {
|
|
26
|
+
if (optimistic.length === 0) return serverThreads
|
|
27
|
+
|
|
28
|
+
const serverMessageIds = new Set<string>()
|
|
29
|
+
for (const thread of serverThreads) {
|
|
30
|
+
for (const message of thread.messages) {
|
|
31
|
+
if (message.messageId) serverMessageIds.add(message.messageId)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Drop optimistic copies the server has already linked (dedupe by messageId).
|
|
36
|
+
const pending = optimistic.filter(
|
|
37
|
+
(entry) => !(entry.messageId && serverMessageIds.has(entry.messageId)),
|
|
38
|
+
)
|
|
39
|
+
if (pending.length === 0) return serverThreads
|
|
40
|
+
|
|
41
|
+
const byKey = new Map<string, EmailThread>()
|
|
42
|
+
const order: string[] = []
|
|
43
|
+
for (const thread of serverThreads) {
|
|
44
|
+
byKey.set(thread.threadKey, thread)
|
|
45
|
+
order.push(thread.threadKey)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const entry of pending) {
|
|
49
|
+
const { threadKey, ...message } = entry
|
|
50
|
+
const existing = byKey.get(threadKey)
|
|
51
|
+
if (existing) {
|
|
52
|
+
// Guard against double-adding the same optimistic message across re-renders.
|
|
53
|
+
if (existing.messages.some((m) => m.id === message.id)) continue
|
|
54
|
+
const messages = [...existing.messages, message].sort((a, b) =>
|
|
55
|
+
a.sentAt.localeCompare(b.sentAt),
|
|
56
|
+
)
|
|
57
|
+
const last = messages[messages.length - 1]
|
|
58
|
+
byKey.set(threadKey, {
|
|
59
|
+
...existing,
|
|
60
|
+
messages,
|
|
61
|
+
messageCount: messages.length,
|
|
62
|
+
lastMessageAt: last?.sentAt ?? existing.lastMessageAt,
|
|
63
|
+
lastDirection: last?.direction ?? existing.lastDirection,
|
|
64
|
+
})
|
|
65
|
+
} else {
|
|
66
|
+
byKey.set(threadKey, {
|
|
67
|
+
threadKey,
|
|
68
|
+
subject: message.subject,
|
|
69
|
+
preview: message.bodyText ? message.bodyText.slice(0, 140) : null,
|
|
70
|
+
participants: message.to,
|
|
71
|
+
lastMessageAt: message.sentAt,
|
|
72
|
+
messageCount: 1,
|
|
73
|
+
providerKey: message.providerKey,
|
|
74
|
+
lastDirection: message.direction,
|
|
75
|
+
messages: [message],
|
|
76
|
+
})
|
|
77
|
+
order.push(threadKey)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return order
|
|
82
|
+
.map((key) => byKey.get(key)!)
|
|
83
|
+
.sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt))
|
|
84
|
+
}
|
|
@@ -15,6 +15,7 @@ type RuntimeContext = {
|
|
|
15
15
|
userId?: string
|
|
16
16
|
features: string[]
|
|
17
17
|
currentPath: string
|
|
18
|
+
t?: (key: string, fallback?: string) => string
|
|
18
19
|
refreshNotifications: () => void
|
|
19
20
|
navigate: (href: string) => void
|
|
20
21
|
markAsRead: (notificationId: string) => Promise<void>
|
|
@@ -57,6 +58,7 @@ function buildHandlerContext(runtime: RuntimeContext): NotificationHandlerContex
|
|
|
57
58
|
userId: runtime.userId,
|
|
58
59
|
features: runtime.features,
|
|
59
60
|
currentPath: runtime.currentPath,
|
|
61
|
+
t: runtime.t,
|
|
60
62
|
toast(options) {
|
|
61
63
|
const text = options.body ? `${options.title}: ${options.body}` : options.title
|
|
62
64
|
flash(text, toFlashKind(options.severity))
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
emitNotificationCountChanged,
|
|
7
7
|
} from '@open-mercato/shared/lib/frontend/notificationEvents'
|
|
8
8
|
import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
|
|
9
|
+
import { useOptionalT } from '@open-mercato/shared/lib/i18n/context'
|
|
9
10
|
import {
|
|
10
11
|
dispatchNotificationHandlers,
|
|
11
12
|
getRequiredNotificationHandlerFeatures,
|
|
@@ -36,6 +37,9 @@ export function useNotificationsPoll(): UseNotificationsPollResult {
|
|
|
36
37
|
const [isLoading, setIsLoading] = React.useState(true)
|
|
37
38
|
const [error, setError] = React.useState<string | null>(null)
|
|
38
39
|
const grantedFeaturesRef = React.useRef<string[]>([])
|
|
40
|
+
const translate = useOptionalT()
|
|
41
|
+
const translateRef = React.useRef(translate)
|
|
42
|
+
React.useEffect(() => { translateRef.current = translate }, [translate])
|
|
39
43
|
const lastIdRef = React.useRef<string | null>(null)
|
|
40
44
|
const prevUnreadRef = React.useRef(0)
|
|
41
45
|
const {
|
|
@@ -76,6 +80,7 @@ export function useNotificationsPoll(): UseNotificationsPollResult {
|
|
|
76
80
|
if (newNotifications.length > 0) {
|
|
77
81
|
dispatchNotificationHandlers(newNotifications, {
|
|
78
82
|
features: grantedFeaturesRef.current,
|
|
83
|
+
t: translateRef.current,
|
|
79
84
|
currentPath:
|
|
80
85
|
typeof window === 'undefined'
|
|
81
86
|
? '/'
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from '@open-mercato/shared/lib/frontend/notificationEvents'
|
|
8
8
|
import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
|
|
9
9
|
import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
|
|
10
|
+
import { useOptionalT } from '@open-mercato/shared/lib/i18n/context'
|
|
10
11
|
import { useAppEvent } from '../injection/useAppEvent'
|
|
11
12
|
import {
|
|
12
13
|
dispatchNotificationHandlers,
|
|
@@ -46,6 +47,9 @@ export function useNotificationsSse(): UseNotificationsSseResult {
|
|
|
46
47
|
const [isLoading, setIsLoading] = React.useState(true)
|
|
47
48
|
const [error, setError] = React.useState<string | null>(null)
|
|
48
49
|
const grantedFeaturesRef = React.useRef<string[]>([])
|
|
50
|
+
const translate = useOptionalT()
|
|
51
|
+
const translateRef = React.useRef(translate)
|
|
52
|
+
React.useEffect(() => { translateRef.current = translate }, [translate])
|
|
49
53
|
const lastIdRef = React.useRef<string | null>(null)
|
|
50
54
|
const prevUnreadRef = React.useRef(0)
|
|
51
55
|
const {
|
|
@@ -73,6 +77,7 @@ export function useNotificationsSse(): UseNotificationsSseResult {
|
|
|
73
77
|
lastIdRef.current = fetched[0].id
|
|
74
78
|
dispatchNotificationHandlers(fetched, {
|
|
75
79
|
features: grantedFeaturesRef.current,
|
|
80
|
+
t: translateRef.current,
|
|
76
81
|
currentPath:
|
|
77
82
|
typeof window === 'undefined'
|
|
78
83
|
? '/'
|
|
@@ -162,6 +167,7 @@ export function useNotificationsSse(): UseNotificationsSseResult {
|
|
|
162
167
|
window.setTimeout(() => setHasNew(false), 3000)
|
|
163
168
|
dispatchNotificationHandlers([notification], {
|
|
164
169
|
features: grantedFeaturesRef.current,
|
|
170
|
+
t: translateRef.current,
|
|
165
171
|
currentPath:
|
|
166
172
|
typeof window === 'undefined'
|
|
167
173
|
? '/'
|