@cowwo/dsh-icon-custom 0.12.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.zh.md +1 -1
- package/client/client.js +137 -29
- package/package.json +1 -1
package/README.zh.md
CHANGED
|
@@ -58,7 +58,7 @@ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提
|
|
|
58
58
|
规则:
|
|
59
59
|
|
|
60
60
|
- 数字 = **几个会话**在等你(不是几件事);同一个会话只占一格。
|
|
61
|
-
-
|
|
61
|
+
- 只有子代理会话不计。**什么算"你在看"**(0.15.2 起):那个会话必须真的显示在主区域的对话里、浏览器标签页在前台(切到别的标签页或最小化不算)、且屏幕上就是它——残留的"最近打开过"不再算数。**这两种"不算"都只是暂停计时,不会因此清掉红点**;真正算"离开"的只有一件事:**去别的会话**(那时它的快照被丢弃,你下次进来待够 N 秒才算看过)。**你正开着的那个会话也算**;**要清掉它,切走再切回来即可**(你人已经在里面时又结束的那一轮不会自动消,见下一条)。
|
|
62
62
|
- **已读记在宿主**(`$DSH_HOME/custom-favicon/unread-seen.json`):进过 = 处理过,刷新/重启不会"复活",**任何一台设备上看过,所有设备都不再提醒**(开着的页面最多约一分钟收敛,刷新则立刻)。浏览器自己也留一份,但只当缓存兼离线队列——所以清站点数据、换浏览器、无痕窗口都不会把"已读"丢回去。装插件之前的历史结束也不算。
|
|
63
63
|
- **可以要求"待够才算已读"**:设置页填一个秒数(默认 0)。填 0 = 进去就算已读(红点立即消失);填 N = 你要在那个会话里**连续待满 N 秒**它才算看过,中途切走就清零重数,刷新页面也重新数。上限 600 秒。
|
|
64
64
|
**计时只认"你进入会话时已经存在的那次结束"**:你人已经在会话里的时候它又跑完一轮,那一轮**不算数**——红点会一直亮着,等你**切走再进来**才消。这样"我正看着的会话跑完了"这件事不会被几秒钟后自动抹掉(0.11.1 起;此前是"停留满 N 秒就把最新那次结束也标成已读",红点只闪一下)。
|
package/client/client.js
CHANGED
|
@@ -80,7 +80,7 @@ window.__ModuleLoader__.load({
|
|
|
80
80
|
badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
|
|
81
81
|
clearDelayLabel: "进入会话后多久算已读:",
|
|
82
82
|
clearDelayUnit: "秒",
|
|
83
|
-
clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600
|
|
83
|
+
clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600 秒。**切到「插件/设置」页、或把标签页切到后台,都只是暂停计时,不会因此清掉红点**(它们不算离开);真正算离开的只有一件事:**去别的会话**。已读记在宿主($DSH_HOME/custom-favicon/unread-seen.json):任何一台设备上看过,所有设备都不再提醒;浏览器那份只是缓存,清站点数据不会把已读丢回去。"
|
|
84
84
|
};
|
|
85
85
|
const en = {
|
|
86
86
|
nav: "Favicon",
|
|
@@ -153,7 +153,7 @@ window.__ModuleLoader__.load({
|
|
|
153
153
|
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.",
|
|
154
154
|
clearDelayLabel: "Mark read after staying:",
|
|
155
155
|
clearDelayUnit: "seconds",
|
|
156
|
-
clearDelayHint: "0 = read as soon as you enter (the dot clears at once). Above 0 you must stay in that session for this many seconds before it counts as read: leaving it resets the clock, and so does a reload. The clock only ever covers **the ending that was already there when you entered** — a turn that finishes while you are sitting in the session does not count: it stays red until you leave and come back. Capped at 600 seconds. Read state lives on the Host ($DSH_HOME/custom-favicon/unread-seen.json): read on any device means read on every device, and the browser's own copy is only a cache — clearing site data no longer throws it away."
|
|
156
|
+
clearDelayHint: "0 = read as soon as you enter (the dot clears at once). Above 0 you must stay in that session for this many seconds before it counts as read: leaving it resets the clock, and so does a reload. The clock only ever covers **the ending that was already there when you entered** — a turn that finishes while you are sitting in the session does not count: it stays red until you leave and come back. Capped at 600 seconds. A settings or plugins page, or a tab in the background, only **pauses** the clock — neither clears a dot (neither counts as leaving); the one thing that does count as leaving is **opening another session**. Read state lives on the Host ($DSH_HOME/custom-favicon/unread-seen.json): read on any device means read on every device, and the browser's own copy is only a cache — clearing site data no longer throws it away."
|
|
157
157
|
};
|
|
158
158
|
//#endregion
|
|
159
159
|
|
|
@@ -562,23 +562,77 @@ window.__ModuleLoader__.load({
|
|
|
562
562
|
}
|
|
563
563
|
return changed;
|
|
564
564
|
}
|
|
565
|
+
/**
|
|
566
|
+
* Whether this page is on screen at all.
|
|
567
|
+
*
|
|
568
|
+
* A hidden tab is not "being looked at": the clock must not run while the page
|
|
569
|
+
* is in the background, or a session left open in another tab would be marked
|
|
570
|
+
* read for every device. Unknown (an exotic embedder) counts as visible, which
|
|
571
|
+
* is the pre-0.12.1 behaviour.
|
|
572
|
+
* @returns whether the page is visible.
|
|
573
|
+
*/
|
|
574
|
+
function documentVisible() {
|
|
575
|
+
try { return document.visibilityState !== "hidden"; } catch { return true; }
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* The session whose conversation the page is rendering right now, from the DOM.
|
|
579
|
+
*
|
|
580
|
+
* This is the only signal that names the session on screen, and it is the one
|
|
581
|
+
* that keeps a stale retention from clearing a dot: `retainedBy.mainView` means
|
|
582
|
+
* "recently opened, not yet released", not "displayed". `undefined` means this
|
|
583
|
+
* build carries no such marker (or the probe failed) and callers must fall back
|
|
584
|
+
* to the retention answer rather than treat it as "nothing is displayed".
|
|
585
|
+
* @returns the session id, or undefined when the page cannot say.
|
|
586
|
+
*/
|
|
587
|
+
function displayedSessionId() {
|
|
588
|
+
try {
|
|
589
|
+
const node = document.querySelector("[data-conversation-session]");
|
|
590
|
+
if (node === null) return undefined;
|
|
591
|
+
const id = node.getAttribute("data-conversation-session");
|
|
592
|
+
return typeof id === "string" && id !== "" ? id : undefined;
|
|
593
|
+
} catch { return undefined; }
|
|
594
|
+
}
|
|
565
595
|
/**
|
|
566
596
|
* Every session the main view is showing.
|
|
567
597
|
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
598
|
+
* `view` carries what the page itself says about the screen:
|
|
599
|
+
* * `panelActive` — a global panel (settings, plugins, …) replaced the
|
|
600
|
+
* conversation. DSH's own sidebar treats that as "no current session"
|
|
601
|
+
* (`usePanelInfo((info) => info.activePanelId !== null)`), and following it
|
|
602
|
+
* is what fixes the "cleared a dot I never opened" report: the conversation
|
|
603
|
+
* is simply not on screen.
|
|
604
|
+
* * `visible` — whether this page is on screen at all.
|
|
605
|
+
* * `displayed` — the session the conversation is rendering, when the DOM can
|
|
606
|
+
* name it. That is the precise answer and it wins over retention.
|
|
607
|
+
*
|
|
608
|
+
* Retention (`retainedBy.mainView`) stays as the fallback: it is what shipped
|
|
609
|
+
* before, it keeps working on a build without the newer signals, and its known
|
|
610
|
+
* weakness (a release that lags behind the pane) only costs a false "read" when
|
|
611
|
+
* nothing better is available.
|
|
612
|
+
* @param list - the session list snapshot (may be absent).
|
|
613
|
+
* @param view - the screen report described above (may be absent).
|
|
614
|
+
* @returns the ids being viewed, in list order; `[]` when there are none.
|
|
615
|
+
*/
|
|
616
|
+
/**
|
|
617
|
+
* Which session this page is ON — the set that defines "entered" and "left".
|
|
618
|
+
*
|
|
619
|
+
* Only a change here counts as leaving a session, so this must NOT be emptied
|
|
620
|
+
* by pausing: a background tab, or a global panel (settings, plugins, …) that
|
|
621
|
+
* covers the conversation, keeps you on the same session. Emptying it would
|
|
622
|
+
* drop the entered-at snapshot, and taking a fresh one on return is exactly
|
|
623
|
+
* what acknowledges an ending you never left the session for — the bug behind
|
|
624
|
+
* "切一下标签页红点就没了".
|
|
571
625
|
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* id then meant the session you were actually reading was never marked read:
|
|
576
|
-
* the dot stayed red however long you stayed in it. Each retained session
|
|
577
|
-
* gets its own stay clock instead.
|
|
626
|
+
* `view.displayed` (read from the DOM) is the precise answer when available;
|
|
627
|
+
* `list.current` and then retention are the fallbacks, which is also what keeps
|
|
628
|
+
* the answer meaningful while a panel covers the conversation.
|
|
578
629
|
* @param list - the session list snapshot (may be absent).
|
|
630
|
+
* @param view - `{ displayed }`: the session the conversation is rendering.
|
|
579
631
|
* @returns the ids being viewed, in list order; `[]` when there are none.
|
|
580
632
|
*/
|
|
581
|
-
function currentSessionIds(list) {
|
|
633
|
+
function currentSessionIds(list, view) {
|
|
634
|
+
const screen = view !== null && typeof view === "object" ? view : {};
|
|
635
|
+
if (typeof screen.displayed === "string" && screen.displayed !== "") return [screen.displayed];
|
|
582
636
|
if (list === null || typeof list !== "object") return [];
|
|
583
637
|
if (typeof list.current === "string" && list.current !== "") return [list.current];
|
|
584
638
|
const byId = list.byId;
|
|
@@ -1081,15 +1135,45 @@ window.__ModuleLoader__.load({
|
|
|
1081
1135
|
// Never silent: a failure here is invisible in the UI (the tab simply
|
|
1082
1136
|
// keeps its previous icon), which once cost a whole debugging session.
|
|
1083
1137
|
// Warn once per distinct reason so the 5s watchdog cannot spam.
|
|
1084
|
-
|
|
1085
|
-
if (why !== lastComposeError) {
|
|
1086
|
-
lastComposeError = why;
|
|
1087
|
-
try { console.warn("dsh-icon-custom: 标签页红点合成失败 / favicon badge compose failed:", why); } catch {}
|
|
1088
|
-
}
|
|
1138
|
+
warnFaviconCompose(error);
|
|
1089
1139
|
} finally {
|
|
1090
1140
|
if (seq === composeSeq) faviconComposing = false;
|
|
1091
1141
|
}
|
|
1092
1142
|
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Report a failed tab-icon composition, once per distinct reason.
|
|
1145
|
+
*
|
|
1146
|
+
* The 5-second watchdog retries, so repeating the same line would bury the
|
|
1147
|
+
* console in noise; a NEW reason is exactly the signal worth seeing.
|
|
1148
|
+
* @param error - whatever the compose threw.
|
|
1149
|
+
*/
|
|
1150
|
+
function warnFaviconCompose(error) {
|
|
1151
|
+
const why = String((error && error.message) || error);
|
|
1152
|
+
if (why === lastComposeError) return;
|
|
1153
|
+
lastComposeError = why;
|
|
1154
|
+
try { console.warn("dsh-icon-custom: 标签页红点合成失败 / favicon badge compose failed:", why); } catch {}
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Fire-and-forget composition whose failures are still reported.
|
|
1158
|
+
*
|
|
1159
|
+
* `applyBadgeToFavicon` is async, so it never throws synchronously: a caller's
|
|
1160
|
+
* `try { ... } catch {}` (the badge listeners all have one) cannot see its
|
|
1161
|
+
* rejection, and the tab icon would simply never change with nothing in the
|
|
1162
|
+
* console — the exact "no warning, no badge" state. Every call site goes through
|
|
1163
|
+
* here instead.
|
|
1164
|
+
* @param count - sessions needing you; 0 restores the plain icon.
|
|
1165
|
+
* @param size - badge size key.
|
|
1166
|
+
*/
|
|
1167
|
+
function composeFavicon(count, size) {
|
|
1168
|
+
try {
|
|
1169
|
+
Promise.resolve(applyBadgeToFavicon(count, size)).catch(warnFaviconCompose);
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
// An `async` callee never throws synchronously, so this arm is dead TODAY —
|
|
1172
|
+
// it is here so a future refactor (an early-returning sync path) cannot
|
|
1173
|
+
// quietly reintroduce the silent "no warning, no badge" state.
|
|
1174
|
+
warnFaviconCompose(error);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1093
1177
|
/**
|
|
1094
1178
|
* Re-assert the tab badge after anything outside this plugin disturbed the
|
|
1095
1179
|
* icon link — a cached page, a browser extension, a tab move, or a plugin
|
|
@@ -1106,17 +1190,17 @@ window.__ModuleLoader__.load({
|
|
|
1106
1190
|
// Carries the custom icon, or puts the shipped pair back. Both writers
|
|
1107
1191
|
// only touch a link that actually differs, so this stays a no-op once
|
|
1108
1192
|
// the page is correct and cannot re-arm the head observer.
|
|
1109
|
-
|
|
1193
|
+
composeFavicon(0);
|
|
1110
1194
|
return;
|
|
1111
1195
|
}
|
|
1112
1196
|
if (ours || faviconComposing) return; // already showing, or a build is running
|
|
1113
|
-
|
|
1197
|
+
composeFavicon(effectiveCount(), badgeSize);
|
|
1114
1198
|
}
|
|
1115
1199
|
// A freshly applied base icon replaces whatever the browser holds, so the
|
|
1116
1200
|
// composite must be rebuilt from the NEW base instead of the stale one.
|
|
1117
1201
|
function refreshFaviconBase() {
|
|
1118
1202
|
faviconBase = null;
|
|
1119
|
-
|
|
1203
|
+
composeFavicon(effectiveCount(), badgeSize);
|
|
1120
1204
|
}
|
|
1121
1205
|
//#endregion
|
|
1122
1206
|
|
|
@@ -1688,7 +1772,26 @@ window.__ModuleLoader__.load({
|
|
|
1688
1772
|
pruneSeenState();
|
|
1689
1773
|
saveSeenState();
|
|
1690
1774
|
}, [list]);
|
|
1691
|
-
|
|
1775
|
+
// What the page says about the screen, refreshed on the two moments that can
|
|
1776
|
+
// change it without the session list changing: a global panel replacing the
|
|
1777
|
+
// conversation (its own standard prop, the same one the shipped sidebar reads)
|
|
1778
|
+
// and the tab going to the background. Both are read here, not inferred, so
|
|
1779
|
+
// "you are looking at it" stops being a guess about stale retention.
|
|
1780
|
+
// Pausing signals, NOT leaving signals: they stop the stay clock without
|
|
1781
|
+
// touching which session this page is on (so the entered-at snapshot
|
|
1782
|
+
// survives). Only switching to another session counts as leaving.
|
|
1783
|
+
const panelActive = typeof props.usePanelInfo === "function"
|
|
1784
|
+
? props.usePanelInfo((info) => info !== null && typeof info === "object" && info.activePanelId !== null)
|
|
1785
|
+
: false;
|
|
1786
|
+
const [visible, setVisible] = React.useState(documentVisible);
|
|
1787
|
+
React.useEffect(() => {
|
|
1788
|
+
const onVisibility = () => setVisible(documentVisible());
|
|
1789
|
+
window.addEventListener("visibilitychange", onVisibility);
|
|
1790
|
+
return () => window.removeEventListener("visibilitychange", onVisibility);
|
|
1791
|
+
}, []);
|
|
1792
|
+
/** Looking at the conversation right now: on screen, and this tab in front. */
|
|
1793
|
+
const watching = visible && panelActive !== true;
|
|
1794
|
+
const currentIds = currentSessionIds(list, { displayed: displayedSessionId() });
|
|
1692
1795
|
const items = collectUnread(list, pending, config, seenState.seen);
|
|
1693
1796
|
const count = items.length;
|
|
1694
1797
|
// Being in a session is what marks it read — and the clock that does it is
|
|
@@ -1725,8 +1828,10 @@ window.__ModuleLoader__.load({
|
|
|
1725
1828
|
/**
|
|
1726
1829
|
* Entered-at snapshot: `id → endAt` as it stood when that session joined
|
|
1727
1830
|
* the view. Deliberately NOT refreshed while the session stays — that is
|
|
1728
|
-
* the rule ("an ending during your stay waits for the next entry").
|
|
1729
|
-
* session that
|
|
1831
|
+
* the rule ("an ending during your stay waits for the next entry"). Only a
|
|
1832
|
+
* session that LEAVES the view is forgotten, so coming back to it takes a
|
|
1833
|
+
* fresh snapshot; pausing (background tab, a global panel) keeps both the
|
|
1834
|
+
* session and its snapshot.
|
|
1730
1835
|
*/
|
|
1731
1836
|
const stayArmed = React.useRef(new Map());
|
|
1732
1837
|
React.useEffect(() => {
|
|
@@ -1738,7 +1843,10 @@ window.__ModuleLoader__.load({
|
|
|
1738
1843
|
for (const id of stayIds) {
|
|
1739
1844
|
if (!stayArmed.current.has(id)) stayArmed.current.set(id, lastTurnEndAt(snapshot[id]));
|
|
1740
1845
|
}
|
|
1741
|
-
|
|
1846
|
+
// Paused: nothing may be acknowledged while nobody is watching, but the
|
|
1847
|
+
// snapshots above stay put — that is what makes resuming continue the
|
|
1848
|
+
// same entry instead of silently re-entering it.
|
|
1849
|
+
if (staySignature === "" || watching !== true) return undefined;
|
|
1742
1850
|
const armed = stayIds.map((id) => ({ id, endAt: stayArmed.current.get(id) }));
|
|
1743
1851
|
let cancelled = false;
|
|
1744
1852
|
let timer = 0;
|
|
@@ -1759,7 +1867,7 @@ window.__ModuleLoader__.load({
|
|
|
1759
1867
|
cancelled = true;
|
|
1760
1868
|
if (timer !== 0) window.clearTimeout(timer);
|
|
1761
1869
|
};
|
|
1762
|
-
}, [staySignature, delayMs]);
|
|
1870
|
+
}, [staySignature, delayMs, watching]);
|
|
1763
1871
|
React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
|
|
1764
1872
|
// Map session → its Workspace from the official snapshot: the ID anchors the
|
|
1765
1873
|
// folder dot to the keyed row, the title is what the row displays (not always
|
|
@@ -2741,15 +2849,15 @@ window.__ModuleLoader__.load({
|
|
|
2741
2849
|
// this effect keeps the tab icon in sync. Teardown restores the
|
|
2742
2850
|
// pristine platform/custom icon.
|
|
2743
2851
|
ctx.effect(() => {
|
|
2744
|
-
const unsubscribe = subscribeBadge((snapshot) => {
|
|
2852
|
+
const unsubscribe = subscribeBadge((snapshot) => { composeFavicon(snapshot.count, snapshot.size); });
|
|
2745
2853
|
// Level-triggered on purpose: (re)applying this half must adopt the
|
|
2746
2854
|
// value that is already on the bridge. The brand mark reads it at
|
|
2747
2855
|
// mount; the tab icon has to be told, or a plugin hot-reload would
|
|
2748
2856
|
// leave it restored (the old effect's cleanup) until the next change.
|
|
2749
|
-
|
|
2857
|
+
composeFavicon(effectiveCount(), badgeSize);
|
|
2750
2858
|
return () => {
|
|
2751
2859
|
unsubscribe();
|
|
2752
|
-
|
|
2860
|
+
composeFavicon(0);
|
|
2753
2861
|
};
|
|
2754
2862
|
}, "dsh-icon-custom: favicon badge");
|
|
2755
2863
|
// Watchdog. The tab icon is the one surface other parties can disturb
|
|
@@ -2826,7 +2934,7 @@ window.__ModuleLoader__.load({
|
|
|
2826
2934
|
}, "dsh-icon-custom: workspace dots");
|
|
2827
2935
|
// The platform favicon.svg repaints itself for dark mode through
|
|
2828
2936
|
// prefers-color-scheme; a composited bitmap cannot, so rebuild it.
|
|
2829
|
-
ctx.on("theme/change", () => {
|
|
2937
|
+
ctx.on("theme/change", () => { composeFavicon(effectiveCount(), badgeSize); });
|
|
2830
2938
|
}
|
|
2831
2939
|
exports.apply = apply;
|
|
2832
2940
|
exports.inject = inject;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cowwo/dsh-icon-custom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标,并可把未读数字显示为系统应用图标角标(Windows 任务栏)。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets, and projects the unread count onto the system app-icon badge (Windows taskbar).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|