@cowwo/dsh-icon-custom 0.10.0 → 0.10.2
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/client/client.js +430 -55
- package/lib/index.js +23 -3
- package/lib/typert.host.js +9 -3
- package/package.json +1 -1
package/client/client.js
CHANGED
|
@@ -60,6 +60,14 @@ window.__ModuleLoader__.load({
|
|
|
60
60
|
unreadWaiting: "在等你",
|
|
61
61
|
unreadEnded: "刚结束,还没看",
|
|
62
62
|
unreadPanelOther: "其他",
|
|
63
|
+
unreadMarkAllRead: "全部标记已读",
|
|
64
|
+
/** 与官方侧栏同一形状("13小时"),方便和列表里的时间戳逐字对上。 */
|
|
65
|
+
ageNow: "刚刚",
|
|
66
|
+
ageMinutes: "{n}分钟",
|
|
67
|
+
ageHours: "{n}小时",
|
|
68
|
+
ageDays: "{n}天",
|
|
69
|
+
ageMonths: "{n}个月",
|
|
70
|
+
ageYears: "{n}年",
|
|
63
71
|
workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
|
|
64
72
|
workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
|
|
65
73
|
badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
|
|
@@ -118,6 +126,14 @@ window.__ModuleLoader__.load({
|
|
|
118
126
|
unreadWaiting: "waiting for you",
|
|
119
127
|
unreadEnded: "ended, not seen yet",
|
|
120
128
|
unreadPanelOther: "Other",
|
|
129
|
+
unreadMarkAllRead: "Mark all read",
|
|
130
|
+
/** Same shape as the shipped sidebar ("13h"), so the two can be read side by side. */
|
|
131
|
+
ageNow: "now",
|
|
132
|
+
ageMinutes: "{n}min",
|
|
133
|
+
ageHours: "{n}h",
|
|
134
|
+
ageDays: "{n}d",
|
|
135
|
+
ageMonths: "{n}mo",
|
|
136
|
+
ageYears: "{n}y",
|
|
121
137
|
workspaceDotLabel: "Dots on workspace and session rows (experimental)",
|
|
122
138
|
workspaceDotHint: "Adds a small red dot to the folder icon of each workspace row. This one reads the page structure, so a DSH upgrade may break it; when it does it simply stops showing, never affecting the counts, the list, or navigation. Turn it off any time.",
|
|
123
139
|
badgeTestHint: "Real unread = a session ended for one of the checked reasons and you have not looked at it yet (opening a session marks it read). The session you are viewing counts too — the window may be minimised or the page in the background, so this half cannot tell whether you are looking, and does not pretend to. Only sub-agents never count. Manual = type a number to preview. Resets to Real on reload.",
|
|
@@ -272,21 +288,67 @@ window.__ModuleLoader__.load({
|
|
|
272
288
|
return () => { unreadConfigListeners.delete(fn); };
|
|
273
289
|
}
|
|
274
290
|
|
|
275
|
-
|
|
291
|
+
/**
|
|
292
|
+
* The seen watermark lives in the HOST's clock domain.
|
|
293
|
+
*
|
|
294
|
+
* A turn end arrives stamped with the Host's `turn/end.time`; a mark written
|
|
295
|
+
* from the browser's `Date.now()` only lines up while both clocks agree. A
|
|
296
|
+
* browser a few seconds behind the Host therefore wrote marks that never
|
|
297
|
+
* reached `endAt`, so the dot could not be cleared by entering the session at
|
|
298
|
+
* all — the one failure users report as "点进去也不消". Marks now store the
|
|
299
|
+
* `endAt` they acknowledge, and `lastActiveAt` stores a host-domain time, so
|
|
300
|
+
* every comparison in `collectUnread` stays inside one clock.
|
|
301
|
+
*/
|
|
302
|
+
const SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v2";
|
|
303
|
+
/** 0.10.1 and earlier wrote browser timestamps here; migrated once. */
|
|
304
|
+
const LEGACY_SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v1";
|
|
276
305
|
const SEEN_MAX = 400;
|
|
306
|
+
/** Host clock minus browser clock, from the last Host sample; null until known. */
|
|
307
|
+
let hostClockOffset = null;
|
|
308
|
+
/** This browser's clock expressed in the Host's domain (best effort). */
|
|
309
|
+
function hostNow() {
|
|
310
|
+
return Date.now() + (hostClockOffset === null ? 0 : hostClockOffset);
|
|
311
|
+
}
|
|
277
312
|
function loadSeenState() {
|
|
278
313
|
try {
|
|
279
314
|
const raw = JSON.parse(window.localStorage.getItem(SEEN_STORE_KEY) || "null");
|
|
280
|
-
if (raw
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
315
|
+
if (raw !== null && typeof raw === "object" && raw.domain === "host") {
|
|
316
|
+
const seen = raw.seen !== null && typeof raw.seen === "object" ? raw.seen : {};
|
|
317
|
+
return { domain: "host", lastActiveAt: typeof raw.lastActiveAt === "number" ? raw.lastActiveAt : 0, seen };
|
|
318
|
+
}
|
|
319
|
+
const legacy = JSON.parse(window.localStorage.getItem(LEGACY_SEEN_STORE_KEY) || "null");
|
|
320
|
+
if (legacy !== null && typeof legacy === "object") {
|
|
321
|
+
const seen = legacy.seen !== null && typeof legacy.seen === "object" ? legacy.seen : {};
|
|
322
|
+
return { domain: "browser", lastActiveAt: typeof legacy.lastActiveAt === "number" ? legacy.lastActiveAt : 0, seen };
|
|
323
|
+
}
|
|
324
|
+
} catch { /* fall through to an empty store */ }
|
|
325
|
+
return { domain: "host", lastActiveAt: 0, seen: {} };
|
|
284
326
|
}
|
|
285
327
|
const seenState = loadSeenState();
|
|
286
|
-
/** When this browser last had the app open before this page load. */
|
|
328
|
+
/** When this browser last had the app open before this page load (host domain). */
|
|
287
329
|
const seenBootAt = seenState.lastActiveAt > 0 ? seenState.lastActiveAt : 0;
|
|
288
330
|
function saveSeenState() {
|
|
289
|
-
try {
|
|
331
|
+
try {
|
|
332
|
+
window.localStorage.setItem(SEEN_STORE_KEY, JSON.stringify({ domain: "host", lastActiveAt: seenState.lastActiveAt, seen: seenState.seen }));
|
|
333
|
+
} catch {}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Learn the Host↔browser clock offset from a Host timestamp, and fold a
|
|
337
|
+
* legacy browser-domain store into the host domain. Idempotent: the fold
|
|
338
|
+
* happens once, on the first sample that carries a usable `hostNow`.
|
|
339
|
+
* @param hostAt - the Host's `Date.now()`, or anything else (ignored).
|
|
340
|
+
*/
|
|
341
|
+
function noteHostClock(hostAt) {
|
|
342
|
+
if (typeof hostAt !== "number" || !Number.isFinite(hostAt) || hostAt <= 0) return;
|
|
343
|
+
const offset = hostAt - Date.now();
|
|
344
|
+
hostClockOffset = offset;
|
|
345
|
+
if (seenState.domain === "host") return;
|
|
346
|
+
seenState.domain = "host";
|
|
347
|
+
if (seenState.lastActiveAt > 0) seenState.lastActiveAt += offset;
|
|
348
|
+
for (const id of Object.keys(seenState.seen)) {
|
|
349
|
+
if (typeof seenState.seen[id] === "number") seenState.seen[id] += offset;
|
|
350
|
+
}
|
|
351
|
+
saveSeenState();
|
|
290
352
|
}
|
|
291
353
|
function pruneSeenState() {
|
|
292
354
|
const ids = Object.keys(seenState.seen);
|
|
@@ -294,10 +356,20 @@ window.__ModuleLoader__.load({
|
|
|
294
356
|
ids.sort((a, b) => seenState.seen[b] - seenState.seen[a]);
|
|
295
357
|
for (const id of ids.slice(SEEN_MAX)) delete seenState.seen[id];
|
|
296
358
|
}
|
|
297
|
-
/**
|
|
298
|
-
|
|
359
|
+
/**
|
|
360
|
+
* Record that this browser has seen a session up to `seenAt` — the
|
|
361
|
+
* host-domain `endAt` being acknowledged. Marks never move backwards, so a
|
|
362
|
+
* late attempt cannot undo a newer one.
|
|
363
|
+
* @param sessionId - the session that was looked at.
|
|
364
|
+
* @param seenAt - host-domain watermark to store; defaults to the Host's now.
|
|
365
|
+
* @returns whether the mark actually advanced.
|
|
366
|
+
*/
|
|
367
|
+
function noteSeen(sessionId, seenAt) {
|
|
299
368
|
if (typeof sessionId !== "string" || sessionId === "") return false;
|
|
300
|
-
|
|
369
|
+
const mark = typeof seenAt === "number" && Number.isFinite(seenAt) ? seenAt : hostNow();
|
|
370
|
+
const previous = seenState.seen[sessionId];
|
|
371
|
+
if (typeof previous === "number" && previous >= mark) return false;
|
|
372
|
+
seenState.seen[sessionId] = mark;
|
|
301
373
|
return true;
|
|
302
374
|
}
|
|
303
375
|
/**
|
|
@@ -307,7 +379,7 @@ window.__ModuleLoader__.load({
|
|
|
307
379
|
* @returns whether anything was learned (callers persist only then).
|
|
308
380
|
*/
|
|
309
381
|
function ensureSeenBaseline(ids) {
|
|
310
|
-
const fallback = seenBootAt > 0 ? seenBootAt :
|
|
382
|
+
const fallback = seenBootAt > 0 ? seenBootAt : hostNow();
|
|
311
383
|
let changed = false;
|
|
312
384
|
for (const id of ids) {
|
|
313
385
|
if (typeof id !== "string" || id === "") continue;
|
|
@@ -318,30 +390,46 @@ window.__ModuleLoader__.load({
|
|
|
318
390
|
return changed;
|
|
319
391
|
}
|
|
320
392
|
/**
|
|
321
|
-
*
|
|
393
|
+
* Every session the main view is showing.
|
|
394
|
+
*
|
|
395
|
+
* The official signal is the row's `retainedBy.mainView` retention count, the
|
|
396
|
+
* same one ui-layout's DocumentTitle, ui-cordis, ui-open-in-app and ui-session
|
|
397
|
+
* read (`list.current` is honoured first for runtimes that expose it).
|
|
322
398
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* session. Runtimes that do expose `current` are still honoured first.
|
|
399
|
+
* It returns ALL of them, not one. The pane retains the incoming session
|
|
400
|
+
* BEFORE it releases the outgoing one, and a retention that outlives its pane
|
|
401
|
+
* can leave an old session looking "current" for good — and picking a single
|
|
402
|
+
* id then meant the session you were actually reading was never marked read:
|
|
403
|
+
* the dot stayed red however long you stayed in it. Each retained session
|
|
404
|
+
* gets its own stay clock instead.
|
|
330
405
|
* @param list - the session list snapshot (may be absent).
|
|
331
|
-
* @returns the
|
|
406
|
+
* @returns the ids being viewed, in list order; `[]` when there are none.
|
|
332
407
|
*/
|
|
333
|
-
function
|
|
334
|
-
if (list === null || typeof list !== "object") return
|
|
335
|
-
if (typeof list.current === "string" && list.current !== "") return list.current;
|
|
408
|
+
function currentSessionIds(list) {
|
|
409
|
+
if (list === null || typeof list !== "object") return [];
|
|
410
|
+
if (typeof list.current === "string" && list.current !== "") return [list.current];
|
|
336
411
|
const byId = list.byId;
|
|
337
|
-
if (byId === null || typeof byId !== "object") return
|
|
412
|
+
if (byId === null || typeof byId !== "object") return [];
|
|
413
|
+
const ids = [];
|
|
338
414
|
for (const session of Object.values(byId)) {
|
|
339
415
|
if (session === null || typeof session !== "object") continue;
|
|
340
416
|
const retainedBy = session.retainedBy;
|
|
341
417
|
const count = retainedBy !== null && typeof retainedBy === "object" ? retainedBy.mainView : undefined;
|
|
342
|
-
if (typeof count === "number" && count > 0
|
|
418
|
+
if (typeof count === "number" && count > 0 && typeof session.id === "string" && session.id !== "") ids.push(session.id);
|
|
343
419
|
}
|
|
344
|
-
return
|
|
420
|
+
return ids;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* One session's last `turn/end` timestamp (host domain), or 0 when it has
|
|
424
|
+
* none. The projection is what the Host folded; an unknown reason still
|
|
425
|
+
* carries the time.
|
|
426
|
+
* @param entry - the session-list row (may be absent).
|
|
427
|
+
* @returns the epoch milliseconds, or 0.
|
|
428
|
+
*/
|
|
429
|
+
function lastTurnEndAt(entry) {
|
|
430
|
+
const values = entry !== null && typeof entry === "object" ? entry.projectionValues : undefined;
|
|
431
|
+
const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
|
|
432
|
+
return value !== null && typeof value === "object" && typeof value.endAt === "number" ? value.endAt : 0;
|
|
345
433
|
}
|
|
346
434
|
/**
|
|
347
435
|
* What the "stay here long enough" timer is armed for, or "" when nothing
|
|
@@ -360,11 +448,7 @@ window.__ModuleLoader__.load({
|
|
|
360
448
|
function stayKeyFor(sessionId, list) {
|
|
361
449
|
if (typeof sessionId !== "string" || sessionId === "") return "";
|
|
362
450
|
const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
|
|
363
|
-
|
|
364
|
-
const values = entry !== null && typeof entry === "object" ? entry.projectionValues : undefined;
|
|
365
|
-
const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
|
|
366
|
-
const endAt = value !== null && typeof value === "object" && typeof value.endAt === "number" ? value.endAt : 0;
|
|
367
|
-
return `${sessionId}\u0000${endAt}`;
|
|
451
|
+
return `${sessionId}\u0000${lastTurnEndAt(byId[sessionId])}`;
|
|
368
452
|
}
|
|
369
453
|
/**
|
|
370
454
|
* How many sessions deserve the badge right now: endings whose reason is
|
|
@@ -724,6 +808,19 @@ window.__ModuleLoader__.load({
|
|
|
724
808
|
unreadPanelListeners.add(fn);
|
|
725
809
|
return () => { unreadPanelListeners.delete(fn); };
|
|
726
810
|
}
|
|
811
|
+
/**
|
|
812
|
+
* Tells the badge source that the marks moved somewhere it did not touch —
|
|
813
|
+
* the panel's "mark all read". It must re-render to re-emit the number (which
|
|
814
|
+
* also repaints the row dots and rebuilds the tab badge).
|
|
815
|
+
*/
|
|
816
|
+
const unreadPokeListeners = new Set();
|
|
817
|
+
function emitUnreadPoke() {
|
|
818
|
+
unreadPokeListeners.forEach((fn) => { try { fn(); } catch {} });
|
|
819
|
+
}
|
|
820
|
+
function subscribeUnreadPoke(fn) {
|
|
821
|
+
unreadPokeListeners.add(fn);
|
|
822
|
+
return () => { unreadPokeListeners.delete(fn); };
|
|
823
|
+
}
|
|
727
824
|
function toggleUnreadPanel(anchor) {
|
|
728
825
|
emitUnreadPanel(!unreadPanelOpen, anchor === undefined ? null : anchor);
|
|
729
826
|
}
|
|
@@ -773,6 +870,97 @@ window.__ModuleLoader__.load({
|
|
|
773
870
|
}
|
|
774
871
|
return groups;
|
|
775
872
|
}
|
|
873
|
+
/**
|
|
874
|
+
* Age buckets, in the shipped sidebar's own units.
|
|
875
|
+
*
|
|
876
|
+
* Same thresholds as the official `relativeTime`: under a minute "now", then
|
|
877
|
+
* minutes / hours / days / months / years. Deliberately copied instead of
|
|
878
|
+
* imported — a display plugin must not depend on a Harness Client package —
|
|
879
|
+
* but the SHAPE has to match the sidebar's, because the whole point of
|
|
880
|
+
* showing it here is that a reader can line the panel's "13小时" up with the
|
|
881
|
+
* timestamp on the row itself.
|
|
882
|
+
* @param at - host-domain epoch milliseconds.
|
|
883
|
+
* @param now - host-domain now.
|
|
884
|
+
* @returns `{ unit, n }`, `unit` in `now|minutes|hours|days|months|years`.
|
|
885
|
+
*/
|
|
886
|
+
function relativeAgeParts(at, now) {
|
|
887
|
+
const MINUTE = 60000, HOUR = 3600000, DAY = 86400000;
|
|
888
|
+
const diff = Math.max(0, now - at);
|
|
889
|
+
if (diff < MINUTE) return { unit: "now", n: 0 };
|
|
890
|
+
if (diff < HOUR) return { unit: "minutes", n: Math.floor(diff / MINUTE) };
|
|
891
|
+
if (diff < DAY) return { unit: "hours", n: Math.floor(diff / HOUR) };
|
|
892
|
+
if (diff < 30 * DAY) return { unit: "days", n: Math.floor(diff / DAY) };
|
|
893
|
+
if (diff < 365 * DAY) return { unit: "months", n: Math.floor(diff / (30 * DAY)) };
|
|
894
|
+
return { unit: "years", n: Math.floor(diff / (365 * DAY)) };
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* One ending's age as text ("13小时" / "13h"), or "" when there is no time.
|
|
898
|
+
* @param at - host-domain epoch milliseconds, 0/absent for "unknown".
|
|
899
|
+
* @param now - host-domain now.
|
|
900
|
+
* @param t - locale binder.
|
|
901
|
+
* @returns the label.
|
|
902
|
+
*/
|
|
903
|
+
function unreadAgeLabel(at, now, t) {
|
|
904
|
+
if (typeof at !== "number" || !Number.isFinite(at) || at <= 0) return "";
|
|
905
|
+
const { unit, n } = relativeAgeParts(at, now);
|
|
906
|
+
// Same shape as the shipped sidebar's own label: the "now" bucket carries
|
|
907
|
+
// no number.
|
|
908
|
+
return unit === "now" ? t("ageNow") : t(`age${unit[0].toUpperCase()}${unit.slice(1)}`, { n });
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* A short, unambiguous handle for a session: the first 8 characters of its id.
|
|
912
|
+
*
|
|
913
|
+
* Two sessions can share a title — that is precisely what made one red dot look
|
|
914
|
+
* unclearable, because the row could not be told apart from its namesake — and
|
|
915
|
+
* this is what distinguishes them when the titles do not.
|
|
916
|
+
* @param id - the session id.
|
|
917
|
+
* @returns the short form, or "".
|
|
918
|
+
*/
|
|
919
|
+
function shortSessionId(id) {
|
|
920
|
+
if (typeof id !== "string" || id === "") return "";
|
|
921
|
+
const bare = id.startsWith("session-") ? id.slice("session-".length) : id;
|
|
922
|
+
return bare.slice(0, 8);
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* The dim second line of one panel row: why it is listed, how long ago that
|
|
926
|
+
* was, and which session it is.
|
|
927
|
+
* @param item - a row from `collectUnread`.
|
|
928
|
+
* @param now - host-domain now.
|
|
929
|
+
* @param t - locale binder.
|
|
930
|
+
* @returns the line, already joined.
|
|
931
|
+
*/
|
|
932
|
+
function unreadRowSubtitle(item, now, t) {
|
|
933
|
+
const parts = [item.waiting === true ? t("unreadWaiting") : t("unreadEnded")];
|
|
934
|
+
// A "waiting" row's `at` is Date.now() (it has no ending of its own), so only
|
|
935
|
+
// turn-end rows carry a readable age.
|
|
936
|
+
if (item.waiting !== true) {
|
|
937
|
+
const age = unreadAgeLabel(item.at, now, t);
|
|
938
|
+
if (age !== "") parts.push(age);
|
|
939
|
+
}
|
|
940
|
+
const short = shortSessionId(item.id);
|
|
941
|
+
if (short !== "") parts.push(short);
|
|
942
|
+
return parts.join(" · ");
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* What "mark all read" may actually acknowledge: the turn-end rows.
|
|
946
|
+
*
|
|
947
|
+
* A "waiting for you" row is not watermark-based at all — it is listed while
|
|
948
|
+
* that interaction is live, and no mark can clear it — so the caller must
|
|
949
|
+
* leave it alone.
|
|
950
|
+
* @param items - rows from `collectUnread`.
|
|
951
|
+
* @returns `[{ id, seenAt }]`, each carrying the `endAt` it acknowledges.
|
|
952
|
+
*/
|
|
953
|
+
function unreadAckTargets(items) {
|
|
954
|
+
const targets = [];
|
|
955
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
956
|
+
if (item === null || typeof item !== "object") continue;
|
|
957
|
+
if (item.waiting === true) continue;
|
|
958
|
+
if (typeof item.id !== "string" || item.id === "") continue;
|
|
959
|
+
if (typeof item.at !== "number" || !Number.isFinite(item.at) || item.at <= 0) continue;
|
|
960
|
+
targets.push({ id: item.id, seenAt: item.at });
|
|
961
|
+
}
|
|
962
|
+
return targets;
|
|
963
|
+
}
|
|
776
964
|
//#endregion
|
|
777
965
|
|
|
778
966
|
//#region workspace dots
|
|
@@ -834,6 +1022,11 @@ window.__ModuleLoader__.load({
|
|
|
834
1022
|
if (workspaceDotFailures < WS_MAX_FAILURES || workspaceDotGivenUp) return;
|
|
835
1023
|
workspaceDotGivenUp = true;
|
|
836
1024
|
try { console.warn("dsh-icon-custom: 工作区红点连续失败,本次会话内已自动关闭,可在设置里关掉这一项。"); } catch {}
|
|
1025
|
+
// Take the markers OUT rather than leaving them frozen: a stale red dot
|
|
1026
|
+
// that no longer reflects the rule (or that can never be cleared) is worse
|
|
1027
|
+
// than no marker at all, and "breaks down to not showing" is this feature's
|
|
1028
|
+
// stated posture.
|
|
1029
|
+
clearWorkspaceDots();
|
|
837
1030
|
}
|
|
838
1031
|
/** The marker already sitting in this host for that key, or null. */
|
|
839
1032
|
function existingMarker(host, attribute, key) {
|
|
@@ -898,6 +1091,38 @@ window.__ModuleLoader__.load({
|
|
|
898
1091
|
if (rect.height < WS_ROW_MIN_H || rect.height > WS_ROW_MAX_H || rect.width < WS_ROW_MIN_W) return null;
|
|
899
1092
|
return { title: label, row };
|
|
900
1093
|
}
|
|
1094
|
+
/**
|
|
1095
|
+
* The DOM row of one session, by the official row key.
|
|
1096
|
+
*
|
|
1097
|
+
* ui-workspace stamps every row with `data-row-key` (`session:<id>`,
|
|
1098
|
+
* `workspace:<id>`, `overflow:<id>`), which is the only stable, unambiguous
|
|
1099
|
+
* handle on a row: titles are neither unique (two sessions can share one) nor
|
|
1100
|
+
* stable (a generated title lands later). Matching by title text used to find
|
|
1101
|
+
* two rows and refuse, so a session whose title collided with another's could
|
|
1102
|
+
* never be marked at all.
|
|
1103
|
+
* @param container - the browsing region root.
|
|
1104
|
+
* @param id - the session id.
|
|
1105
|
+
* @returns the row element, or null.
|
|
1106
|
+
*/
|
|
1107
|
+
function sessionRowElement(container, id) {
|
|
1108
|
+
if (typeof id !== "string" || id === "") return null;
|
|
1109
|
+
try { return container.querySelector(`[data-row-key="session:${id}"]`); } catch { return null; }
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Where one session's marker goes: the tight title holder INSIDE that
|
|
1113
|
+
* session's own row. The row is found by `data-row-key`; the title inside it
|
|
1114
|
+
* is still matched by text, because that is the element the marker hangs off.
|
|
1115
|
+
* Falls back to the old whole-region title match on a build without row keys.
|
|
1116
|
+
* @returns `{ title, row }`, or null when this row cannot be trusted.
|
|
1117
|
+
*/
|
|
1118
|
+
function sessionRowTarget(container, id, title) {
|
|
1119
|
+
const row = sessionRowElement(container, id);
|
|
1120
|
+
if (row !== null) {
|
|
1121
|
+
const inside = labelElementFor(row, title);
|
|
1122
|
+
return inside === null ? null : titleTargetFor(inside, title);
|
|
1123
|
+
}
|
|
1124
|
+
return titleTargetFor(labelElementFor(container, title), title);
|
|
1125
|
+
}
|
|
901
1126
|
function paintWorkspaceDots() {
|
|
902
1127
|
if (workspaceDotGivenUp) return;
|
|
903
1128
|
const enabled = unreadConfig.workspaceDot !== false;
|
|
@@ -950,8 +1175,7 @@ window.__ModuleLoader__.load({
|
|
|
950
1175
|
// Session rows: the marker rides inline right after the title, so it stays
|
|
951
1176
|
// beside the name whatever the timestamp happens to say.
|
|
952
1177
|
for (const entry of wantedRows) {
|
|
953
|
-
const
|
|
954
|
-
const target = titleTargetFor(label, entry.title);
|
|
1178
|
+
const target = sessionRowTarget(container, entry.id, entry.title);
|
|
955
1179
|
if (target === null) { missed++; continue; }
|
|
956
1180
|
if (existingMarker(target.row, ROW_DOT_ATTR, entry.id) !== null) { placed++; continue; }
|
|
957
1181
|
const dot = document.createElement("span");
|
|
@@ -1031,26 +1255,67 @@ window.__ModuleLoader__.load({
|
|
|
1031
1255
|
pruneSeenState();
|
|
1032
1256
|
saveSeenState();
|
|
1033
1257
|
}, [list]);
|
|
1034
|
-
const
|
|
1258
|
+
const currentIds = currentSessionIds(list);
|
|
1035
1259
|
const items = collectUnread(list, pending, config, seenState.seen);
|
|
1036
1260
|
const count = items.length;
|
|
1037
1261
|
// Being in a session is what marks it read — but only once you have STAYED
|
|
1038
1262
|
// `clearDelaySec` seconds (0 = the moment you enter, the original
|
|
1039
|
-
// behaviour).
|
|
1263
|
+
// behaviour). A clock is torn down whenever you leave, or whenever that
|
|
1040
1264
|
// session becomes unread again from a fresh ending, so the clock restarts;
|
|
1041
1265
|
// that teardown IS the "continuous stay, leaving resets it" rule. A reload
|
|
1042
|
-
// restarts it too, since the
|
|
1043
|
-
|
|
1044
|
-
|
|
1266
|
+
// restarts it too, since the timers only live in this page.
|
|
1267
|
+
//
|
|
1268
|
+
// One clock per session the main view shows: with two retained sessions,
|
|
1269
|
+
// marking only the first left the session you were reading unread forever.
|
|
1270
|
+
const unreadIds = new Set(items.map((item) => item.id));
|
|
1271
|
+
const staySignature = currentIds
|
|
1272
|
+
.filter((id) => unreadIds.has(id))
|
|
1273
|
+
.map((id) => stayKeyFor(id, list))
|
|
1274
|
+
.join("|");
|
|
1045
1275
|
const delayMs = config.clearDelaySec * 1000;
|
|
1046
1276
|
const [, bumpStay] = React.useState(0);
|
|
1277
|
+
// The panel can move the marks without going through this component
|
|
1278
|
+
// ("mark all read"); re-render so the number, the row dots and the tab
|
|
1279
|
+
// badge all follow immediately.
|
|
1280
|
+
const [, bumpPoke] = React.useState(0);
|
|
1281
|
+
React.useEffect(() => subscribeUnreadPoke(() => bumpPoke((n) => n + 1)), []);
|
|
1282
|
+
// The timer chain below is keyed on the signature, but every attempt reads
|
|
1283
|
+
// the CURRENT list and marks; a ref is how the chain sees them.
|
|
1284
|
+
const stayLive = React.useRef({ list });
|
|
1285
|
+
stayLive.current = { list };
|
|
1047
1286
|
React.useEffect(() => {
|
|
1048
|
-
if (
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1287
|
+
if (staySignature === "") return undefined;
|
|
1288
|
+
const ids = staySignature.split("|").map((key) => key.slice(0, key.indexOf("\u0000")));
|
|
1289
|
+
let cancelled = false;
|
|
1290
|
+
let timer = 0;
|
|
1291
|
+
const attempt = () => {
|
|
1292
|
+
if (cancelled) return;
|
|
1293
|
+
const byId = stayLive.current.list !== null && typeof stayLive.current.list === "object" && stayLive.current.list.byId !== null && typeof stayLive.current.list.byId === "object" ? stayLive.current.list.byId : {};
|
|
1294
|
+
let changed = false;
|
|
1295
|
+
let stillUnread = false;
|
|
1296
|
+
for (const id of ids) {
|
|
1297
|
+
const endAt = lastTurnEndAt(byId[id]);
|
|
1298
|
+
if (endAt <= 0) continue;
|
|
1299
|
+
const seenAt = seenState.seen[id];
|
|
1300
|
+
if (typeof seenAt === "number" && endAt <= seenAt) continue;
|
|
1301
|
+
stillUnread = true;
|
|
1302
|
+
if (noteSeen(id, endAt)) changed = true;
|
|
1303
|
+
}
|
|
1304
|
+
if (changed) { saveSeenState(); bumpStay((n) => n + 1); }
|
|
1305
|
+
// Watchdog. With the mark in the Host's own domain it should always
|
|
1306
|
+
// land, so this normally stops after the first attempt; it exists for
|
|
1307
|
+
// the case the mark DID land and the item is still unread (a fresh
|
|
1308
|
+
// ending raced the timer), instead of leaving the dot red for good.
|
|
1309
|
+
// A "waiting for you" item has no turn end to acknowledge: stop there.
|
|
1310
|
+
timer = stillUnread ? window.setTimeout(attempt, 2000) : 0;
|
|
1311
|
+
};
|
|
1312
|
+
if (delayMs <= 0) attempt();
|
|
1313
|
+
else timer = window.setTimeout(attempt, delayMs);
|
|
1314
|
+
return () => {
|
|
1315
|
+
cancelled = true;
|
|
1316
|
+
if (timer !== 0) window.clearTimeout(timer);
|
|
1317
|
+
};
|
|
1318
|
+
}, [staySignature, delayMs]);
|
|
1054
1319
|
React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
|
|
1055
1320
|
// The workspace rows are labelled with the workspace TITLE, which is not
|
|
1056
1321
|
// always the cwd basename, so map session → title from the official snapshot.
|
|
@@ -1187,6 +1452,9 @@ window.__ModuleLoader__.load({
|
|
|
1187
1452
|
if (headerBadgeFailures < HEAD_BADGE_MAX_FAILURES || headerBadgeGivenUp) return;
|
|
1188
1453
|
headerBadgeGivenUp = true;
|
|
1189
1454
|
try { console.warn("dsh-icon-custom: 工作区标题红点连续失败,本次会话内已自动关闭。"); } catch {}
|
|
1455
|
+
// Same posture as the row dots: give up by disappearing, never by freezing a
|
|
1456
|
+
// number that has stopped following the rule.
|
|
1457
|
+
clearHeaderBadge();
|
|
1190
1458
|
}
|
|
1191
1459
|
|
|
1192
1460
|
/**
|
|
@@ -1229,7 +1497,8 @@ window.__ModuleLoader__.load({
|
|
|
1229
1497
|
event.preventDefault();
|
|
1230
1498
|
event.stopPropagation();
|
|
1231
1499
|
const rect = node.getBoundingClientRect();
|
|
1232
|
-
|
|
1500
|
+
// `bottom` is what the panel drops below; see unreadPanelPlacement.
|
|
1501
|
+
toggleUnreadPanel({ left: rect.left, top: rect.top, bottom: rect.bottom });
|
|
1233
1502
|
});
|
|
1234
1503
|
}
|
|
1235
1504
|
const count = effectiveCount();
|
|
@@ -1284,6 +1553,52 @@ window.__ModuleLoader__.load({
|
|
|
1284
1553
|
headerBadgeHost = null;
|
|
1285
1554
|
headerBadgeHostPosition = "";
|
|
1286
1555
|
}
|
|
1556
|
+
/** Gap between the panel and whatever it is anchored to. */
|
|
1557
|
+
const UNREAD_PANEL_GAP = 6;
|
|
1558
|
+
/** Breathing room from the window edges. */
|
|
1559
|
+
const UNREAD_PANEL_MARGIN = 8;
|
|
1560
|
+
/** The panel's practical width (between its minWidth and maxWidth below). */
|
|
1561
|
+
const UNREAD_PANEL_WIDTH = 300;
|
|
1562
|
+
/** How tall the scrolling session list may get before it scrolls. */
|
|
1563
|
+
const UNREAD_PANEL_LIST_MAX = 320;
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* Where the unread panel goes, in viewport coordinates.
|
|
1567
|
+
*
|
|
1568
|
+
* It opens DOWNWARD from the badge. The original math pinned the panel's
|
|
1569
|
+
* BOTTOM edge just above the anchor — correct while the trigger sat in the
|
|
1570
|
+
* sidebar footer, and wrong the moment that trigger moved to the section
|
|
1571
|
+
* header: the same formula then parked the panel's bottom near the top of the
|
|
1572
|
+
* window and grew the panel upward, off the top of the screen (the title and
|
|
1573
|
+
* the first workspace heading were what got cut off). Anchoring on the
|
|
1574
|
+
* anchor's `bottom` is what drops it below the badge instead.
|
|
1575
|
+
*
|
|
1576
|
+
* Horizontally it stays clear of the sidebar: the panel is a frame-wide
|
|
1577
|
+
* overlay, and covering the sidebar — and the very badge that opened it — was
|
|
1578
|
+
* the other half of the complaint.
|
|
1579
|
+
* @param anchor - the badge's viewport rect (`{left, top, bottom}`), or null.
|
|
1580
|
+
* @param sidebarRight - the browsing region's right edge, or null.
|
|
1581
|
+
* @param viewportWidth - window width.
|
|
1582
|
+
* @param viewportHeight - window height.
|
|
1583
|
+
* @returns `{ left, top, listMaxHeight }` in viewport pixels.
|
|
1584
|
+
*/
|
|
1585
|
+
function unreadPanelPlacement(anchor, sidebarRight, viewportWidth, viewportHeight) {
|
|
1586
|
+
let left = UNREAD_PANEL_MARGIN;
|
|
1587
|
+
let top = UNREAD_PANEL_MARGIN;
|
|
1588
|
+
if (anchor !== null && typeof anchor === "object") {
|
|
1589
|
+
if (typeof anchor.left === "number") left = anchor.left;
|
|
1590
|
+
const below = typeof anchor.bottom === "number" ? anchor.bottom : anchor.top;
|
|
1591
|
+
if (typeof below === "number") top = below + UNREAD_PANEL_GAP;
|
|
1592
|
+
}
|
|
1593
|
+
if (typeof sidebarRight === "number" && sidebarRight > 0) left = Math.max(left, sidebarRight + UNREAD_PANEL_GAP);
|
|
1594
|
+
left = Math.max(UNREAD_PANEL_MARGIN, Math.min(left, viewportWidth - UNREAD_PANEL_WIDTH - UNREAD_PANEL_MARGIN));
|
|
1595
|
+
top = Math.max(UNREAD_PANEL_MARGIN, top);
|
|
1596
|
+
// Never taller than what is left below the panel's own top, so a long list
|
|
1597
|
+
// scrolls inside the panel instead of pushing it off the bottom edge.
|
|
1598
|
+
const listMaxHeight = Math.max(120, Math.min(UNREAD_PANEL_LIST_MAX, viewportHeight - top - 72));
|
|
1599
|
+
return { left: Math.round(left), top: Math.round(top), listMaxHeight: Math.round(listMaxHeight) };
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1287
1602
|
/**
|
|
1288
1603
|
* The list behind that number, in the frame-wide overlay layer so it escapes
|
|
1289
1604
|
* the sidebar's clipping and scroll container. Clicking a row opens that
|
|
@@ -1292,8 +1607,16 @@ window.__ModuleLoader__.load({
|
|
|
1292
1607
|
function UnreadPopup(props) {
|
|
1293
1608
|
const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
|
|
1294
1609
|
const [open, setOpen] = React.useState(unreadPanelOpen);
|
|
1610
|
+
// Ages are computed against the HOST's clock (the endings are host
|
|
1611
|
+
// timestamps); re-tick while open so "刚刚" does not stay stale.
|
|
1612
|
+
const [, tick] = React.useState(0);
|
|
1295
1613
|
React.useEffect(() => subscribeBadge(setSnapshot), []);
|
|
1296
1614
|
React.useEffect(() => subscribeUnreadPanel(() => setOpen(unreadPanelOpen)), []);
|
|
1615
|
+
React.useEffect(() => {
|
|
1616
|
+
if (!open) return undefined;
|
|
1617
|
+
const interval = window.setInterval(() => tick((n) => n + 1), 30000);
|
|
1618
|
+
return () => window.clearInterval(interval);
|
|
1619
|
+
}, [open]);
|
|
1297
1620
|
React.useEffect(() => {
|
|
1298
1621
|
if (!open) return undefined;
|
|
1299
1622
|
const onKey = (event) => { if (event.key === "Escape") emitUnreadPanel(false); };
|
|
@@ -1312,22 +1635,42 @@ window.__ModuleLoader__.load({
|
|
|
1312
1635
|
if (!open) return null;
|
|
1313
1636
|
const t = props.t;
|
|
1314
1637
|
const anchor = unreadPanelAnchor;
|
|
1638
|
+
// Measured, not assumed: the panel has to clear whatever width the sidebar
|
|
1639
|
+
// currently has (it is resizable and collapsible).
|
|
1640
|
+
let sidebarRight = null;
|
|
1641
|
+
try {
|
|
1642
|
+
const region = workspaceDotContainer();
|
|
1643
|
+
if (region !== null) sidebarRight = region.getBoundingClientRect().right;
|
|
1644
|
+
} catch { /* measurement is best effort; the margin fallback still applies */ }
|
|
1645
|
+
const place = unreadPanelPlacement(anchor, sidebarRight, window.innerWidth, window.innerHeight);
|
|
1315
1646
|
const style = {
|
|
1316
|
-
position: "fixed", left: "
|
|
1647
|
+
position: "fixed", left: place.left + "px", top: place.top + "px", zIndex: 60, pointerEvents: "auto",
|
|
1317
1648
|
minWidth: "240px", maxWidth: "340px", padding: "8px", borderRadius: "12px",
|
|
1318
1649
|
border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-1)",
|
|
1319
1650
|
boxShadow: "0 10px 30px rgba(0,0,0,.18)", color: "var(--dsw-alias-label-primary)", fontSize: "12px"
|
|
1320
1651
|
};
|
|
1321
|
-
if (anchor !== null && typeof anchor === "object") {
|
|
1322
|
-
style.left = Math.max(8, Math.round(anchor.left)) + "px";
|
|
1323
|
-
style.bottom = Math.max(8, Math.round(window.innerHeight - anchor.top + 6)) + "px";
|
|
1324
|
-
}
|
|
1325
1652
|
const items = Array.isArray(snapshot.items) ? snapshot.items : [];
|
|
1653
|
+
const ackTargets = unreadAckTargets(items);
|
|
1654
|
+
/**
|
|
1655
|
+
* Acknowledge every turn-end row at once.
|
|
1656
|
+
*
|
|
1657
|
+
* "Waiting for you" rows cannot be acknowledged (they are not watermark
|
|
1658
|
+
* based), so the panel stays open while any of them remain — closing it
|
|
1659
|
+
* would hide the one thing that still needs the reader.
|
|
1660
|
+
*/
|
|
1661
|
+
const markAllRead = () => {
|
|
1662
|
+
let changed = false;
|
|
1663
|
+
for (const target of ackTargets) if (noteSeen(target.id, target.seenAt)) changed = true;
|
|
1664
|
+
if (!changed) return;
|
|
1665
|
+
saveSeenState();
|
|
1666
|
+
emitUnreadPoke();
|
|
1667
|
+
if (ackTargets.length === items.length) emitUnreadPanel(false);
|
|
1668
|
+
};
|
|
1326
1669
|
return React.createElement("div", { "data-icon-custom-unread": "1", style },
|
|
1327
1670
|
React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t("unreadPanelTitle")),
|
|
1328
1671
|
items.length === 0
|
|
1329
1672
|
? React.createElement("div", { style: { padding: "2px 6px 8px", opacity: 0.7 } }, t("unreadPanelEmpty"))
|
|
1330
|
-
: React.createElement("div", { style: { display: "flex", flexDirection: "column", maxHeight: "
|
|
1673
|
+
: React.createElement("div", { style: { display: "flex", flexDirection: "column", maxHeight: place.listMaxHeight + "px", overflowY: "auto" } },
|
|
1331
1674
|
groupUnreadByWorkspace(items).map((group) => React.createElement("div", { key: group.where === "" ? "\u0000none" : group.where },
|
|
1332
1675
|
React.createElement("div", { style: { padding: "7px 8px 3px", fontSize: "11px", fontWeight: 600, opacity: 0.55, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
|
|
1333
1676
|
group.where === "" ? t("unreadPanelOther") : group.where
|
|
@@ -1338,10 +1681,17 @@ window.__ModuleLoader__.load({
|
|
|
1338
1681
|
style: { display: "block", width: "100%", textAlign: "left", padding: "6px 8px", border: "0", borderRadius: "8px", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer" }
|
|
1339
1682
|
},
|
|
1340
1683
|
React.createElement("span", { style: { display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, item.title),
|
|
1341
|
-
React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, item
|
|
1684
|
+
React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, unreadRowSubtitle(item, hostNow(), t))
|
|
1342
1685
|
))
|
|
1343
1686
|
))
|
|
1344
|
-
)
|
|
1687
|
+
),
|
|
1688
|
+
ackTargets.length > 0 && React.createElement("div", { style: { marginTop: "6px", paddingTop: "6px", borderTop: "1px solid var(--dsw-alias-border-l2)" } },
|
|
1689
|
+
React.createElement("button", {
|
|
1690
|
+
type: "button",
|
|
1691
|
+
onClick: markAllRead,
|
|
1692
|
+
style: { display: "block", width: "100%", padding: "5px 8px", border: "0", borderRadius: "8px", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer", opacity: 0.85 }
|
|
1693
|
+
}, t("unreadMarkAllRead"))
|
|
1694
|
+
)
|
|
1345
1695
|
);
|
|
1346
1696
|
}
|
|
1347
1697
|
|
|
@@ -1802,12 +2152,21 @@ window.__ModuleLoader__.load({
|
|
|
1802
2152
|
.then((resp) => { if (resp && resp.ok === true) syncStatus(resp.value); })
|
|
1803
2153
|
.catch(() => {});
|
|
1804
2154
|
rpc.call("/api", "iconCustom/getUnreadRule", { args: {} })
|
|
1805
|
-
.then((resp) => {
|
|
2155
|
+
.then((resp) => {
|
|
2156
|
+
if (resp === null || typeof resp !== "object" || resp.ok !== true) return;
|
|
2157
|
+
// The rule response carries the Host's clock; it is what puts the seen
|
|
2158
|
+
// watermark (and a legacy browser-domain store) into the Host's domain.
|
|
2159
|
+
if (resp.value !== null && typeof resp.value === "object") noteHostClock(resp.value.hostNow);
|
|
2160
|
+
emitUnreadConfig(resp.value);
|
|
2161
|
+
})
|
|
1806
2162
|
.catch(() => {});
|
|
1807
2163
|
// Keep "the last moment this browser was here" fresh, so an ending that
|
|
1808
2164
|
// happens while the app is closed still counts as unread when you return.
|
|
1809
2165
|
ctx.effect(() => {
|
|
1810
|
-
|
|
2166
|
+
// Host domain (see the seen store): "the last moment this browser was
|
|
2167
|
+
// here" must be comparable with `turn/end.time`, not with the browser's
|
|
2168
|
+
// own clock.
|
|
2169
|
+
const touch = () => { seenState.lastActiveAt = hostNow(); saveSeenState(); };
|
|
1811
2170
|
const interval = window.setInterval(touch, 20000);
|
|
1812
2171
|
window.addEventListener("visibilitychange", touch);
|
|
1813
2172
|
window.addEventListener("beforeunload", touch);
|
|
@@ -1818,6 +2177,22 @@ window.__ModuleLoader__.load({
|
|
|
1818
2177
|
touch();
|
|
1819
2178
|
};
|
|
1820
2179
|
}, "dsh-icon-custom: unread watermark");
|
|
2180
|
+
// Re-sample the Host↔browser clock offset after a sleep/resume, which is the
|
|
2181
|
+
// one moment the two can drift apart mid-page. Throttled: it is one small
|
|
2182
|
+
// RPC per visible-again, and never on beforeunload (nothing would come back).
|
|
2183
|
+
ctx.effect(() => {
|
|
2184
|
+
let sampledAt = 0;
|
|
2185
|
+
const sample = () => {
|
|
2186
|
+
if (document.visibilityState === "hidden") return;
|
|
2187
|
+
if (Date.now() - sampledAt < 60000) return;
|
|
2188
|
+
sampledAt = Date.now();
|
|
2189
|
+
rpc.call("/api", "iconCustom/getUnreadRule", { args: {} })
|
|
2190
|
+
.then((resp) => { if (resp && resp.ok === true && resp.value !== null && typeof resp.value === "object") noteHostClock(resp.value.hostNow); })
|
|
2191
|
+
.catch(() => {});
|
|
2192
|
+
};
|
|
2193
|
+
window.addEventListener("visibilitychange", sample);
|
|
2194
|
+
return () => window.removeEventListener("visibilitychange", sample);
|
|
2195
|
+
}, "dsh-icon-custom: host clock sample");
|
|
1821
2196
|
// Brand-mark seat: replaces only the whale mark (verified: a
|
|
1822
2197
|
// third-party registration wins over the official occupant and the
|
|
1823
2198
|
// official package's mark steps aside). The brand NAME text is a
|
package/lib/index.js
CHANGED
|
@@ -459,10 +459,10 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
459
459
|
* icon RPCs (`getStatus` / `setIcon` / `setPwa` / `setLogo` / `resetIcon`)
|
|
460
460
|
* keep their EXACT pre-badge response shape, so a problem in this feature
|
|
461
461
|
* can never break the icon itself.
|
|
462
|
-
* @returns `{ reasons, pending }`.
|
|
462
|
+
* @returns `{ reasons, pending, workspaceDot, clearDelaySec, hostNow }`.
|
|
463
463
|
*/
|
|
464
464
|
async getUnreadRule() {
|
|
465
|
-
return
|
|
465
|
+
return this._unreadRuleView()
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
/**
|
|
@@ -478,7 +478,27 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
478
478
|
}
|
|
479
479
|
this.unread = normalizeUnreadConfig(request)
|
|
480
480
|
await this._saveUnread().catch(() => {}) // rule file is advisory
|
|
481
|
-
return
|
|
481
|
+
return this._unreadRuleView()
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The rule as the client reads it, plus the Host's clock.
|
|
486
|
+
*
|
|
487
|
+
* `hostNow` is here because the badge compares the Host's `turn/end.time`
|
|
488
|
+
* against a watermark this browser keeps: without a Host time sample the
|
|
489
|
+
* browser has to guess that domain from its own clock, and a browser a few
|
|
490
|
+
* seconds behind the Host then clears nothing. It rides this existing
|
|
491
|
+
* response instead of a new endpoint, so an older client simply ignores it.
|
|
492
|
+
* @returns the stored rule with `hostNow`.
|
|
493
|
+
*/
|
|
494
|
+
_unreadRuleView() {
|
|
495
|
+
return {
|
|
496
|
+
reasons: this.unread.reasons,
|
|
497
|
+
pending: this.unread.pending,
|
|
498
|
+
workspaceDot: this.unread.workspaceDot,
|
|
499
|
+
clearDelaySec: this.unread.clearDelaySec,
|
|
500
|
+
hostNow: Date.now()
|
|
501
|
+
}
|
|
482
502
|
}
|
|
483
503
|
|
|
484
504
|
/** typert endpoint: store a new icon uploaded as a data URL and make it active. */
|
package/lib/typert.host.js
CHANGED
|
@@ -54,7 +54,13 @@ const unreadRuleSchema = z.object({
|
|
|
54
54
|
/** 实验项:工作区图标上的红点(贴产品 DOM,可一键关闭)。 */
|
|
55
55
|
workspaceDot: z.boolean(),
|
|
56
56
|
/** 进入会话后要连续待满多少秒才算"看过";0 = 进入即已读。上限 10 分钟。 */
|
|
57
|
-
clearDelaySec: z.number().int().nonnegative().max(600)
|
|
57
|
+
clearDelaySec: z.number().int().nonnegative().max(600),
|
|
58
|
+
/**
|
|
59
|
+
* 宿主此刻的 `Date.now()`。
|
|
60
|
+
* 客户端用它把"看过"水位换算进宿主时钟域(`turn/end.time` 也是宿主时钟):
|
|
61
|
+
* 浏览器与宿主差几秒,红点就会永远清不掉。
|
|
62
|
+
*/
|
|
63
|
+
hostNow: z.number()
|
|
58
64
|
})
|
|
59
65
|
|
|
60
66
|
const setUnreadRuleRequestSchema = z.object({
|
|
@@ -272,7 +278,7 @@ export const TYPERT = {
|
|
|
272
278
|
{
|
|
273
279
|
kind: 'method',
|
|
274
280
|
name: 'getUnreadRule',
|
|
275
|
-
signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number }>',
|
|
281
|
+
signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number; hostNow: number }>',
|
|
276
282
|
description: "Read the unread-badge rule.",
|
|
277
283
|
summary: "Read the unread-badge rule.",
|
|
278
284
|
jsDoc: "/** Read the unread-badge rule. */",
|
|
@@ -281,7 +287,7 @@ export const TYPERT = {
|
|
|
281
287
|
{
|
|
282
288
|
kind: 'method',
|
|
283
289
|
name: 'setUnreadRule',
|
|
284
|
-
signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean; clearDelaySec?: number }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number }>',
|
|
290
|
+
signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean; clearDelaySec?: number }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number; hostNow: number }>',
|
|
285
291
|
description: "Replace the unread-badge rule: which turn-end reasons light the red dot.",
|
|
286
292
|
summary: "Set the unread-badge rule.",
|
|
287
293
|
jsDoc: "/** Set the unread-badge rule. */",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cowwo/dsh-icon-custom",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|