create-astram-app 0.12.10 → 0.13.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/package.json +1 -1
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/auth/AuthContext.jsx +8 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +380 -181
- package/template/frontend/src/pages/access/accessMatrix.js +49 -0
- package/template/frontend/src/pages/access/accessMatrix.test.mjs +118 -0
- package/template/frontend/src/shell/AnnouncementsMenu.jsx +190 -0
- package/template/frontend/src/shell/AppShell.jsx +6 -3
- package/template/frontend/src/shell/NotificationsMenu.jsx +263 -0
- package/template/frontend/src/shell/notificationsView.js +138 -0
- package/template/frontend/src/shell/notificationsView.test.mjs +182 -0
- package/template/frontend/src/shell/useNotificationCounts.js +211 -0
- package/template/server/package.json +1 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notificationsView — React-free helpers for the header bell and Announcements menus (G-027
|
|
3
|
+
* step 5). Kept pure so `node --test` covers them (notificationsView.test.mjs); the menus in
|
|
4
|
+
* NotificationsMenu.jsx / AnnouncementsMenu.jsx only render what these return.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The counts poll interval (spec §10: 60 s, plus window focus). */
|
|
8
|
+
export const POLL_MS = 60_000;
|
|
9
|
+
|
|
10
|
+
/** The longest notice link the server accepts (guard `NOTIFICATION_LINK_MAX`). */
|
|
11
|
+
const LINK_MAX = 512;
|
|
12
|
+
|
|
13
|
+
// Any C0 control character or DEL (the guard's link rule refuses them all, newline included).
|
|
14
|
+
// eslint-disable-next-line no-control-regex
|
|
15
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
16
|
+
|
|
17
|
+
/** Badge text for an unread count: 0 (or not a count) ⇒ '' (no badge), above 9 ⇒ '9+'. */
|
|
18
|
+
export function badgeText(n) {
|
|
19
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n < 1) return '';
|
|
20
|
+
return n > 9 ? '9+' : String(Math.floor(n));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Accessible name for a header menu button, e.g. "Notifications, 3 unread". */
|
|
24
|
+
export function badgeLabel(label, n) {
|
|
25
|
+
const text = badgeText(n);
|
|
26
|
+
return text ? `${label}, ${text} unread` : label;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* "just now" / "5 min ago" / "3 h ago" / "2 d ago", then the date. `now` is a ms timestamp;
|
|
31
|
+
* `opts` ({ locale, timeZone }) only shapes the date form (tests pin them). An unparsable time
|
|
32
|
+
* ⇒ '' (never "Invalid Date"); a time slightly in the future (clock skew) ⇒ "just now".
|
|
33
|
+
*/
|
|
34
|
+
export function relativeTime(iso, now = Date.now(), opts = {}) {
|
|
35
|
+
if (typeof iso !== 'string' || iso === '') return '';
|
|
36
|
+
const at = Date.parse(iso);
|
|
37
|
+
if (Number.isNaN(at)) return '';
|
|
38
|
+
const seconds = Math.floor((now - at) / 1000);
|
|
39
|
+
if (seconds < 60) return 'just now';
|
|
40
|
+
const minutes = Math.floor(seconds / 60);
|
|
41
|
+
if (minutes < 60) return `${minutes} min ago`;
|
|
42
|
+
const hours = Math.floor(minutes / 60);
|
|
43
|
+
if (hours < 24) return `${hours} h ago`;
|
|
44
|
+
const days = Math.floor(hours / 24);
|
|
45
|
+
if (days < 7) return `${days} d ago`;
|
|
46
|
+
return formatDay(at, opts);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A calendar date ("20 Oct 2026" in en-GB), or '' when unparsable. */
|
|
50
|
+
function formatDay(ms, { locale, timeZone } = {}) {
|
|
51
|
+
try {
|
|
52
|
+
return new Date(ms).toLocaleDateString(locale, {
|
|
53
|
+
day: 'numeric',
|
|
54
|
+
month: 'short',
|
|
55
|
+
year: 'numeric',
|
|
56
|
+
...(timeZone ? { timeZone } : {}),
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An admin task's heading: "New permission: <label>". Never falls back to the raw key. */
|
|
64
|
+
export function taskTitle(row) {
|
|
65
|
+
const label = typeof row?.label === 'string' ? row.label.trim() : '';
|
|
66
|
+
return label ? `New permission: ${label}` : 'New permission';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* When the task's permission first appeared: "Release 1.4.0 · 20 Oct 2026", either half alone,
|
|
71
|
+
* or '' when neither is known. `opts` as for {@link relativeTime}.
|
|
72
|
+
*/
|
|
73
|
+
export function taskSince(row, opts = {}) {
|
|
74
|
+
const parts = [];
|
|
75
|
+
if (typeof row?.firstSeenVersion === 'string' && row.firstSeenVersion !== '') {
|
|
76
|
+
parts.push(`Release ${row.firstSeenVersion}`);
|
|
77
|
+
}
|
|
78
|
+
if (typeof row?.firstSeenAt === 'string') {
|
|
79
|
+
const at = Date.parse(row.firstSeenAt);
|
|
80
|
+
if (!Number.isNaN(at)) {
|
|
81
|
+
const day = formatDay(at, opts);
|
|
82
|
+
if (day) parts.push(day);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return parts.join(' · ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The notice link when it is safe to follow inside the app, else null. Mirrors the guard rule
|
|
90
|
+
* (`notification.service.ts` `validLink`): one leading `/` (never `//`), no backslash, no control
|
|
91
|
+
* character, at most 512 characters. A scheme can never follow a single leading `/`.
|
|
92
|
+
*/
|
|
93
|
+
export function safeAppLink(link) {
|
|
94
|
+
if (typeof link !== 'string' || link === '' || link.length > LINK_MAX) return null;
|
|
95
|
+
if (!link.startsWith('/') || link.startsWith('//')) return null;
|
|
96
|
+
if (link.includes('\\') || CONTROL_CHARS.test(link)) return null;
|
|
97
|
+
return link;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Poll the counts only while the tab is visible (`document.visibilityState`). */
|
|
101
|
+
export function shouldPoll(visibilityState) {
|
|
102
|
+
return visibilityState === 'visible';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The key that owns a set of badge counts: tenant, principal and session id of the signed-in
|
|
107
|
+
* session, or null when signed out. The counts store stamps its counts with this key and the
|
|
108
|
+
* hook compares it during render, so one user's counts never show for the next session.
|
|
109
|
+
*/
|
|
110
|
+
export function sessionKeyOf(session) {
|
|
111
|
+
if (!session || typeof session !== 'object') return null;
|
|
112
|
+
return `${session.tenantId ?? ''}:${session.principalId ?? ''}:${session.sessionId ?? ''}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const NO_COUNTS = Object.freeze({ personal: 0, announcement: 0, tasks: 0 });
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The counts to show for `sessionKey`: the stored counts only when they were fetched for that
|
|
119
|
+
* same session, else all 0 (signed out, a different session, or nothing fetched yet).
|
|
120
|
+
* A missing or non-numeric field reads as 0.
|
|
121
|
+
*/
|
|
122
|
+
export function countsFor(stored, sessionKey) {
|
|
123
|
+
if (typeof sessionKey !== 'string' || sessionKey === '') return NO_COUNTS;
|
|
124
|
+
if (!stored || stored.sessionKey !== sessionKey) return NO_COUNTS;
|
|
125
|
+
const n = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
|
|
126
|
+
return {
|
|
127
|
+
personal: n(stored.personal),
|
|
128
|
+
announcement: n(stored.announcement),
|
|
129
|
+
tasks: n(stored.tasks),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The Announcements badge: unread announcement notices plus open admin tasks. */
|
|
134
|
+
export function announcementsBadgeCount(counts) {
|
|
135
|
+
const a = Number.isFinite(counts?.announcement) ? counts.announcement : 0;
|
|
136
|
+
const t = Number.isFinite(counts?.tasks) ? counts.tasks : 0;
|
|
137
|
+
return a + t;
|
|
138
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// notificationsView.test.mjs — header bell / Announcements helpers (G-027 step 5): badge text,
|
|
2
|
+
// relative time, task heading, the app-relative link rule (mirrors the guard) and the
|
|
3
|
+
// visibility-gated poll.
|
|
4
|
+
//
|
|
5
|
+
// node --test src/shell/notificationsView.test.mjs
|
|
6
|
+
import { test, describe } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import {
|
|
9
|
+
POLL_MS,
|
|
10
|
+
announcementsBadgeCount,
|
|
11
|
+
badgeLabel,
|
|
12
|
+
badgeText,
|
|
13
|
+
countsFor,
|
|
14
|
+
relativeTime,
|
|
15
|
+
sessionKeyOf,
|
|
16
|
+
safeAppLink,
|
|
17
|
+
shouldPoll,
|
|
18
|
+
taskSince,
|
|
19
|
+
taskTitle,
|
|
20
|
+
} from './notificationsView.js';
|
|
21
|
+
|
|
22
|
+
const fmt = { locale: 'en-GB', timeZone: 'UTC' };
|
|
23
|
+
const now = Date.parse('2026-10-26T12:00:00.000Z');
|
|
24
|
+
const ago = (ms) => new Date(now - ms).toISOString();
|
|
25
|
+
|
|
26
|
+
describe('badgeText', () => {
|
|
27
|
+
test('0, negatives and non-counts ⇒ no badge', () => {
|
|
28
|
+
for (const n of [0, -1, NaN, undefined, null, '3']) assert.equal(badgeText(n), '');
|
|
29
|
+
});
|
|
30
|
+
test('1..9 as is, above 9 ⇒ 9+', () => {
|
|
31
|
+
assert.equal(badgeText(1), '1');
|
|
32
|
+
assert.equal(badgeText(9), '9');
|
|
33
|
+
assert.equal(badgeText(10), '9+');
|
|
34
|
+
assert.equal(badgeText(250), '9+');
|
|
35
|
+
});
|
|
36
|
+
test('badgeLabel names the count for screen readers', () => {
|
|
37
|
+
assert.equal(badgeLabel('Notifications', 0), 'Notifications');
|
|
38
|
+
assert.equal(badgeLabel('Notifications', 3), 'Notifications, 3 unread');
|
|
39
|
+
assert.equal(badgeLabel('Announcements', 12), 'Announcements, 9+ unread');
|
|
40
|
+
});
|
|
41
|
+
test('announcementsBadgeCount adds unread announcements and open tasks', () => {
|
|
42
|
+
assert.equal(announcementsBadgeCount({ announcement: 2, tasks: 3, personal: 7 }), 5);
|
|
43
|
+
assert.equal(announcementsBadgeCount({ announcement: 2 }), 2);
|
|
44
|
+
assert.equal(announcementsBadgeCount(null), 0);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('relativeTime', () => {
|
|
49
|
+
test('under a minute (and slight future skew) ⇒ just now', () => {
|
|
50
|
+
assert.equal(relativeTime(ago(5_000), now, fmt), 'just now');
|
|
51
|
+
assert.equal(relativeTime(ago(-30_000), now, fmt), 'just now');
|
|
52
|
+
});
|
|
53
|
+
test('minutes, hours, days', () => {
|
|
54
|
+
assert.equal(relativeTime(ago(5 * 60_000), now, fmt), '5 min ago');
|
|
55
|
+
assert.equal(relativeTime(ago(3 * 3_600_000), now, fmt), '3 h ago');
|
|
56
|
+
assert.equal(relativeTime(ago(2 * 86_400_000), now, fmt), '2 d ago');
|
|
57
|
+
});
|
|
58
|
+
test('a week or more ⇒ the date', () => {
|
|
59
|
+
assert.equal(relativeTime('2026-10-01T09:00:00.000Z', now, fmt), '1 Oct 2026');
|
|
60
|
+
});
|
|
61
|
+
test('missing or unparsable ⇒ empty, never "Invalid Date"', () => {
|
|
62
|
+
assert.equal(relativeTime(undefined, now, fmt), '');
|
|
63
|
+
assert.equal(relativeTime('', now, fmt), '');
|
|
64
|
+
assert.equal(relativeTime('not a date', now, fmt), '');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('taskTitle / taskSince', () => {
|
|
69
|
+
test('heading uses the label, never the key', () => {
|
|
70
|
+
assert.equal(
|
|
71
|
+
taskTitle({ label: 'Invoices: approve', key: 'billing:invoice:approve' }),
|
|
72
|
+
'New permission: Invoices: approve',
|
|
73
|
+
);
|
|
74
|
+
assert.equal(taskTitle({ label: ' ', key: 'billing:invoice:approve' }), 'New permission');
|
|
75
|
+
assert.equal(taskTitle(null), 'New permission');
|
|
76
|
+
});
|
|
77
|
+
test('first-seen release and date', () => {
|
|
78
|
+
const row = { firstSeenVersion: '1.4.0', firstSeenAt: '2026-10-20T09:30:00.000Z' };
|
|
79
|
+
assert.equal(taskSince(row, fmt), 'Release 1.4.0 · 20 Oct 2026');
|
|
80
|
+
assert.equal(taskSince({ firstSeenVersion: '1.4.0', firstSeenAt: null }, fmt), 'Release 1.4.0');
|
|
81
|
+
assert.equal(
|
|
82
|
+
taskSince({ firstSeenVersion: null, firstSeenAt: row.firstSeenAt }, fmt),
|
|
83
|
+
'20 Oct 2026',
|
|
84
|
+
);
|
|
85
|
+
assert.equal(taskSince({ firstSeenVersion: null, firstSeenAt: 'x' }, fmt), '');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('safeAppLink (the guard link rule)', () => {
|
|
90
|
+
test('app-relative paths pass', () => {
|
|
91
|
+
assert.equal(safeAppLink('/imports/42?tab=review'), '/imports/42?tab=review');
|
|
92
|
+
assert.equal(safeAppLink('/access'), '/access');
|
|
93
|
+
});
|
|
94
|
+
test('absolute, protocol-relative, schemes and relative paths are refused', () => {
|
|
95
|
+
for (const link of [
|
|
96
|
+
'https://evil.example/phish',
|
|
97
|
+
'//evil.example',
|
|
98
|
+
'javascript:alert(1)',
|
|
99
|
+
'imports/1',
|
|
100
|
+
' /imports/1',
|
|
101
|
+
]) {
|
|
102
|
+
assert.equal(safeAppLink(link), null, link);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
test('backslashes and control characters are refused', () => {
|
|
106
|
+
for (const link of ['/\\evil.example', '/a\tb', '/a\nb', '/a\u0000b', '/a\u007fb']) {
|
|
107
|
+
assert.equal(safeAppLink(link), null, JSON.stringify(link));
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
test('empty, non-string and over-long ⇒ null', () => {
|
|
111
|
+
assert.equal(safeAppLink(''), null);
|
|
112
|
+
assert.equal(safeAppLink(null), null);
|
|
113
|
+
assert.equal(safeAppLink(42), null);
|
|
114
|
+
assert.equal(safeAppLink('/' + 'x'.repeat(512)), null);
|
|
115
|
+
assert.equal(safeAppLink('/' + 'x'.repeat(511)), '/' + 'x'.repeat(511));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('sessionKeyOf', () => {
|
|
120
|
+
test('tenant, principal and session id; null when signed out', () => {
|
|
121
|
+
assert.equal(sessionKeyOf({ tenantId: 't1', principalId: 'p1', sessionId: 's1' }), 't1:p1:s1');
|
|
122
|
+
assert.equal(sessionKeyOf({ principalId: 'p1' }), ':p1:');
|
|
123
|
+
assert.equal(sessionKeyOf(null), null);
|
|
124
|
+
assert.equal(sessionKeyOf(undefined), null);
|
|
125
|
+
});
|
|
126
|
+
test('a different user or a new session gives a different key', () => {
|
|
127
|
+
const a = sessionKeyOf({ tenantId: 't1', principalId: 'pA', sessionId: 's1' });
|
|
128
|
+
const b = sessionKeyOf({ tenantId: 't1', principalId: 'pB', sessionId: 's2' });
|
|
129
|
+
const a2 = sessionKeyOf({ tenantId: 't1', principalId: 'pA', sessionId: 's3' });
|
|
130
|
+
assert.notEqual(a, b);
|
|
131
|
+
assert.notEqual(a, a2);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('countsFor', () => {
|
|
136
|
+
const zero = { personal: 0, announcement: 0, tasks: 0 };
|
|
137
|
+
const storedA = { personal: 3, announcement: 2, tasks: 5, sessionKey: 't1:pA:s1' };
|
|
138
|
+
|
|
139
|
+
test('the stored counts for their own session', () => {
|
|
140
|
+
assert.deepEqual(countsFor(storedA, 't1:pA:s1'), { personal: 3, announcement: 2, tasks: 5 });
|
|
141
|
+
});
|
|
142
|
+
test("another session never sees the previous user's counts (sign-out, sign-in as B)", () => {
|
|
143
|
+
assert.deepEqual(countsFor(storedA, 't1:pB:s2'), zero);
|
|
144
|
+
assert.deepEqual(countsFor(storedA, 't1:pA:s9'), zero);
|
|
145
|
+
});
|
|
146
|
+
test('signed out or no key ⇒ 0', () => {
|
|
147
|
+
assert.deepEqual(countsFor(storedA, null), zero);
|
|
148
|
+
assert.deepEqual(countsFor(storedA, ''), zero);
|
|
149
|
+
assert.deepEqual(countsFor(storedA, undefined), zero);
|
|
150
|
+
});
|
|
151
|
+
test('nothing stored, or counts stamped with no session ⇒ 0', () => {
|
|
152
|
+
assert.deepEqual(countsFor(null, 't1:pA:s1'), zero);
|
|
153
|
+
assert.deepEqual(countsFor({ personal: 4, announcement: 1, tasks: 1 }, 't1:pA:s1'), zero);
|
|
154
|
+
assert.deepEqual(
|
|
155
|
+
countsFor({ personal: 4, announcement: 1, tasks: 1, sessionKey: null }, 't1:pA:s1'),
|
|
156
|
+
zero,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
test('missing, negative or non-numeric fields read as 0', () => {
|
|
160
|
+
assert.deepEqual(
|
|
161
|
+
countsFor({ sessionKey: 'k', personal: -1, announcement: Number.NaN, tasks: '7' }, 'k'),
|
|
162
|
+
zero,
|
|
163
|
+
);
|
|
164
|
+
assert.deepEqual(countsFor({ sessionKey: 'k', personal: 2 }, 'k'), {
|
|
165
|
+
personal: 2,
|
|
166
|
+
announcement: 0,
|
|
167
|
+
tasks: 0,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('shouldPoll', () => {
|
|
173
|
+
test('only while the tab is visible', () => {
|
|
174
|
+
assert.equal(shouldPoll('visible'), true);
|
|
175
|
+
assert.equal(shouldPoll('hidden'), false);
|
|
176
|
+
assert.equal(shouldPoll('prerender'), false);
|
|
177
|
+
assert.equal(shouldPoll(undefined), false);
|
|
178
|
+
});
|
|
179
|
+
test('60 s cadence', () => {
|
|
180
|
+
assert.equal(POLL_MS, 60_000);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useNotificationCounts — the ONE shared source of the header badge counts (G-027 step 5):
|
|
3
|
+
* `{ personal, announcement, tasks, refresh }`.
|
|
4
|
+
*
|
|
5
|
+
* Why a module-level store (read through `useSyncExternalStore`) rather than a React provider:
|
|
6
|
+
* every page renders its own <AppShell>, so anything mounted inside the shell remounts on each
|
|
7
|
+
* navigation, and the Access form (Phase 3) renders the shell rather than sitting under it. A
|
|
8
|
+
* provider would have to live in main.jsx/App.jsx; the store needs no new wrapper, survives
|
|
9
|
+
* those remounts (no badge flicker, no request per navigation) and lets any component under
|
|
10
|
+
* <AuthProvider> call the same `refresh()`.
|
|
11
|
+
*
|
|
12
|
+
* Triggers (spec §5.C, §11): sign-in (a new session), window focus, every 60 s only while
|
|
13
|
+
* `document.visibilityState === 'visible'`, at once when the tab becomes visible again, and
|
|
14
|
+
* `refresh()` after the user's own actions. Listeners and the interval are armed while at least
|
|
15
|
+
* one component uses the hook and a token is present; re-armed when the token or session
|
|
16
|
+
* changes (the auth-facts pattern in AuthContext.jsx). A failed call keeps the last value; the
|
|
17
|
+
* services never throw, and nothing here does either.
|
|
18
|
+
*
|
|
19
|
+
* Tasks = `listPermissionAnnouncements({ status: 'open', limit: 1 }).total`, only when
|
|
20
|
+
* `can('identity:role:update')` (cosmetic, fail closed: no facts ⇒ 0 and no call); a 403 ⇒ 0.
|
|
21
|
+
* The server still decides every call.
|
|
22
|
+
*/
|
|
23
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
24
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
25
|
+
import { POLL_MS, countsFor, sessionKeyOf, shouldPoll } from './notificationsView';
|
|
26
|
+
|
|
27
|
+
/** The permission that shows the admin tasks (the server's own check on the route). */
|
|
28
|
+
export const TASKS_PERMISSION = 'identity:role:update';
|
|
29
|
+
|
|
30
|
+
// The stored counts carry the session key they were fetched for; the hook shows them only to
|
|
31
|
+
// that same session (countsFor), so a previous user's counts never render for the next one,
|
|
32
|
+
// even on the first render before `configure` runs.
|
|
33
|
+
const ZERO = Object.freeze({ personal: 0, announcement: 0, tasks: 0, sessionKey: null });
|
|
34
|
+
|
|
35
|
+
// Focus and visibilitychange both fire when the user returns to the tab: one request, not two.
|
|
36
|
+
const TRIGGER_GAP_MS = 2_000;
|
|
37
|
+
|
|
38
|
+
const store = {
|
|
39
|
+
counts: ZERO,
|
|
40
|
+
listeners: new Set(),
|
|
41
|
+
/** { notifications, identity, token, sessionKey, canTasks } from the latest render. */
|
|
42
|
+
deps: null,
|
|
43
|
+
/** Bumped per refresh and on a session change; an older answer is dropped. */
|
|
44
|
+
seq: 0,
|
|
45
|
+
lastStartedAt: 0,
|
|
46
|
+
consumers: 0,
|
|
47
|
+
disarm: null,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function emit(next) {
|
|
51
|
+
store.counts = next;
|
|
52
|
+
for (const listener of store.listeners) listener();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function subscribe(listener) {
|
|
56
|
+
store.listeners.add(listener);
|
|
57
|
+
return () => store.listeners.delete(listener);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function snapshot() {
|
|
61
|
+
return store.counts;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Fetch both counts for the current session. Resolves once applied; never rejects. */
|
|
65
|
+
async function runRefresh() {
|
|
66
|
+
const deps = store.deps;
|
|
67
|
+
if (!deps?.token) return;
|
|
68
|
+
const seq = ++store.seq;
|
|
69
|
+
store.lastStartedAt = Date.now();
|
|
70
|
+
const { notifications, identity, token, canTasks, sessionKey } = deps;
|
|
71
|
+
let unread;
|
|
72
|
+
let tasks;
|
|
73
|
+
try {
|
|
74
|
+
[unread, tasks] = await Promise.all([
|
|
75
|
+
notifications.unreadNotificationCount(token),
|
|
76
|
+
canTasks
|
|
77
|
+
? identity.listPermissionAnnouncements(token, { status: 'open', limit: 1 })
|
|
78
|
+
: Promise.resolve(null),
|
|
79
|
+
]);
|
|
80
|
+
} catch {
|
|
81
|
+
return; // documented never to throw; belt and braces — keep the last value
|
|
82
|
+
}
|
|
83
|
+
// Superseded by a later refresh, or the session changed meanwhile: drop this answer.
|
|
84
|
+
if (seq !== store.seq || store.deps?.sessionKey !== sessionKey) return;
|
|
85
|
+
// The last value for THIS session (0s when the stored counts belong to another one).
|
|
86
|
+
const prev = countsFor(store.counts, sessionKey);
|
|
87
|
+
let taskCount = prev.tasks;
|
|
88
|
+
if (!canTasks || tasks?.status === 403) taskCount = 0;
|
|
89
|
+
else if (tasks?.ok === true) taskCount = tasks.total;
|
|
90
|
+
const next = {
|
|
91
|
+
personal: unread?.ok === true ? unread.personal : prev.personal,
|
|
92
|
+
announcement: unread?.ok === true ? unread.announcement : prev.announcement,
|
|
93
|
+
tasks: taskCount,
|
|
94
|
+
sessionKey,
|
|
95
|
+
};
|
|
96
|
+
if (
|
|
97
|
+
store.counts.sessionKey !== sessionKey ||
|
|
98
|
+
next.personal !== prev.personal ||
|
|
99
|
+
next.announcement !== prev.announcement ||
|
|
100
|
+
next.tasks !== prev.tasks
|
|
101
|
+
) {
|
|
102
|
+
emit(next);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Refresh now (after the user's own action). Safe to call anywhere, any time. */
|
|
107
|
+
export function refreshNotificationCounts() {
|
|
108
|
+
return runRefresh();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Take the latest services/token/session/permission from a render. */
|
|
112
|
+
function configure(next) {
|
|
113
|
+
const prev = store.deps;
|
|
114
|
+
store.deps = next;
|
|
115
|
+
const sessionChanged = prev?.sessionKey !== next.sessionKey;
|
|
116
|
+
if (sessionChanged || !next.token) {
|
|
117
|
+
store.seq += 1; // drop any answer for the previous session
|
|
118
|
+
store.lastStartedAt = 0;
|
|
119
|
+
if (store.counts !== ZERO) emit(ZERO);
|
|
120
|
+
}
|
|
121
|
+
if (!next.token) return;
|
|
122
|
+
// Sign-in (new session), or the tasks permission arrived/left with the auth facts.
|
|
123
|
+
if (sessionChanged || prev?.canTasks !== next.canTasks) void runRefresh();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Attach focus / visibility listeners and the interval for one consumer; returns its release. */
|
|
127
|
+
function arm() {
|
|
128
|
+
store.consumers += 1;
|
|
129
|
+
if (store.consumers === 1) {
|
|
130
|
+
let intervalId = null;
|
|
131
|
+
const visible = () => shouldPoll(document.visibilityState);
|
|
132
|
+
const startInterval = () => {
|
|
133
|
+
if (intervalId === null) {
|
|
134
|
+
intervalId = setInterval(() => {
|
|
135
|
+
if (visible()) void runRefresh();
|
|
136
|
+
}, POLL_MS);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const stopInterval = () => {
|
|
140
|
+
if (intervalId !== null) clearInterval(intervalId);
|
|
141
|
+
intervalId = null;
|
|
142
|
+
};
|
|
143
|
+
const poke = () => {
|
|
144
|
+
if (visible() && Date.now() - store.lastStartedAt >= TRIGGER_GAP_MS) void runRefresh();
|
|
145
|
+
};
|
|
146
|
+
const onVisibility = () => {
|
|
147
|
+
if (visible()) {
|
|
148
|
+
poke(); // one immediate refresh, then the 60 s cycle from now
|
|
149
|
+
stopInterval();
|
|
150
|
+
startInterval();
|
|
151
|
+
} else {
|
|
152
|
+
stopInterval(); // hidden: no count requests at all
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
window.addEventListener('focus', poke);
|
|
156
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
157
|
+
if (visible()) startInterval();
|
|
158
|
+
store.disarm = () => {
|
|
159
|
+
window.removeEventListener('focus', poke);
|
|
160
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
161
|
+
stopInterval();
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// Re-armed after a navigation (the shell remounts): refresh only if the last one is a poll old.
|
|
165
|
+
if (shouldPoll(document.visibilityState) && Date.now() - store.lastStartedAt >= POLL_MS) {
|
|
166
|
+
void runRefresh();
|
|
167
|
+
}
|
|
168
|
+
let released = false;
|
|
169
|
+
return () => {
|
|
170
|
+
if (released) return;
|
|
171
|
+
released = true;
|
|
172
|
+
store.consumers -= 1;
|
|
173
|
+
if (store.consumers === 0 && store.disarm) {
|
|
174
|
+
store.disarm();
|
|
175
|
+
store.disarm = null;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `{ personal, announcement, tasks, refresh }` for the signed-in user; all 0 when signed out.
|
|
182
|
+
* Call `refresh()` right after the user's own mark read, mark all read, dismiss, reopen or saved
|
|
183
|
+
* grant so the badges move at once rather than on the next poll.
|
|
184
|
+
*/
|
|
185
|
+
export function useNotificationCounts() {
|
|
186
|
+
const { tokens, session, notifications, identity, can } = useAuth();
|
|
187
|
+
const token = tokens?.accessToken ?? null;
|
|
188
|
+
const sessionKey = sessionKeyOf(session);
|
|
189
|
+
const canTasks = can(TASKS_PERMISSION);
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
configure({ notifications, identity, token, sessionKey, canTasks });
|
|
193
|
+
}, [notifications, identity, token, sessionKey, canTasks]);
|
|
194
|
+
|
|
195
|
+
// Re-armed whenever the token/session changes so a stale closure never polls for a
|
|
196
|
+
// signed-out or replaced session.
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!token) return undefined;
|
|
199
|
+
return arm();
|
|
200
|
+
}, [token, sessionKey]);
|
|
201
|
+
|
|
202
|
+
const counts = useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
203
|
+
// Decided during render: counts stamped with another session (or none) show as 0.
|
|
204
|
+
const current = countsFor(counts, token ? sessionKey : null);
|
|
205
|
+
return {
|
|
206
|
+
personal: current.personal,
|
|
207
|
+
announcement: current.announcement,
|
|
208
|
+
tasks: current.tasks,
|
|
209
|
+
refresh: refreshNotificationCounts,
|
|
210
|
+
};
|
|
211
|
+
}
|