@asteby/metacore-notifications 38.0.0 → 38.2.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/dropdown.js CHANGED
@@ -23,53 +23,201 @@ const DEFAULT_LABELS = {
23
23
  permissionRequestFailed: 'No se pudo abrir la solicitud de permisos',
24
24
  permissionRequired: 'Permisos requeridos',
25
25
  srLabel: 'Notificaciones',
26
+ loading: 'Cargando…',
27
+ error: 'No se pudieron cargar las notificaciones',
28
+ retry: 'Reintentar',
29
+ delete: 'Eliminar notificación',
30
+ confirmDelete: 'Pulsa de nuevo para eliminar',
31
+ confirmDeleteKey: 'Pulsa Supr otra vez para eliminar',
26
32
  };
33
+ /** Default list contract: `{ data: NotificationItem[] }`. */
34
+ function defaultParseList(body) {
35
+ const data = body?.data;
36
+ return { items: Array.isArray(data) ? data : [] };
37
+ }
38
+ function initialsOf(name) {
39
+ const parts = name.trim().split(/\s+/).filter(Boolean);
40
+ if (parts.length === 0)
41
+ return '';
42
+ const first = parts[0][0] ?? '';
43
+ const last = parts.length > 1 ? (parts[parts.length - 1][0] ?? '') : '';
44
+ return (first + last).toUpperCase();
45
+ }
46
+ function sameId(a, b) {
47
+ return String(a) === String(b);
48
+ }
27
49
  /**
28
50
  * Bell-icon dropdown: REST list + live ingest (SSE preferred, WebSocket
29
51
  * fallback) + optional canonical card toast on every new item.
52
+ *
53
+ * Every prop beyond `apiClient` / `apiBasePath` is optional; without them the
54
+ * component keeps its original contract (`{ data: [] }` list, one PATCH per
55
+ * item, local unread count).
30
56
  */
