@cplieger/web-terminal-ui 8.6.0-dev.3 → 8.6.0-dev.5
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 +3 -3
- package/src/features/tabs/index.ts +91 -34
- package/src/features/tabs/model.ts +32 -37
- package/src/kernel/attention.ts +53 -13
- package/src/kernel/notify.ts +35 -7
- package/src/kernel/shell.ts +1 -1
- package/src/kernel/types.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "8.6.0-dev.
|
|
3
|
+
"version": "8.6.0-dev.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"verify": "bash scripts/verify.sh"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"@cplieger/web-terminal-engine": "^6.0.0 || ^6.2.0-dev"
|
|
47
|
+
"@cplieger/web-terminal-engine": "^6.0.0 || ^6.2.0-dev || ^6.4.0-dev"
|
|
48
48
|
},
|
|
49
49
|
"overrides": {
|
|
50
50
|
"qs": "^6.15.2"
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"html-validate": "11.16.2",
|
|
65
65
|
"knip": "6.41.0",
|
|
66
66
|
"playwright": "1.64.0",
|
|
67
|
-
"prettier": "3.9.
|
|
67
|
+
"prettier": "3.9.10",
|
|
68
68
|
"stylelint": "17.16.0",
|
|
69
69
|
"stylelint-config-standard": "40.0.0",
|
|
70
70
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
} from "./route.js";
|
|
29
29
|
import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
|
|
30
30
|
import {
|
|
31
|
-
CUE_SEEN_KEY,
|
|
32
31
|
MAX_PERSISTED_CUE_SEEN,
|
|
33
32
|
MAX_PINNED_NAME,
|
|
34
33
|
PROGRESS_ABSENT,
|
|
@@ -39,6 +38,8 @@ import {
|
|
|
39
38
|
createSessionAPI,
|
|
40
39
|
createTombstones,
|
|
41
40
|
cueIconName,
|
|
41
|
+
cueSeenID,
|
|
42
|
+
cueSeenKey,
|
|
42
43
|
foldedCueStatus,
|
|
43
44
|
hasPinnedName,
|
|
44
45
|
isCueStatus,
|
|
@@ -51,7 +52,6 @@ import {
|
|
|
51
52
|
parseCueSeen,
|
|
52
53
|
renderedProgress,
|
|
53
54
|
sanitizePinnedName,
|
|
54
|
-
serializeCueSeen,
|
|
55
55
|
statusPhrase,
|
|
56
56
|
summarizeCues,
|
|
57
57
|
tabAccessibleName,
|
|
@@ -196,26 +196,47 @@ const CATCHUP_SETTLE_MS = 250;
|
|
|
196
196
|
* multiple frames; ordinary streaming must never arm the cue. */
|
|
197
197
|
const CATCHUP_MIN_BACKLOG = 400;
|
|
198
198
|
|
|
199
|
+
function* storedCueSeen(win: Pick<Window, "localStorage">): Generator<readonly [string, string]> {
|
|
200
|
+
try {
|
|
201
|
+
const store = win.localStorage;
|
|
202
|
+
for (let i = 0; i < store.length; i++) {
|
|
203
|
+
const key = store.key(i);
|
|
204
|
+
const id = key === null ? null : cueSeenID(key);
|
|
205
|
+
if (key !== null && id !== null) {
|
|
206
|
+
yield [id, store.getItem(key) ?? ""];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
/* storage unavailable */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
199
214
|
/** The acknowledged background-tab cues, or an empty map when storage throws or
|
|
200
215
|
* holds nothing trustworthy: the dot simply lights again. */
|
|
201
216
|
function readCueSeen(win: Pick<Window, "localStorage">): Map<string, CueStatus> {
|
|
217
|
+
return parseCueSeen(storedCueSeen(win));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Best-effort: a full quota must not break the dismissal the user just
|
|
221
|
+
* performed on screen. */
|
|
222
|
+
function writeCueSeen(win: Pick<Window, "localStorage">, id: string, status: CueStatus): void {
|
|
202
223
|
try {
|
|
203
|
-
|
|
224
|
+
win.localStorage.setItem(cueSeenKey(id), status);
|
|
204
225
|
} catch {
|
|
205
|
-
|
|
226
|
+
/* storage unavailable or full: the dismissal still holds for this page */
|
|
206
227
|
}
|
|
207
228
|
}
|
|
208
229
|
|
|
209
|
-
/**
|
|
210
|
-
*
|
|
211
|
-
function
|
|
212
|
-
win: Pick<Window, "localStorage">,
|
|
213
|
-
seen: ReadonlyMap<string, CueStatus>,
|
|
214
|
-
): void {
|
|
230
|
+
/** Remove `id`'s stored acknowledgement only while it still holds `status`:
|
|
231
|
+
* another window may since have acknowledged the session's next cue. */
|
|
232
|
+
function dropCueSeen(win: Pick<Window, "localStorage">, id: string, status: CueStatus): void {
|
|
215
233
|
try {
|
|
216
|
-
|
|
234
|
+
const key = cueSeenKey(id);
|
|
235
|
+
if (win.localStorage.getItem(key) === status) {
|
|
236
|
+
win.localStorage.removeItem(key);
|
|
237
|
+
}
|
|
217
238
|
} catch {
|
|
218
|
-
/* storage unavailable
|
|
239
|
+
/* storage unavailable: nothing stored to remove */
|
|
219
240
|
}
|
|
220
241
|
}
|
|
221
242
|
|
|
@@ -682,36 +703,40 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
682
703
|
// The session that raised the pending cue, so arriving on that tab (a
|
|
683
704
|
// swipe or any switch) acknowledges it without opening the list.
|
|
684
705
|
let switchNotifyId: string | null = null;
|
|
685
|
-
// Cues this viewer has already SEEN, per session (see
|
|
706
|
+
// Cues this viewer has already SEEN, per session (see CUE_SEEN_PREFIX).
|
|
686
707
|
// Loaded here so a reload starts from what the user already dismissed:
|
|
687
708
|
// input/done stay LATCHED server-side, and the status stream re-pushes the
|
|
688
709
|
// latch in the snapshot it sends on every open, so without this a
|
|
689
710
|
// dismissed dot came back on the next load — and on every SSE reconnect,
|
|
690
711
|
// which on a phone is just returning to a backgrounded page.
|
|
691
712
|
const cueSeen = readCueSeen(win);
|
|
713
|
+
// The out-of-page surfaces render the SAME unseen-cue set the switch dot
|
|
714
|
+
// shows, fed only through paintAttention below.
|
|
715
|
+
const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
|
|
692
716
|
// markCueSeen records that this viewer has seen `id` holding `status`:
|
|
693
717
|
// either because that tab is the one on screen, or because the tray listing
|
|
694
|
-
// every tab's dot was opened.
|
|
695
|
-
//
|
|
718
|
+
// every tab's dot was opened. Every read re-asserts the badge, which may
|
|
719
|
+
// still show a write the platform dropped though the count is unchanged,
|
|
720
|
+
// and closes the session's banner; a non-cue status stores nothing.
|
|
696
721
|
function markCueSeen(id: string, status: string): void {
|
|
722
|
+
attention.resync();
|
|
723
|
+
ctx.shell.notifications.retract(id);
|
|
697
724
|
if (!isCueStatus(status) || cueSeen.get(id) === status) {
|
|
698
725
|
return;
|
|
699
726
|
}
|
|
700
727
|
cueSeen.set(id, status);
|
|
701
|
-
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
// make the parser discard whatever it read last — dropping fresh
|
|
706
|
-
// acknowledgements to keep dead ones.
|
|
728
|
+
writeCueSeen(win, id, status);
|
|
729
|
+
// Evict the oldest so storage obeys the parser's cap: dropTab prunes a
|
|
730
|
+
// session closed while this page was open, but one that vanished while the
|
|
731
|
+
// page was CLOSED leaves an entry nothing else collects.
|
|
707
732
|
while (cueSeen.size > MAX_PERSISTED_CUE_SEEN) {
|
|
708
|
-
const oldest = cueSeen.
|
|
709
|
-
if (oldest === undefined) {
|
|
733
|
+
const [oldest, held] = cueSeen.entries().next().value ?? [];
|
|
734
|
+
if (oldest === undefined || held === undefined) {
|
|
710
735
|
break;
|
|
711
736
|
}
|
|
712
737
|
cueSeen.delete(oldest);
|
|
738
|
+
dropCueSeen(win, oldest, held);
|
|
713
739
|
}
|
|
714
|
-
writeCueSeen(win, cueSeen);
|
|
715
740
|
// An acknowledgement can change the unseen count without touching the tab
|
|
716
741
|
// list, so syncChrome's sweep is not enough on its own: opening the tray
|
|
717
742
|
// and arriving on a tab both land here and neither adds or removes a chip.
|
|
@@ -722,13 +747,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
722
747
|
// (a new working phase, an exit) and when the session goes away. Every
|
|
723
748
|
// caller ends in syncChrome, which repaints the out-of-page surfaces.
|
|
724
749
|
function forgetCueSeen(id: string): void {
|
|
725
|
-
|
|
726
|
-
|
|
750
|
+
const known = cueSeen.get(id);
|
|
751
|
+
if (known === undefined) {
|
|
752
|
+
return;
|
|
727
753
|
}
|
|
754
|
+
cueSeen.delete(id);
|
|
755
|
+
dropCueSeen(win, id, known);
|
|
728
756
|
}
|
|
729
|
-
// The out-of-page surfaces render the SAME unseen-cue set the switch dot
|
|
730
|
-
// shows, fed only through paintAttention below.
|
|
731
|
-
const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
|
|
732
757
|
// visibilityState, not document.hasFocus(), which is false for a
|
|
733
758
|
// visible-but-unfocused window where the terminal IS on screen.
|
|
734
759
|
function pageVisible(): boolean {
|
|
@@ -753,6 +778,36 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
753
778
|
ctx.defer(() => {
|
|
754
779
|
doc.removeEventListener("visibilitychange", onPageVisible);
|
|
755
780
|
});
|
|
781
|
+
// Every page writes the one app badge, so each mirrors the shared map;
|
|
782
|
+
// otherwise a hidden page keeps counting a cue the reader saw elsewhere.
|
|
783
|
+
// A null key is another page's localStorage.clear().
|
|
784
|
+
function onCueSeenStored(e: StorageEvent): void {
|
|
785
|
+
if (e.key !== null && cueSeenID(e.key) === null) {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const stored = readCueSeen(win);
|
|
789
|
+
for (const id of [...cueSeen.keys()]) {
|
|
790
|
+
if (!stored.has(id)) {
|
|
791
|
+
cueSeen.delete(id);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
for (const [id, status] of stored) {
|
|
795
|
+
if (cueSeen.get(id) !== status) {
|
|
796
|
+
cueSeen.set(id, status);
|
|
797
|
+
// Read elsewhere, but only this page can close the banners it posted.
|
|
798
|
+
ctx.shell.notifications.retract(id);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (switchNotifyId !== null && cueSeen.get(switchNotifyId) === switchNotify) {
|
|
802
|
+
clearSwitchNotify();
|
|
803
|
+
}
|
|
804
|
+
attention.resync();
|
|
805
|
+
paintAttention();
|
|
806
|
+
}
|
|
807
|
+
win.addEventListener("storage", onCueSeenStored);
|
|
808
|
+
ctx.defer(() => {
|
|
809
|
+
win.removeEventListener("storage", onCueSeenStored);
|
|
810
|
+
});
|
|
756
811
|
|
|
757
812
|
// A FOLD over the tab list rather than incremental bookkeeping: no state of
|
|
758
813
|
// its own can go stale, and the shell no-ops when nothing changed.
|
|
@@ -3738,11 +3793,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3738
3793
|
let streamOpens = 0;
|
|
3739
3794
|
const offStreamOpen = monitor.onStreamOpen?.(() => {
|
|
3740
3795
|
streamOpens++;
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3796
|
+
const settled =
|
|
3797
|
+
streamOpens === 1
|
|
3798
|
+
? reconcileOnce()
|
|
3799
|
+
: withPlacementHold(() => inReconcileSlot(reconcileNow));
|
|
3800
|
+
void settled.then(() => {
|
|
3801
|
+
attention.resync();
|
|
3802
|
+
});
|
|
3746
3803
|
});
|
|
3747
3804
|
if (offStreamOpen) {
|
|
3748
3805
|
ctx.defer(offStreamOpen);
|
|
@@ -282,54 +282,49 @@ export function summarizeCues(
|
|
|
282
282
|
return { count, worst };
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
/** localStorage key for the cues this viewer has SEEN:
|
|
286
|
-
* acknowledged status. Remembered because
|
|
287
|
-
*
|
|
288
|
-
* every reload; client-side because
|
|
289
|
-
*
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
285
|
+
/** localStorage key prefix for the cues this viewer has SEEN: one key per
|
|
286
|
+
* session, `cueSeenKey(id)`, holding the acknowledged status. Remembered because
|
|
287
|
+
* `input` and `done` are latched on the server and re-delivered in every
|
|
288
|
+
* snapshot, so a dismissed dot came back on every reload; client-side because
|
|
289
|
+
* "seen" is the VIEWER's, not the session's. One key per session because every
|
|
290
|
+
* window of the app writes here: two windows acknowledging different sessions
|
|
291
|
+
* write different keys, where one shared map lets the later write drop the
|
|
292
|
+
* other's acknowledgement. */
|
|
293
|
+
const CUE_SEEN_PREFIX = "wt-cue-seen/";
|
|
294
|
+
|
|
295
|
+
/** The storage key holding `id`'s acknowledgement. */
|
|
296
|
+
export function cueSeenKey(id: string): string {
|
|
297
|
+
return CUE_SEEN_PREFIX + id;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The session id a storage key acknowledges, or null for any other key. */
|
|
301
|
+
export function cueSeenID(key: string): string | null {
|
|
302
|
+
return key.startsWith(CUE_SEEN_PREFIX) && key.length > CUE_SEEN_PREFIX.length
|
|
303
|
+
? key.slice(CUE_SEEN_PREFIX.length)
|
|
304
|
+
: null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Bound on the acknowledgements read back, so a corrupted or hostile store
|
|
293
308
|
* cannot make the restore path do unbounded work. */
|
|
294
309
|
export const MAX_PERSISTED_CUE_SEEN = 200;
|
|
295
310
|
|
|
296
|
-
/** Read stored
|
|
297
|
-
* lost acknowledgement only re-lights a dot the user can
|
|
298
|
-
|
|
311
|
+
/** Read stored [session id, raw status] entries into a clean map, dropping
|
|
312
|
+
* anything untrusted (a lost acknowledgement only re-lights a dot the user can
|
|
313
|
+
* dismiss again), and stop reading at the cap. */
|
|
314
|
+
export function parseCueSeen(entries: Iterable<readonly [string, string]>): Map<string, CueStatus> {
|
|
299
315
|
const out = new Map<string, CueStatus>();
|
|
300
|
-
|
|
301
|
-
return out;
|
|
302
|
-
}
|
|
303
|
-
let data: unknown;
|
|
304
|
-
try {
|
|
305
|
-
data = JSON.parse(raw);
|
|
306
|
-
} catch {
|
|
307
|
-
return out;
|
|
308
|
-
}
|
|
309
|
-
// Arrays and null are typeof "object" too, and neither is a cue map.
|
|
310
|
-
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
311
|
-
return out;
|
|
312
|
-
}
|
|
313
|
-
for (const [id, status] of Object.entries(data)) {
|
|
314
|
-
if (id === "" || typeof status !== "string" || !isCueStatus(status)) {
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
out.set(id, status);
|
|
316
|
+
for (const [id, status] of entries) {
|
|
318
317
|
if (out.size >= MAX_PERSISTED_CUE_SEEN) {
|
|
319
318
|
break;
|
|
320
319
|
}
|
|
320
|
+
if (id === "" || !isCueStatus(status)) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
out.set(id, status);
|
|
321
324
|
}
|
|
322
325
|
return out;
|
|
323
326
|
}
|
|
324
327
|
|
|
325
|
-
/** serializeCueSeen encodes acknowledgements for storage. The caller keeps the
|
|
326
|
-
* live map within the cap (see markCueSeen), so this does not truncate: a silent
|
|
327
|
-
* truncation here would drop whichever entries the parser happened to read last,
|
|
328
|
-
* which is the opposite of what an eviction should discard. */
|
|
329
|
-
export function serializeCueSeen(seen: ReadonlyMap<string, CueStatus>): string {
|
|
330
|
-
return JSON.stringify(Object.fromEntries(seen));
|
|
331
|
-
}
|
|
332
|
-
|
|
333
328
|
/** TabOrderKey is what the strip's order is decided by: the server's shared
|
|
334
329
|
* position for the session, its creation timestamp, and its id. Both `Tab` and
|
|
335
330
|
* the wire `SessionInfo` carry these three fields, so either can be passed
|
package/src/kernel/attention.ts
CHANGED
|
@@ -40,17 +40,18 @@ export function iconVariantHref(href: string, variant: string): string | null {
|
|
|
40
40
|
return `${href.slice(0, at)}-${variant}${href.slice(at)}`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/** Render reports onto `sinks
|
|
44
|
-
*
|
|
45
|
-
* re-
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* is still in the strip rendering its icon. */
|
|
43
|
+
/** Render reports onto `sinks`. The title and icon are change-gated: the title is
|
|
44
|
+
* also the tab label and bookmark name, and re-assigning an icon href makes some
|
|
45
|
+
* browsers re-fetch it. The badge is OS state a platform may drop a write to
|
|
46
|
+
* without rejecting, so it is also re-asserted on `resync`, a return to visible,
|
|
47
|
+
* window focus and `pageshow`. `pagehide` restores the title and icon (a browser
|
|
48
|
+
* remembers ONE icon per URL) but leaves the badge, which means "unread" for the
|
|
49
|
+
* whole app rather than for this page. */
|
|
51
50
|
export function createAttention(sinks: AttentionSinks, win: Window): AttentionSurface {
|
|
52
|
-
let reported: AttentionState =
|
|
51
|
+
let reported: AttentionState | null = null;
|
|
53
52
|
let rendered: AttentionState | null = null;
|
|
53
|
+
let badged: number | null = null;
|
|
54
|
+
let pendingResync: number | null = null;
|
|
54
55
|
|
|
55
56
|
function render(next: AttentionState): void {
|
|
56
57
|
const countChanged = next.count !== rendered?.count;
|
|
@@ -58,24 +59,58 @@ export function createAttention(sinks: AttentionSinks, win: Window): AttentionSu
|
|
|
58
59
|
rendered = next;
|
|
59
60
|
if (countChanged) {
|
|
60
61
|
sinks.setTitleMark(titleMarkFor(next.count));
|
|
61
|
-
sinks.setBadge?.(next.count);
|
|
62
62
|
}
|
|
63
63
|
if (iconChanged) {
|
|
64
64
|
sinks.setIcon?.(next.icon);
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
function cancelResync(): void {
|
|
68
|
+
if (pendingResync !== null) {
|
|
69
|
+
win.clearTimeout(pendingResync);
|
|
70
|
+
pendingResync = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function writeBadge(count: number): void {
|
|
74
|
+
cancelResync();
|
|
75
|
+
badged = count;
|
|
76
|
+
sinks.setBadge?.(count);
|
|
77
|
+
}
|
|
78
|
+
const listeners = new AbortController();
|
|
79
|
+
const { signal } = listeners;
|
|
80
|
+
// Deferred a task so this surface's listener and the caller's report on one
|
|
81
|
+
// event write once. Before the first report, a write would wipe the badge
|
|
82
|
+
// another page or a previous run set; after dispose, it would raise a cleared one.
|
|
83
|
+
function resync(): void {
|
|
84
|
+
if (reported === null || signal.aborted || pendingResync !== null) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
pendingResync = win.setTimeout(() => {
|
|
88
|
+
pendingResync = null;
|
|
89
|
+
if (reported !== null && !signal.aborted) {
|
|
90
|
+
writeBadge(reported.count);
|
|
91
|
+
}
|
|
92
|
+
}, 0);
|
|
93
|
+
}
|
|
67
94
|
|
|
68
95
|
function onPageGone(): void {
|
|
69
96
|
render(NO_ATTENTION);
|
|
70
97
|
}
|
|
71
98
|
function onPageBack(): void {
|
|
72
|
-
|
|
99
|
+
if (reported !== null) {
|
|
100
|
+
render(reported);
|
|
101
|
+
}
|
|
102
|
+
resync();
|
|
103
|
+
}
|
|
104
|
+
function onVisibility(): void {
|
|
105
|
+
if (win.document.visibilityState !== "hidden") {
|
|
106
|
+
resync();
|
|
107
|
+
}
|
|
73
108
|
}
|
|
74
|
-
const listeners = new AbortController();
|
|
75
|
-
const { signal } = listeners;
|
|
76
109
|
try {
|
|
77
110
|
win.addEventListener("pagehide", onPageGone, { signal });
|
|
78
111
|
win.addEventListener("pageshow", onPageBack, { signal });
|
|
112
|
+
win.addEventListener("focus", resync, { signal });
|
|
113
|
+
win.document.addEventListener("visibilitychange", onVisibility, { signal });
|
|
79
114
|
} catch (err) {
|
|
80
115
|
listeners.abort();
|
|
81
116
|
throw err;
|
|
@@ -85,10 +120,15 @@ export function createAttention(sinks: AttentionSinks, win: Window): AttentionSu
|
|
|
85
120
|
report(state) {
|
|
86
121
|
reported = state;
|
|
87
122
|
render(state);
|
|
123
|
+
if (state.count !== badged) {
|
|
124
|
+
writeBadge(state.count);
|
|
125
|
+
}
|
|
88
126
|
},
|
|
127
|
+
resync,
|
|
89
128
|
dispose() {
|
|
90
129
|
listeners.abort();
|
|
91
130
|
render(NO_ATTENTION);
|
|
131
|
+
writeBadge(0);
|
|
92
132
|
},
|
|
93
133
|
};
|
|
94
134
|
}
|
package/src/kernel/notify.ts
CHANGED
|
@@ -38,24 +38,41 @@ export function shouldNotify(sessionIsActive: boolean, pageVisible: boolean): bo
|
|
|
38
38
|
return !(sessionIsActive && pageVisible);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/** Bound on
|
|
42
|
-
*
|
|
41
|
+
/** Bound on each per-session map: each key is a live session server-side, so a
|
|
42
|
+
* real page is nowhere near this and a hostile stream cannot grow it. */
|
|
43
43
|
const MAX_TRACKED_SESSIONS = 200;
|
|
44
44
|
|
|
45
45
|
export function createNotifier(env: NotifierEnv): Notifier {
|
|
46
46
|
// Highest sequence delivered per session. The engine never replays a
|
|
47
47
|
// notification; this makes a doubled frame unable to notify twice for one event.
|
|
48
48
|
const lastSeq = new Map<string, number>();
|
|
49
|
+
// The banner on screen per session. A page notification is invisible to
|
|
50
|
+
// ServiceWorkerRegistration.getNotifications(), so only this reference can
|
|
51
|
+
// close it once its session is read.
|
|
52
|
+
const shown = new Map<string, NotificationLike>();
|
|
49
53
|
let armed = false;
|
|
50
54
|
let requested = false;
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
// `drop` must remove the key, or this never ends.
|
|
57
|
+
function evict(map: Map<string, unknown>, drop: (id: string) => void): void {
|
|
58
|
+
while (map.size > MAX_TRACKED_SESSIONS) {
|
|
59
|
+
const oldest = map.keys().next().value;
|
|
55
60
|
if (oldest === undefined) {
|
|
56
61
|
return;
|
|
57
62
|
}
|
|
58
|
-
|
|
63
|
+
drop(oldest);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function retract(id: string): void {
|
|
67
|
+
const posted = shown.get(id);
|
|
68
|
+
if (posted === undefined) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
shown.delete(id);
|
|
72
|
+
try {
|
|
73
|
+
posted.close();
|
|
74
|
+
} catch {
|
|
75
|
+
/* a banner the platform already took down */
|
|
59
76
|
}
|
|
60
77
|
}
|
|
61
78
|
|
|
@@ -71,7 +88,7 @@ export function createNotifier(env: NotifierEnv): Notifier {
|
|
|
71
88
|
return false;
|
|
72
89
|
}
|
|
73
90
|
lastSeq.set(ev.id, seq);
|
|
74
|
-
evict();
|
|
91
|
+
evict(lastSeq, (id) => lastSeq.delete(id));
|
|
75
92
|
}
|
|
76
93
|
if (!shouldNotify(view.sessionIsActive, env.pageVisible())) {
|
|
77
94
|
return false;
|
|
@@ -90,7 +107,16 @@ export function createNotifier(env: NotifierEnv): Notifier {
|
|
|
90
107
|
posted.onclick = (): void => {
|
|
91
108
|
view.activate();
|
|
92
109
|
posted.close();
|
|
110
|
+
if (shown.get(ev.id) === posted) {
|
|
111
|
+
shown.delete(ev.id);
|
|
112
|
+
}
|
|
93
113
|
};
|
|
114
|
+
// Re-inserted so map order is banner age, and the banner evicted is
|
|
115
|
+
// closed: a dropped reference would leave it (and Android's launcher
|
|
116
|
+
// dot) up with nothing able to retract it.
|
|
117
|
+
shown.delete(ev.id);
|
|
118
|
+
shown.set(ev.id, posted);
|
|
119
|
+
evict(shown, retract);
|
|
94
120
|
return true;
|
|
95
121
|
} catch {
|
|
96
122
|
// Safari throws on `new Notification` outside an installed PWA.
|
|
@@ -117,8 +143,10 @@ export function createNotifier(env: NotifierEnv): Notifier {
|
|
|
117
143
|
/* a browser that refuses to be asked is a tab-only browser */
|
|
118
144
|
}
|
|
119
145
|
},
|
|
146
|
+
retract,
|
|
120
147
|
forget(id): void {
|
|
121
148
|
lastSeq.delete(id);
|
|
149
|
+
retract(id);
|
|
122
150
|
},
|
|
123
151
|
};
|
|
124
152
|
}
|
package/src/kernel/shell.ts
CHANGED
|
@@ -438,7 +438,7 @@ function createShellInto(
|
|
|
438
438
|
function attention(options: AttentionOptions): AttentionReporter {
|
|
439
439
|
// A setup still running past destroy() gets a reporter that writes nothing.
|
|
440
440
|
if (destroyed) {
|
|
441
|
-
return { report: () => undefined };
|
|
441
|
+
return { report: () => undefined, resync: () => undefined };
|
|
442
442
|
}
|
|
443
443
|
const surface = createAttention(
|
|
444
444
|
browserAttentionSinks(
|
package/src/kernel/types.ts
CHANGED
|
@@ -240,6 +240,12 @@ export interface AttentionReporter {
|
|
|
240
240
|
/** Render `state`. Idempotent: a state equal to the last one reported touches
|
|
241
241
|
* nothing, so a status sweep may call it every tick. */
|
|
242
242
|
report(state: AttentionState): void;
|
|
243
|
+
/** Write the latest reported count to the app badge again on the next task,
|
|
244
|
+
* changed or not; a `report` that writes the badge before then satisfies it.
|
|
245
|
+
* The page cannot read the badge back and a platform may drop a write
|
|
246
|
+
* silently, so a caller resyncs wherever the two may have diverged, ahead of
|
|
247
|
+
* the report that follows. A no-op before the first report. */
|
|
248
|
+
resync(): void;
|
|
243
249
|
}
|
|
244
250
|
|
|
245
251
|
/** The notification a session status event carries (OSC 9 `<message>`). Both
|
|
@@ -276,7 +282,10 @@ export interface Notifier {
|
|
|
276
282
|
arm(): void;
|
|
277
283
|
/** Note a user gesture: the ONLY moment a permission prompt may be raised. */
|
|
278
284
|
gesture(): void;
|
|
279
|
-
/**
|
|
285
|
+
/** Close the notification delivered for `id`, if one is still up: the user has
|
|
286
|
+
* seen that session, so the banner no longer reports anything unread. */
|
|
287
|
+
retract(id: string): void;
|
|
288
|
+
/** Drop a closed session's dedupe state and retract its notification. */
|
|
280
289
|
forget(id: string): void;
|
|
281
290
|
}
|
|
282
291
|
|