@asteby/metacore-notifications 37.0.0 → 38.1.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/README.md +95 -0
- package/dist/dropdown.d.ts +5 -1
- package/dist/dropdown.d.ts.map +1 -1
- package/dist/dropdown.js +438 -83
- package/dist/dropdown.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/rich-text.d.ts +10 -1
- package/dist/rich-text.d.ts.map +1 -1
- package/dist/rich-text.js +15 -12
- package/dist/rich-text.js.map +1 -1
- package/dist/toast.d.ts +5 -0
- package/dist/toast.d.ts.map +1 -1
- package/dist/toast.js +3 -2
- package/dist/toast.js.map +1 -1
- package/dist/types.d.ts +111 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -3
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(
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 (
|
|
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:',
|
|
170
|
+
console.error('Failed to fetch notifications:', err);
|
|
65
171
|
}
|
|
66
172
|
finally {
|
|
67
|
-
|
|
173
|
+
if (seq === requestSeqRef.current)
|
|
174
|
+
setLoading(false);
|
|
68
175
|
}
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
248
|
+
baseMeta.addon_key = payload.addon_key;
|
|
100
249
|
if (payload.apartado)
|
|
101
|
-
|
|
250
|
+
baseMeta.apartado = payload.apartado;
|
|
102
251
|
if (payload.color)
|
|
103
|
-
|
|
252
|
+
baseMeta.color = payload.color;
|
|
104
253
|
const metadataStr = typeof payload.metadata === 'string'
|
|
105
254
|
? payload.metadata
|
|
106
|
-
: JSON.stringify(
|
|
107
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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 (
|
|
446
|
+
catch (err) {
|
|
188
447
|
// eslint-disable-next-line no-console
|
|
189
|
-
console.error('Failed to mark
|
|
448
|
+
console.error('Failed to mark all as read:', err);
|
|
190
449
|
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
198
|
-
|
|
199
|
-
})));
|
|
468
|
+
await remove(id);
|
|
469
|
+
pendingDeletesRef.current.delete(key);
|
|
200
470
|
}
|
|
201
|
-
catch (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(' '),
|
|
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
|
-
|
|
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:
|
|
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] })
|
|
262
|
-
|
|
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
|
-
|
|
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
|