31
- export function NotificationsDropdown({ apiClient, apiBasePath, enableBadge = true, onNotificationClick, perPage = 20, locale = es, labels: labelsOverride, resolveImageUrl, subscribeToNotifications, sseUrl, sseAccessToken, preferSse, showToastOnIngest = true, }) {
57
+ export function NotificationsDropdown({ apiClient, apiBasePath, enableBadge = true, onNotificationClick, perPage = 20, locale = es, labels: labelsOverride, resolveImageUrl, subscribeToNotifications, sseUrl, sseAccessToken, preferSse, showToastOnIngest = true, listParams, parseList, normalizeItem, onMarkRead, onMarkAllRead, onDelete, fetchUnreadCount, unreadPollMs, badgeMax = 99, triggerAriaLabel, onIngest, footer, closeOnClick, renderAvatar, richText = true, reloadAfterPermission = true, showLoadStates = false, }) {
32
58
  const labels = useMemo(() => ({ ...DEFAULT_LABELS, ...labelsOverride }), [labelsOverride]);
33
59
  const [notifications, setNotifications] = useState([]);
34
60
  const [unreadCount, setUnreadCount] = useState(0);
35
- const [, setLoading] = useState(false);
61
+ const [isLoading, setLoading] = useState(true);
62
+ const [error, setError] = useState(null);
63
+ const [open, setOpen] = useState(false);
36
64
  const { setBadge } = useAppBadge();
37
65
  const seenIdsRef = useRef(new Set());
38
- const apiClientRef = useRef(apiClient);
39
- apiClientRef.current = apiClient;
40
- const basePathRef = useRef(apiBasePath);
41
- basePathRef.current = apiBasePath;
66
+ const requestSeqRef = useRef(0);
67
+ // Latest-props refs: callbacks stay referentially stable (the live
68
+ // subscriptions must not reconnect when a host passes inline functions).
69
+ const latest = {
70
+ apiClient,
71
+ apiBasePath,
72
+ perPage,
73
+ listParams,
74
+ parseList,
75
+ normalizeItem,
76
+ onMarkRead,
77
+ onMarkAllRead,
78
+ onDelete,
79
+ fetchUnreadCount,
80
+ onIngest,
81
+ onNotificationClick,
82
+ richText,
83
+ };
84
+ const latestRef = useRef(latest);
85
+ latestRef.current = latest;
86
+ const notificationsRef = useRef(notifications);
87
+ notificationsRef.current = notifications;
88
+ const unreadCountRef = useRef(unreadCount);
89
+ unreadCountRef.current = unreadCount;
90
+ // Optimistic actions in flight. The unread poll ignores any result that
91
+ // overlaps an action (it would overwrite the optimistic counter).
92
+ const inflightRef = useRef(0);
93
+ const epochRef = useRef(0);
94
+ // Rows removed optimistically by a pending delete, with their CURRENT
95
+ // is_read, so a failed delete restores the up-to-date state.
96
+ const pendingDeletesRef = useRef(new Map());
97
+ /** Apply a list update synchronously to the ref too, so chained rollbacks see it. */
98
+ const commitList = (fn) => {
99
+ const next = fn(notificationsRef.current);
100
+ notificationsRef.current = next;
101
+ setNotifications(next);
102
+ };
103
+ const commitCount = (fn) => {
104
+ const next = fn(unreadCountRef.current);
105
+ unreadCountRef.current = next;
106
+ setUnreadCount(next);
107
+ };
108
+ const trackAction = async (run) => {
109
+ inflightRef.current += 1;
110
+ epochRef.current += 1;
111
+ try {
112
+ return await run();
113
+ }
114
+ finally {
115
+ inflightRef.current -= 1;
116
+ epochRef.current += 1;
117
+ }
118
+ };
42
119
  useEffect(() => {
43
120
  if (!enableBadge)
44
121
  return;
45
122
  setBadge(unreadCount);
46
123
  }, [unreadCount, setBadge, enableBadge]);
124
+ const resolveParams = () => {
125
+ const lp = latestRef.current.listParams;
126
+ const pp = latestRef.current.perPage;
127
+ if (typeof lp === 'function')
128
+ return lp(pp);
129
+ if (lp)
130
+ return lp;
131
+ return { orderBy: 'created_at', orderDir: 'desc', per_page: pp };
132
+ };
133
+ // A non-serializable / throwing `listParams` must never break rendering; the
134
+ // request itself reports the failure through the error state.
135
+ let paramsKey;
136
+ try {
137
+ paramsKey = JSON.stringify(resolveParams());
138
+ }
139
+ catch {
140
+ paramsKey = '__unserializable__';
141
+ }
142
+ const toItem = (raw) => {
143
+ const fn = latestRef.current.normalizeItem;
144
+ return fn ? fn(raw) : raw;
145
+ };
47
146
  const fetchNotifications = useCallback(async () => {
147
+ const seq = ++requestSeqRef.current;
48
148
  try {
49
149
  setLoading(true);
50
- const response = await apiClientRef.current.get(basePathRef.current, {
51
- params: {
52
- orderBy: 'created_at',
53
- orderDir: 'desc',
54
- per_page: perPage,
55
- },
150
+ setError(null);
151
+ const cur = latestRef.current;
152
+ const response = await cur.apiClient.get(cur.apiBasePath, {
153
+ params: resolveParams(),
56
154
  });
57
- const items = response.data?.data ?? [];
58
- setNotifications(items);
59
- setUnreadCount(items.filter((n) => !n.is_read).length);
60
- seenIdsRef.current = new Set(items.map((n) => n.id));
155
+ if (seq !== requestSeqRef.current)
156
+ return;
157
+ const parsed = (cur.parseList ?? defaultParseList)(response.data);
158
+ const items = parsed.items.map(toItem);
159
+ commitList(() => items);
160
+ commitCount(() => typeof parsed.unreadCount === 'number'
161
+ ? parsed.unreadCount
162
+ : items.filter((n) => !n.is_read).length);
163
+ seenIdsRef.current = new Set(items.map((n) => String(n.id)));
61
164
  }
62
- catch (error) {
165
+ catch (err) {
166
+ if (seq !== requestSeqRef.current)
167
+ return;
168
+ setError(err);
63
169
  // eslint-disable-next-line no-console
64
- console.error('Failed to fetch notifications:', error);
170
+ console.error('Failed to fetch notifications:', err);
65
171
  }
66
172
  finally {
67
- setLoading(false);
173
+ if (seq === requestSeqRef.current)
174
+ setLoading(false);
68
175
  }
69
- }, [perPage]);
176
+ // eslint-disable-next-line react-hooks/exhaustive-deps
177
+ }, []);
178
+ useEffect(() => {
179
+ void fetchNotifications();
180
+ }, [fetchNotifications, apiBasePath, perPage, paramsKey]);
181
+ // Server-side unread counter polling (visible tab only).
182
+ const hasUnreadFetcher = Boolean(fetchUnreadCount);
70
183
  useEffect(() => {
71
- fetchNotifications();
72
- }, [fetchNotifications, apiBasePath]);
184
+ if (!hasUnreadFetcher || !unreadPollMs || unreadPollMs <= 0)
185
+ return;
186
+ if (typeof document === 'undefined')
187
+ return;
188
+ let cancelled = false;
189
+ const poll = async () => {
190
+ if (document.visibilityState !== 'visible')
191
+ return;
192
+ const fn = latestRef.current.fetchUnreadCount;
193
+ if (!fn)
194
+ return;
195
+ const startEpoch = epochRef.current;
196
+ try {
197
+ const n = await fn();
198
+ // An optimistic action started or finished while the poll was in
199
+ // flight: its result may predate the action, so drop it (the next
200
+ // tick reconciles).
201
+ if (inflightRef.current > 0 || epochRef.current !== startEpoch)
202
+ return;
203
+ if (!cancelled && typeof n === 'number' && Number.isFinite(n)) {
204
+ commitCount(() => Math.max(0, n));
205
+ }
206
+ }
207
+ catch (err) {
208
+ // eslint-disable-next-line no-console
209
+ console.error('Failed to fetch unread count:', err);
210
+ }
211
+ };
212
+ const timer = setInterval(() => void poll(), unreadPollMs);
213
+ const onVisible = () => void poll();
214
+ document.addEventListener('visibilitychange', onVisible);
215
+ return () => {
216
+ cancelled = true;
217
+ clearInterval(timer);
218
+ document.removeEventListener('visibilitychange', onVisible);
219
+ };
220
+ }, [hasUnreadFetcher, unreadPollMs]);
73
221
  const ingestWsPayload = useCallback((payload) => {
74
222
  // Deterministic id BEFORE the random fallback: the same notification
75
223
  // arrives over BOTH transports (WS frame + SSE, by design — SSE can
@@ -84,27 +232,28 @@ export function NotificationsDropdown({ apiClient, apiBasePath, enableBadge = tr
84
232
  : parseNotificationMeta(typeof payload.metadata === 'string' ? payload.metadata : undefined);
85
233
  const recordId = earlyMeta.record_id;
86
234
  const eventKey = (earlyMeta.event ?? earlyMeta.rule);
87
- const id = payload.id ||
88
- (recordId && eventKey ? `ntf:${eventKey}:${recordId}` : '') ||
89
- (typeof crypto !== 'undefined' && 'randomUUID' in crypto
90
- ? crypto.randomUUID()
91
- : `${Date.now()}-${Math.random()}`);
92
- if (seenIdsRef.current.has(id))
93
- return;
94
- seenIdsRef.current.add(id);
95
- const metaObj = typeof payload.metadata === 'object' && payload.metadata
235
+ const hasPayloadId = payload.id !== undefined && payload.id !== null && payload.id !== '';
236
+ // `payload.id` of 0 is a valid id: test for null/undefined/'' only.
237
+ const id = hasPayloadId
238
+ ? payload.id
239
+ : recordId && eventKey
240
+ ? `ntf:${eventKey}:${recordId}`
241
+ : typeof crypto !== 'undefined' && 'randomUUID' in crypto
242
+ ? crypto.randomUUID()
243
+ : `${Date.now()}-${Math.random()}`;
244
+ const baseMeta = typeof payload.metadata === 'object' && payload.metadata
96
245
  ? { ...payload.metadata }
97
246
  : parseNotificationMeta(typeof payload.metadata === 'string' ? payload.metadata : undefined);
98
247
  if (payload.addon_key)
99
- metaObj.addon_key = payload.addon_key;
248
+ baseMeta.addon_key = payload.addon_key;
100
249
  if (payload.apartado)
101
- metaObj.apartado = payload.apartado;
250
+ baseMeta.apartado = payload.apartado;
102
251
  if (payload.color)
103
- metaObj.color = payload.color;
252
+ baseMeta.color = payload.color;
104
253
  const metadataStr = typeof payload.metadata === 'string'
105
254
  ? payload.metadata
106
- : JSON.stringify(metaObj);
107
- const newNotification = {
255
+ : JSON.stringify(baseMeta);
256
+ let newNotification = {
108
257
  id,
109
258
  title: payload.title,
110
259
  message: payload.body || payload.message || payload.description || '',
@@ -117,31 +266,72 @@ export function NotificationsDropdown({ apiClient, apiBasePath, enableBadge = tr
117
266
  metadata: metadataStr,
118
267
  conversation_id: payload.conversation_id,
119
268
  };
120
- setNotifications((prev) => [newNotification, ...prev]);
121
- setUnreadCount((prev) => prev + 1);
269
+ let metaObj = baseMeta;
270
+ const normalize = latestRef.current.normalizeItem;
271
+ if (normalize) {
272
+ try {
273
+ const normalized = normalize(payload);
274
+ const hasId = normalized.id !== undefined && normalized.id !== null && normalized.id !== '';
275
+ newNotification = { ...normalized, id: hasId ? normalized.id : id };
276
+ metaObj =
277
+ typeof normalized.metadata === 'object' && normalized.metadata
278
+ ? { ...normalized.metadata }
279
+ : parseNotificationMeta(typeof normalized.metadata === 'string' ? normalized.metadata : undefined);
280
+ if (payload.addon_key)
281
+ metaObj.addon_key = payload.addon_key;
282
+ if (payload.apartado)
283
+ metaObj.apartado = payload.apartado;
284
+ if (payload.color)
285
+ metaObj.color = payload.color;
286
+ }
287
+ catch (err) {
288
+ // eslint-disable-next-line no-console
289
+ console.error('normalizeItem failed for a live notification:', err);
290
+ }
291
+ }
292
+ const key = String(newNotification.id);
293
+ if (seenIdsRef.current.has(key))
294
+ return;
295
+ seenIdsRef.current.add(key);
296
+ commitList((prev) => [newNotification, ...prev]);
297
+ commitCount((prev) => prev + 1);
122
298
  // Quiet frames from POST /notifications/me already toasted on the client.
123
299
  const skipToast = Boolean(payload.skip_toast) ||
124
300
  metaObj.skip_toast === true ||
125
301
  metaObj.source === 'toast';
126
302
  if (showToastOnIngest && !skipToast) {
127
303
  showNotificationToast({
128
- id,
129
- title: payload.title,
130
- body: payload.body || payload.message || payload.description,
131
- type: payload.type || 'info',
132
- icon: payload.icon,
133
- image: payload.image,
304
+ // String id with a prefix: a raw numeric id could collide with
305
+ // sonner's own auto-incremented numeric toast ids.
306
+ id: `ntf-${String(newNotification.id)}`,
307
+ title: newNotification.title,
308
+ body: newNotification.message || undefined,
309
+ type: newNotification.type || 'info',
310
+ icon: newNotification.icon,
311
+ image: newNotification.image,
134
312
  apartado: payload.apartado,
135
313
  addonKey: payload.addon_key || metaObj.addon_key,
136
314
  metadata: metaObj,
315
+ richText: latestRef.current.richText,
137
316
  onClick: () => {
138
- if (onNotificationClick)
139
- onNotificationClick(newNotification);
140
- else if (payload.link?.startsWith('http'))
141
- window.open(payload.link, '_blank');
317
+ const click = latestRef.current.onNotificationClick;
318
+ if (click)
319
+ click(newNotification);
320
+ else if (newNotification.link?.startsWith('http'))
321
+ window.open(newNotification.link, '_blank', 'noopener,noreferrer');
142
322
  },
143
323
  });
144
324
  }
325
+ const ingestHook = latestRef.current.onIngest;
326
+ if (ingestHook) {
327
+ try {
328
+ ingestHook(payload, newNotification);
329
+ }
330
+ catch (err) {
331
+ // eslint-disable-next-line no-console
332
+ console.error('onIngest handler failed:', err);
333
+ }
334
+ }
145
335
  if (typeof window !== 'undefined' &&
146
336
  'Notification' in window &&
147
337
  Notification.permission === 'default' &&
@@ -153,7 +343,7 @@ export function NotificationsDropdown({ apiClient, apiBasePath, enableBadge = tr
153
343
  true;
154
344
  window.dispatchEvent(new CustomEvent('show-notification-prompt'));
155
345
  }
156
- }, [showToastOnIngest, onNotificationClick]);
346
+ }, [showToastOnIngest]);
157
347
  const useCustomSubscription = Boolean(subscribeToNotifications);
158
348
  const useSse = Boolean(sseUrl);
159
349
  const skipBuiltInWs = useCustomSubscription || (useSse && preferSse !== false);
@@ -175,43 +365,150 @@ export function NotificationsDropdown({ apiClient, apiBasePath, enableBadge = tr
175
365
  onMessage: ingestWsPayload,
176
366
  });
177
367
  }, [useSse, sseUrl, sseAccessToken, ingestWsPayload]);
178
- const markHandlers = {
179
- onMarkAsRead: async (id) => {
180
- setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)));
181
- setUnreadCount((prev) => Math.max(0, prev - 1));
368
+ /**
369
+ * Undo an optimistic "mark read". Only rows that are still present AND
370
+ * currently read are reverted (and counted): rows deleted meanwhile or
371
+ * already reverted must not inflate the counter.
372
+ */
373
+ const rollbackRead = (ids) => {
374
+ const has = (n) => ids.some((id) => sameId(id, n.id));
375
+ const reverted = notificationsRef.current.filter((n) => n.is_read && has(n)).length;
376
+ if (reverted > 0) {
377
+ commitList((prev) => prev.map((n) => (n.is_read && has(n) ? { ...n, is_read: false } : n)));
378
+ commitCount((prev) => prev + reverted);
379
+ }
380
+ // A row being deleted right now keeps its up-to-date flag for its own rollback.
381
+ for (const [key, row] of pendingDeletesRef.current) {
382
+ if (row.is_read && has(row))
383
+ pendingDeletesRef.current.set(key, { ...row, is_read: false });
384
+ }
385
+ };
386
+ const markRead = async (ids) => {
387
+ const targets = notificationsRef.current
388
+ .filter((n) => !n.is_read && ids.some((id) => sameId(id, n.id)))
389
+ .map((n) => n.id);
390
+ if (targets.length === 0)
391
+ return;
392
+ commitList((prev) => prev.map((n) => targets.some((id) => sameId(id, n.id)) ? { ...n, is_read: true } : n));
393
+ commitCount((prev) => Math.max(0, prev - targets.length));
394
+ const cur = latestRef.current;
395
+ await trackAction(async () => {
396
+ if (cur.onMarkRead) {
397
+ try {
398
+ await cur.onMarkRead(targets);
399
+ }
400
+ catch (err) {
401
+ rollbackRead(targets);
402
+ // eslint-disable-next-line no-console
403
+ console.error('Failed to mark notification as read:', err);
404
+ }
405
+ return;
406
+ }
407
+ try {
408
+ await Promise.all(targets.map((id) => cur.apiClient.patch(`${cur.apiBasePath}/${id}`, { is_read: true })));
409
+ }
410
+ catch (err) {
411
+ // eslint-disable-next-line no-console
412
+ console.error('Failed to mark notification as read:', err);
413
+ }
414
+ });
415
+ };
416
+ const markAllRead = async () => {
417
+ const unreadIds = notificationsRef.current.filter((n) => !n.is_read).map((n) => n.id);
418
+ const cur = latestRef.current;
419
+ // Only `onMarkRead` given and nothing unread locally: no empty call.
420
+ if (!cur.onMarkAllRead && cur.onMarkRead && unreadIds.length === 0)
421
+ return;
422
+ commitList((prev) => prev.map((n) => (n.is_read ? n : { ...n, is_read: true })));
423
+ commitCount(() => 0);
424
+ const custom = cur.onMarkAllRead ??
425
+ (cur.onMarkRead ? () => cur.onMarkRead(unreadIds) : undefined);
426
+ await trackAction(async () => {
427
+ if (custom) {
428
+ try {
429
+ await custom();
430
+ }
431
+ catch (err) {
432
+ // Incremental rollback: count only the rows still present that
433
+ // were unread; live notifications that arrived meanwhile stay.
434
+ const has = (n) => unreadIds.some((id) => sameId(id, n.id));
435
+ const reverted = notificationsRef.current.filter((n) => n.is_read && has(n)).length;
436
+ commitList((prev) => prev.map((n) => (n.is_read && has(n) ? { ...n, is_read: false } : n)));
437
+ commitCount((prev) => prev + reverted);
438
+ // eslint-disable-next-line no-console
439
+ console.error('Failed to mark all as read:', err);
440
+ }
441
+ return;
442
+ }
182
443
  try {
183
- await apiClientRef.current.patch(`${basePathRef.current}/${id}`, {
184
- is_read: true,
185
- });
444
+ await Promise.all(unreadIds.map((id) => cur.apiClient.patch(`${cur.apiBasePath}/${id}`, { is_read: true })));
186
445
  }
187
- catch (error) {
446
+ catch (err) {
188
447
  // eslint-disable-next-line no-console
189
- console.error('Failed to mark notification as read:', error);
448
+ console.error('Failed to mark all as read:', err);
190
449
  }
191
- },
192
- onMarkAllAsRead: async () => {
193
- const unreadIds = notifications.filter((n) => !n.is_read).map((n) => n.id);
194
- setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
195
- setUnreadCount(0);
450
+ });
451
+ };
452
+ const deleteItem = async (id) => {
453
+ const remove = latestRef.current.onDelete;
454
+ if (!remove)
455
+ return;
456
+ const list = notificationsRef.current;
457
+ const index = list.findIndex((n) => sameId(n.id, id));
458
+ if (index < 0)
459
+ return;
460
+ const removed = list[index];
461
+ const key = String(id);
462
+ pendingDeletesRef.current.set(key, removed);
463
+ commitList((prev) => prev.filter((n) => !sameId(n.id, id)));
464
+ if (!removed.is_read)
465
+ commitCount((prev) => Math.max(0, prev - 1));
466
+ await trackAction(async () => {
196
467
  try {
197
- await Promise.all(unreadIds.map((id) => apiClientRef.current.patch(`${basePathRef.current}/${id}`, {
198
- is_read: true,
199
- })));
468
+ await remove(id);
469
+ pendingDeletesRef.current.delete(key);
200
470
  }
201
- catch (error) {
471
+ catch (err) {
472
+ // Restore with the CURRENT read flag (a concurrent rollback may have
473
+ // flipped it while the row was out of the list).
474
+ const restored = pendingDeletesRef.current.get(key) ?? removed;
475
+ pendingDeletesRef.current.delete(key);
476
+ if (!notificationsRef.current.some((n) => sameId(n.id, id))) {
477
+ commitList((prev) => {
478
+ const next = prev.slice();
479
+ next.splice(Math.min(index, next.length), 0, restored);
480
+ return next;
481
+ });
482
+ if (!restored.is_read)
483
+ commitCount((prev) => prev + 1);
484
+ }
202
485
  // eslint-disable-next-line no-console
203
- console.error('Failed to mark all as read:', error);
486
+ console.error('Failed to delete notification:', err);
204
487
  }
205
- },
488
+ });
206
489
  };
207
490
  const shellProps = {
208
491
  labels,
209
492
  locale,
210
493
  notifications,
211
494
  unreadCount,
495
+ isLoading: showLoadStates && isLoading,
496
+ error: showLoadStates ? error : null,
497
+ onRetry: () => void fetchNotifications(),
212
498
  resolveImageUrl,
213
499
  onNotificationClick,
214
- ...markHandlers,
500
+ onMarkAsRead: (id) => markRead([id]),
501
+ onMarkAllAsRead: markAllRead,
502
+ onDelete: onDelete ? deleteItem : undefined,
503
+ badgeMax,
504
+ triggerAriaLabel,
505
+ footer,
506
+ closeOnClick,
507
+ renderAvatar,
508
+ richText,
509
+ reloadAfterPermission,
510
+ open,
511
+ onOpenChange: setOpen,
215
512
  };
216
513
  return skipBuiltInWs ? (_jsx(DropdownShell, { ...shellProps })) : (_jsx(DropdownWithWebSocket, { ingestWsPayload: ingestWsPayload, ...shellProps }));
217
514
  }
@@ -222,23 +519,61 @@ function DropdownWithWebSocket({ ingestWsPayload, ...rest }) {
222
519
  });
223
520
  return _jsx(DropdownShell, { ...rest });
224
521
  }
225
- function DropdownShell({ labels, locale, notifications, unreadCount, resolveImageUrl, onMarkAsRead, onMarkAllAsRead, onNotificationClick, }) {
522
+ const MESSAGE_CLASS = 'line-clamp-2 text-[11px] leading-relaxed text-muted-foreground [&_strong]:font-semibold [&_strong]:text-foreground/85 [&_b]:font-semibold [&_b]:text-foreground/85 [&_em]:italic [&_i]:italic [&_u]:underline [&_mark]:rounded-sm [&_mark]:bg-amber-500/20 [&_mark]:px-0.5 [&_mark]:text-foreground [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:font-mono [&_code]:text-[10px] [&_code]:text-foreground';
523
+ function relativeTime(createdAt, locale) {
524
+ const date = new Date(createdAt);
525
+ if (Number.isNaN(date.getTime()))
526
+ return '';
527
+ return formatDistanceToNow(date, { addSuffix: true, locale });
528
+ }
529
+ function DropdownShell({ labels, locale, notifications, unreadCount, isLoading, error, onRetry, resolveImageUrl, onMarkAsRead, onMarkAllAsRead, onDelete, onNotificationClick, badgeMax, triggerAriaLabel, footer, closeOnClick, renderAvatar, richText, reloadAfterPermission, open, onOpenChange, }) {
226
530
  const notificationApiAvailable = typeof window !== 'undefined' && 'Notification' in window;
227
531
  const permission = notificationApiAvailable
228
532
  ? Notification.permission
229
533
  : null;
230
- return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: 'ghost', size: 'icon', className: 'relative', children: [notificationApiAvailable && permission !== 'granted' ? (_jsxs("div", { className: 'relative', children: [_jsx(LucideIcons.BellOff, { className: 'h-[1.2rem] w-[1.2rem] text-muted-foreground' }), permission === 'default' && (_jsx("span", { className: 'absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-yellow-500 ring-2 ring-background', title: labels.permissionRequired }))] })) : (_jsx(LucideIcons.Bell, { className: 'h-[1.2rem] w-[1.2rem] text-foreground' })), unreadCount > 0 && (_jsx("span", { className: 'absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground ring-2 ring-background', children: unreadCount > 99 ? '99+' : unreadCount })), _jsx("span", { className: 'sr-only', children: labels.srLabel })] }) }), _jsxs(DropdownMenuContent, { className: 'w-[min(100vw-1.5rem,22rem)] overflow-hidden p-0 sm:w-96', align: 'end', forceMount: true, children: [_jsx(DropdownMenuLabel, { className: 'border-b px-4 py-3 font-normal', children: _jsxs("div", { className: 'flex items-center justify-between gap-3', children: [_jsx("p", { className: 'text-sm font-semibold text-foreground', children: labels.title }), unreadCount > 0 ? (_jsx("span", { className: 'rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary', children: labels.newBadge(unreadCount) })) : null] }) }), _jsx(DropdownMenuGroup, { className: 'max-h-[min(24rem,60vh)] overflow-y-auto py-1', children: notifications.length === 0 ? (_jsx("div", { className: 'px-4 py-10 text-center text-sm text-muted-foreground', children: labels.empty })) : (notifications.map((notification) => {
534
+ const [confirmDeleteId, setConfirmDeleteId] = useState(null);
535
+ const close = useCallback(() => onOpenChange(false), [onOpenChange]);
536
+ return (_jsxs(DropdownMenu, { open: open, onOpenChange: onOpenChange, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: 'ghost', size: 'icon', className: 'relative', "aria-label": triggerAriaLabel ? triggerAriaLabel(unreadCount) : undefined, children: [notificationApiAvailable && permission !== 'granted' ? (_jsxs("div", { className: 'relative', children: [_jsx(LucideIcons.BellOff, { className: 'h-[1.2rem] w-[1.2rem] text-muted-foreground' }), permission === 'default' && (_jsx("span", { className: 'absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-yellow-500 ring-2 ring-background', title: labels.permissionRequired }))] })) : (_jsx(LucideIcons.Bell, { className: 'h-[1.2rem] w-[1.2rem] text-foreground' })), unreadCount > 0 && (_jsx("span", { className: 'absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground ring-2 ring-background', children: unreadCount > badgeMax ? `${badgeMax}+` : unreadCount })), _jsx("span", { className: 'sr-only', children: labels.srLabel })] }) }), _jsxs(DropdownMenuContent, { className: 'w-[min(100vw-1.5rem,22rem)] overflow-hidden p-0 sm:w-96', align: 'end', forceMount: true, children: [_jsx("div", { className: 'sr-only', role: 'status', "aria-live": 'polite', children: confirmDeleteId ? labels.confirmDeleteKey : '' }), _jsx(DropdownMenuLabel, { className: 'border-b px-4 py-3 font-normal', children: _jsxs("div", { className: 'flex items-center justify-between gap-3', children: [_jsx("p", { className: 'text-sm font-semibold text-foreground', children: labels.title }), unreadCount > 0 ? (_jsx("span", { className: 'rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary', children: labels.newBadge(unreadCount) })) : null] }) }), _jsx(DropdownMenuGroup, { className: 'max-h-[min(24rem,60vh)] overflow-y-auto py-1', children: notifications.length === 0 && isLoading ? (_jsx("div", { className: 'px-4 py-10 text-center text-sm text-muted-foreground', role: 'status', "data-state": 'loading', children: labels.loading })) : notifications.length === 0 && error ? (_jsxs("div", { className: 'flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-muted-foreground', role: 'alert', "data-state": 'error', children: [_jsx("span", { children: labels.error }), _jsx(Button, { variant: 'outline', size: 'sm', className: 'h-8 text-xs', onClick: onRetry, children: labels.retry })] })) : notifications.length === 0 ? (_jsx("div", { className: 'px-4 py-10 text-center text-sm text-muted-foreground', children: labels.empty })) : (notifications.map((notification) => {
231
537
  const meta = parseNotificationMeta(notification.metadata);
232
538
  const Icon = resolveNotificationIcon(notification.icon, notification.type);
233
539
  const tone = resolveNotificationTone(notification.type, meta);
234
540
  const mod = moduleLabelFromMeta(meta);
235
541
  const unread = !notification.is_read;
542
+ const idKey = String(notification.id);
543
+ const confirming = confirmDeleteId === idKey;
236
544
  return (_jsx(DropdownMenuItem, { className: [
237
545
  'cursor-pointer rounded-none border-0 px-3 py-1.5 focus:bg-muted/50 data-[highlighted]:bg-muted/50 sm:px-3.5',
238
546
  unread ? 'bg-primary/[0.035]' : '',
239
547
  ]
240
548
  .filter(Boolean)
241
- .join(' '), onClick: () => {
549
+ .join(' '), "aria-keyshortcuts": onDelete ? 'Delete' : undefined, onMouseLeave: () => {
550
+ if (confirming)
551
+ setConfirmDeleteId(null);
552
+ }, onBlur: (event) => {
553
+ // Focus left the row (arrow keys, Tab, outside click).
554
+ if (confirming &&
555
+ !event.currentTarget.contains(event.relatedTarget)) {
556
+ setConfirmDeleteId(null);
557
+ }
558
+ }, onKeyDown: (event) => {
559
+ // Keyboard path for deletion: the trash button inside a
560
+ // menuitem is not reachable (Radix only focuses items).
561
+ if (!onDelete || event.key !== 'Delete')
562
+ return;
563
+ event.preventDefault();
564
+ event.stopPropagation();
565
+ if (!confirming) {
566
+ setConfirmDeleteId(idKey);
567
+ return;
568
+ }
569
+ setConfirmDeleteId(null);
570
+ void onDelete(notification.id);
571
+ }, onSelect: (event) => {
572
+ // Radix closes the menu on select (legacy behaviour).
573
+ // closeOnClick={false} opts into keeping it open.
574
+ if (closeOnClick === false)
575
+ event.preventDefault();
576
+ }, onClick: () => {
242
577
  if (unread)
243
578
  void onMarkAsRead(notification.id);
244
579
  if (onNotificationClick) {
@@ -246,25 +581,41 @@ function DropdownShell({ labels, locale, notifications, unreadCount, resolveImag
246
581
  }
247
582
  else if (notification.link &&
248
583
  notification.link.startsWith('http')) {
249
- window.open(notification.link, '_blank');
584
+ window.open(notification.link, '_blank', 'noopener,noreferrer');
250
585
  }
251
- }, children: _jsxs("div", { className: 'flex w-full items-center gap-2.5', children: [_jsx(NotificationAvatar, { notification: notification, Icon: Icon, toneClass: tone.iconClass, customColor: tone.customColor, resolveImageUrl: resolveImageUrl }), _jsxs("div", { className: 'min-w-0 flex-1 space-y-0.5', children: [_jsxs("div", { className: 'flex items-start justify-between gap-2', children: [_jsx("p", { className: [
586
+ if (closeOnClick === true)
587
+ close();
588
+ }, children: _jsxs("div", { className: 'flex w-full items-center gap-2.5', children: [renderAvatar ? (_jsx("div", { className: 'shrink-0', children: renderAvatar(notification) })) : (_jsx(NotificationAvatar, { notification: notification, Icon: Icon, toneClass: tone.iconClass, customColor: tone.customColor, resolveImageUrl: resolveImageUrl })), _jsxs("div", { className: 'min-w-0 flex-1 space-y-0.5', children: [_jsxs("div", { className: 'flex items-start justify-between gap-2', children: [_jsx("p", { className: [
252
589
  'truncate text-[13px] leading-snug',
253
590
  unread
254
591
  ? 'font-semibold text-foreground'
255
592
  : 'font-medium text-foreground/90',
256
- ].join(' '), children: notification.title }), _jsxs("div", { className: 'mt-0.5 flex shrink-0 items-center gap-1.5', children: [_jsx("span", { className: 'whitespace-nowrap text-[10px] tabular-nums text-muted-foreground', children: formatDistanceToNow(new Date(notification.created_at), {
257
- addSuffix: true,
258
- locale,
259
- }) }), unread ? (_jsx("span", { className: 'h-1.5 w-1.5 rounded-full bg-primary', "aria-hidden": true })) : null] })] }), notification.message ? (_jsx("p", { className: 'line-clamp-2 text-[11px] leading-relaxed text-muted-foreground [&_strong]:font-semibold [&_strong]:text-foreground/85 [&_b]:font-semibold [&_b]:text-foreground/85 [&_em]:italic [&_i]:italic [&_u]:underline [&_mark]:rounded-sm [&_mark]:bg-amber-500/20 [&_mark]:px-0.5 [&_mark]:text-foreground [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:font-mono [&_code]:text-[10px] [&_code]:text-foreground', dangerouslySetInnerHTML: {
593
+ ].join(' '), children: notification.title }), _jsxs("div", { className: 'mt-0.5 flex shrink-0 items-center gap-1.5', children: [_jsx("span", { className: 'whitespace-nowrap text-[10px] tabular-nums text-muted-foreground', children: relativeTime(notification.created_at, locale) }), unread ? (_jsx("span", { className: 'h-1.5 w-1.5 rounded-full bg-primary', "aria-hidden": true })) : null] })] }), notification.message ? (richText ? (_jsx("p", { className: MESSAGE_CLASS, dangerouslySetInnerHTML: {
260
594
  __html: formatNotificationBodyHtml(notification.message),
261
- } })) : null, mod ? (_jsx("span", { className: 'inline-flex max-w-full truncate rounded px-1 py-px text-[10px] font-medium text-muted-foreground ring-1 ring-border/70', children: mod })) : null] })] }) }, notification.id));
262
- })) }), notifications.length > 0 ? (_jsx("div", { className: 'border-t bg-popover p-1.5', children: _jsx(Button, { variant: 'ghost', size: 'sm', className: 'h-8 w-full text-xs text-muted-foreground hover:text-foreground', onClick: () => void onMarkAllAsRead(), children: labels.markAllAsRead }) })) : null, notificationApiAvailable && permission !== 'granted' ? (_jsx("div", { className: 'border-t bg-popover p-1.5', children: _jsxs(Button, { variant: 'outline', size: 'sm', className: 'h-8 w-full gap-2 border-primary/25 bg-primary/5 text-xs text-primary hover:bg-primary/10', onClick: async () => {
595
+ } })) : (_jsx("p", { className: MESSAGE_CLASS, children: notification.message }))) : null, mod ? (_jsx("span", { className: 'inline-flex max-w-full truncate rounded px-1 py-px text-[10px] font-medium text-muted-foreground ring-1 ring-border/70', children: mod })) : null] }), onDelete ? (_jsx("button", { type: 'button', "aria-hidden": true, tabIndex: -1, "data-confirming": confirming ? 'true' : undefined, "aria-label": confirming ? labels.confirmDelete : labels.delete, title: confirming ? labels.confirmDelete : labels.delete, className: [
596
+ 'inline-flex h-6 shrink-0 items-center justify-center rounded px-1.5 text-muted-foreground transition-colors hover:text-destructive focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
597
+ confirming ? 'bg-destructive/10 text-destructive' : 'opacity-60 hover:opacity-100',
598
+ ].join(' '), onBlur: () => {
599
+ if (confirming)
600
+ setConfirmDeleteId(null);
601
+ }, onClick: (event) => {
602
+ event.preventDefault();
603
+ event.stopPropagation();
604
+ if (!confirming) {
605
+ setConfirmDeleteId(idKey);
606
+ return;
607
+ }
608
+ setConfirmDeleteId(null);
609
+ void onDelete(notification.id);
610
+ }, children: _jsx(LucideIcons.Trash2, { className: 'h-3.5 w-3.5', "aria-hidden": true }) })) : null] }) }, idKey));
611
+ })) }), notifications.length > 0 ? (_jsx("div", { className: 'border-t bg-popover p-1.5', children: _jsx(Button, { variant: 'ghost', size: 'sm', className: 'h-8 w-full text-xs text-muted-foreground hover:text-foreground', onClick: () => void onMarkAllAsRead(), children: labels.markAllAsRead }) })) : null, footer ? (_jsx("div", { className: 'border-t bg-popover p-1.5', children: typeof footer === 'function' ? footer(close) : footer })) : null, notificationApiAvailable && permission !== 'granted' ? (_jsx("div", { className: 'border-t bg-popover p-1.5', children: _jsxs(Button, { variant: 'outline', size: 'sm', className: 'h-8 w-full gap-2 border-primary/25 bg-primary/5 text-xs text-primary hover:bg-primary/10', onClick: async () => {
263
612
  try {
264
613
  const next = await Notification.requestPermission();
265
614
  if (next === 'granted') {
266
615
  toast.success(labels.notificationsEnabled);
267
- setTimeout(() => window.location.reload(), 1500);
616
+ if (reloadAfterPermission) {
617
+ setTimeout(() => window.location.reload(), 1500);
618
+ }
268
619
  }
269
620
  else {
270
621
  toast.error(labels.permissionsBlocked, {
@@ -279,12 +630,16 @@ function DropdownShell({ labels, locale, notifications, unreadCount, resolveImag
279
630
  }
280
631
  function NotificationAvatar({ notification, Icon, toneClass, customColor, resolveImageUrl, }) {
281
632
  const [failed, setFailed] = useState(false);
282
- const raw = (notification.image || '').trim();
633
+ const raw = (notification.image || notification.user?.avatar || '').trim();
283
634
  const resolved = raw && !failed ? (resolveImageUrl ? resolveImageUrl(raw) : raw) : '';
284
635
  const showPhoto = Boolean(resolved);
285
636
  if (showPhoto) {
286
637
  return (_jsxs("div", { className: 'relative shrink-0', children: [_jsx("img", { src: resolved, alt: '', className: 'h-7 w-7 rounded-full object-cover', onError: () => setFailed(true) }), _jsx("div", { className: 'absolute -bottom-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-background text-primary ring-1 ring-border', children: _jsx(Icon, { className: 'size-2.5 text-current', strokeWidth: 2.5 }) })] }));
287
638
  }
639
+ const initials = notification.user?.name ? initialsOf(notification.user.name) : '';
640
+ if (initials) {
641
+ return (_jsx("div", { className: 'flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold text-foreground', "aria-hidden": true, children: initials }));
642
+ }
288
643
  return (_jsx("div", { className: `flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-white ${toneClass}`, style: customColor ? { backgroundColor: customColor, color: '#fff' } : undefined, children: _jsx(Icon, { className: 'h-3.5 w-3.5 text-white', strokeWidth: 2.5 }) }));
289
644
  }
290
645
  //# sourceMappingURL=dropdown.js.map