@cowwo/dsh-icon-custom 0.10.1 → 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 +372 -48
- 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
|
/**
|
|
@@ -1339,8 +1607,16 @@ window.__ModuleLoader__.load({
|
|
|
1339
1607
|
function UnreadPopup(props) {
|
|
1340
1608
|
const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
|
|
1341
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);
|
|
1342
1613
|
React.useEffect(() => subscribeBadge(setSnapshot), []);
|
|
1343
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]);
|
|
1344
1620
|
React.useEffect(() => {
|
|
1345
1621
|
if (!open) return undefined;
|
|
1346
1622
|
const onKey = (event) => { if (event.key === "Escape") emitUnreadPanel(false); };
|
|
@@ -1374,6 +1650,22 @@ window.__ModuleLoader__.load({
|
|
|
1374
1650
|
boxShadow: "0 10px 30px rgba(0,0,0,.18)", color: "var(--dsw-alias-label-primary)", fontSize: "12px"
|
|
1375
1651
|
};
|
|
1376
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
|
+
};
|
|
1377
1669
|
return React.createElement("div", { "data-icon-custom-unread": "1", style },
|
|
1378
1670
|
React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t("unreadPanelTitle")),
|
|
1379
1671
|
items.length === 0
|
|
@@ -1389,10 +1681,17 @@ window.__ModuleLoader__.load({
|
|
|
1389
1681
|
style: { display: "block", width: "100%", textAlign: "left", padding: "6px 8px", border: "0", borderRadius: "8px", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer" }
|
|
1390
1682
|
},
|
|
1391
1683
|
React.createElement("span", { style: { display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, item.title),
|
|
1392
|
-
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))
|
|
1393
1685
|
))
|
|
1394
1686
|
))
|
|
1395
|
-
)
|
|
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
|
+
)
|
|
1396
1695
|
);
|
|
1397
1696
|
}
|
|
1398
1697
|
|
|
@@ -1853,12 +2152,21 @@ window.__ModuleLoader__.load({
|
|
|
1853
2152
|
.then((resp) => { if (resp && resp.ok === true) syncStatus(resp.value); })
|
|
1854
2153
|
.catch(() => {});
|
|
1855
2154
|
rpc.call("/api", "iconCustom/getUnreadRule", { args: {} })
|
|
1856
|
-
.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
|
+
})
|
|
1857
2162
|
.catch(() => {});
|
|
1858
2163
|
// Keep "the last moment this browser was here" fresh, so an ending that
|
|
1859
2164
|
// happens while the app is closed still counts as unread when you return.
|
|
1860
2165
|
ctx.effect(() => {
|
|
1861
|
-
|
|
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(); };
|
|
1862
2170
|
const interval = window.setInterval(touch, 20000);
|
|
1863
2171
|
window.addEventListener("visibilitychange", touch);
|
|
1864
2172
|
window.addEventListener("beforeunload", touch);
|
|
@@ -1869,6 +2177,22 @@ window.__ModuleLoader__.load({
|
|
|
1869
2177
|
touch();
|
|
1870
2178
|
};
|
|
1871
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");
|
|
1872
2196
|
// Brand-mark seat: replaces only the whale mark (verified: a
|
|
1873
2197
|
// third-party registration wins over the official occupant and the
|
|
1874
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",
|