@open-mercato/ui 0.6.5-develop.4384.1.ce2ec6eaaa → 0.6.5-develop.4397.1.9a65481757

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/backend/CrudForm.js +27 -7
  3. package/dist/backend/CrudForm.js.map +3 -3
  4. package/dist/backend/DataTable.js +11 -4
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/icons/lucideRegistry.generated.js +4 -0
  7. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  8. package/dist/backend/messages/EmailThreadsPanel.js +212 -0
  9. package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
  10. package/dist/backend/messages/index.js +4 -0
  11. package/dist/backend/messages/index.js.map +2 -2
  12. package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
  13. package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
  14. package/dist/backend/notifications/NotificationDispatcher.js +1 -0
  15. package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
  16. package/dist/backend/notifications/useNotificationsPoll.js +7 -0
  17. package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
  18. package/dist/backend/notifications/useNotificationsSse.js +8 -0
  19. package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
  20. package/package.json +3 -3
  21. package/src/backend/CrudForm.tsx +33 -7
  22. package/src/backend/DataTable.tsx +34 -4
  23. package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -2
  24. package/src/backend/icons/lucideRegistry.generated.tsx +4 -0
  25. package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
  26. package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
  27. package/src/backend/messages/index.ts +12 -0
  28. package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
  29. package/src/backend/notifications/NotificationDispatcher.ts +2 -0
  30. package/src/backend/notifications/useNotificationsPoll.ts +5 -0
  31. 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
  ? '/'