@cowwo/dsh-icon-custom 0.9.2 → 0.10.1
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 +2 -1
- package/client/client.js +163 -31
- package/lib/index.js +3 -3
- package/lib/typert.host.js +7 -4
- package/lib/unread.js +27 -3
- package/package.json +1 -1
package/README.zh.md
CHANGED
|
@@ -59,7 +59,8 @@ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提
|
|
|
59
59
|
|
|
60
60
|
- 数字 = **几个会话**在等你(不是几件事);同一个会话只占一格。
|
|
61
61
|
- 只有子代理会话不计。**你正开着的那个会话也算**——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分;要清掉它,切走再切回来即可。
|
|
62
|
-
- **已读记在每个浏览器自己身上**(localStorage)
|
|
62
|
+
- **已读记在每个浏览器自己身上**(localStorage):进过 = 处理过,刷新/重启不会"复活";装插件之前的历史结束也不算。
|
|
63
|
+
- **可以要求"待够才算已读"**:设置页填一个秒数(默认 0)。填 0 = 进去就算已读(红点立即消失);填 N = 你要在那个会话里**连续待满 N 秒**它才算看过——中途切走、或它又跑完一轮,都清零重数,刷新页面也重新数。上限 600 秒。
|
|
63
64
|
- 数据全部来自官方:`turn/end` 事件经官方 session projection 折叠,加上官方的"等待交互"注册表——**不依赖任何其他插件**。
|
|
64
65
|
- 设置页的**数据来源**可以切到"手动测试",手填数字试看效果。
|
|
65
66
|
- **工作区和会话行上的红点(实验)**:侧栏每个**工作区**那一行的文件夹图标右上角一个小红点,每个**有未读的会话**在标题右边跟一个小红点——一眼看出"事情在哪个工作区的哪个会话"。这一项是**贴着页面结构**做的——DSH 大版本升级后**可能失效**,失效时只会变成"不显示",**不影响**红点数字、清单和跳转;设置页里有一个开关可以随时关掉它。
|
package/client/client.js
CHANGED
|
@@ -62,7 +62,10 @@ window.__ModuleLoader__.load({
|
|
|
62
62
|
unreadPanelOther: "其他",
|
|
63
63
|
workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
|
|
64
64
|
workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
|
|
65
|
-
badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。"
|
|
65
|
+
badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
|
|
66
|
+
clearDelayLabel: "进入会话后多久算已读:",
|
|
67
|
+
clearDelayUnit: "秒",
|
|
68
|
+
clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走、或它又跑完一轮,都会清零重数;刷新页面也重新数。上限 600 秒。"
|
|
66
69
|
};
|
|
67
70
|
const en = {
|
|
68
71
|
nav: "Favicon",
|
|
@@ -117,7 +120,10 @@ window.__ModuleLoader__.load({
|
|
|
117
120
|
unreadPanelOther: "Other",
|
|
118
121
|
workspaceDotLabel: "Dots on workspace and session rows (experimental)",
|
|
119
122
|
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.",
|
|
120
|
-
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."
|
|
123
|
+
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.",
|
|
124
|
+
clearDelayLabel: "Mark read after staying:",
|
|
125
|
+
clearDelayUnit: "seconds",
|
|
126
|
+
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, or a fresh ending in it, resets the clock, and so does a reload. Capped at 600 seconds."
|
|
121
127
|
};
|
|
122
128
|
//#endregion
|
|
123
129
|
|
|
@@ -231,8 +237,18 @@ window.__ModuleLoader__.load({
|
|
|
231
237
|
};
|
|
232
238
|
/** Mirror of the host defaults; only used before the first status arrives. */
|
|
233
239
|
const UNREAD_FALLBACK_REASONS = { completed: true, error: true, blocked: true, "max-tokens": true, interrupted: true, "aborted:user": false, "aborted:other": true };
|
|
234
|
-
|
|
240
|
+
/** Mirror of the host's cap (see lib/unread.js) — 10 minutes. */
|
|
241
|
+
const UNREAD_CLEAR_DELAY_MAX_SEC = 600;
|
|
242
|
+
let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, clearDelaySec: 0 };
|
|
235
243
|
const unreadConfigListeners = new Set();
|
|
244
|
+
/** Mirror of the host's normalizer: any input becomes an integer 0..cap seconds. */
|
|
245
|
+
function normalizeClearDelay(input) {
|
|
246
|
+
const value = typeof input === "string" && input.trim() !== "" ? Number(input) : input;
|
|
247
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
|
248
|
+
const seconds = Math.floor(value);
|
|
249
|
+
if (seconds <= 0) return 0;
|
|
250
|
+
return Math.min(seconds, UNREAD_CLEAR_DELAY_MAX_SEC);
|
|
251
|
+
}
|
|
236
252
|
function normalizeUnreadConfig(input) {
|
|
237
253
|
const source = input !== null && typeof input === "object" ? input : {};
|
|
238
254
|
const raw = source.reasons !== null && typeof source.reasons === "object" ? source.reasons : {};
|
|
@@ -243,7 +259,8 @@ window.__ModuleLoader__.load({
|
|
|
243
259
|
return {
|
|
244
260
|
reasons,
|
|
245
261
|
pending: typeof source.pending === "boolean" ? source.pending : true,
|
|
246
|
-
workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true
|
|
262
|
+
workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true,
|
|
263
|
+
clearDelaySec: normalizeClearDelay(source.clearDelaySec)
|
|
247
264
|
};
|
|
248
265
|
}
|
|
249
266
|
function emitUnreadConfig(input) {
|
|
@@ -326,6 +343,29 @@ window.__ModuleLoader__.load({
|
|
|
326
343
|
}
|
|
327
344
|
return undefined;
|
|
328
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* What the "stay here long enough" timer is armed for, or "" when nothing
|
|
348
|
+
* should be armed.
|
|
349
|
+
*
|
|
350
|
+
* Keyed on the session's own `lastTurnEnd.endAt`, deliberately NOT on the
|
|
351
|
+
* collected item's `at`: a "waiting for you" item carries `at: Date.now()`,
|
|
352
|
+
* which changes on every evaluation — keying on that would tear the timer
|
|
353
|
+
* down and re-arm it forever, and a waiting session could never be marked
|
|
354
|
+
* read. Including `endAt` is also what makes a NEW ending restart the clock
|
|
355
|
+
* instead of inheriting the previous one's progress.
|
|
356
|
+
* @param sessionId - the session being viewed.
|
|
357
|
+
* @param list - the session list snapshot.
|
|
358
|
+
* @returns the key, or "" when there is no session to arm for.
|
|
359
|
+
*/
|
|
360
|
+
function stayKeyFor(sessionId, list) {
|
|
361
|
+
if (typeof sessionId !== "string" || sessionId === "") return "";
|
|
362
|
+
const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
|
|
363
|
+
const entry = byId[sessionId];
|
|
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}`;
|
|
368
|
+
}
|
|
329
369
|
/**
|
|
330
370
|
* How many sessions deserve the badge right now: endings whose reason is
|
|
331
371
|
* enabled and that this browser has not seen since, plus sessions waiting
|
|
@@ -991,13 +1031,26 @@ window.__ModuleLoader__.load({
|
|
|
991
1031
|
pruneSeenState();
|
|
992
1032
|
saveSeenState();
|
|
993
1033
|
}, [list]);
|
|
994
|
-
// Looking at a session marks it seen; the count also excludes it outright.
|
|
995
1034
|
const currentId = currentSessionId(list);
|
|
996
|
-
React.useEffect(() => {
|
|
997
|
-
if (noteSeen(currentId)) saveSeenState();
|
|
998
|
-
}, [currentId]);
|
|
999
1035
|
const items = collectUnread(list, pending, config, seenState.seen);
|
|
1000
1036
|
const count = items.length;
|
|
1037
|
+
// Being in a session is what marks it read — but only once you have STAYED
|
|
1038
|
+
// `clearDelaySec` seconds (0 = the moment you enter, the original
|
|
1039
|
+
// behaviour). The timer is torn down whenever you leave, or whenever that
|
|
1040
|
+
// session becomes unread again from a fresh ending, so the clock restarts;
|
|
1041
|
+
// that teardown IS the "continuous stay, leaving resets it" rule. A reload
|
|
1042
|
+
// restarts it too, since the timer only lives in this page.
|
|
1043
|
+
const currentUnread = items.some((item) => item.id === currentId);
|
|
1044
|
+
const stayKey = currentUnread ? stayKeyFor(currentId, list) : "";
|
|
1045
|
+
const delayMs = config.clearDelaySec * 1000;
|
|
1046
|
+
const [, bumpStay] = React.useState(0);
|
|
1047
|
+
React.useEffect(() => {
|
|
1048
|
+
if (stayKey === "") return undefined;
|
|
1049
|
+
const markRead = () => { if (noteSeen(currentId)) { saveSeenState(); bumpStay((n) => n + 1); } };
|
|
1050
|
+
if (delayMs <= 0) { markRead(); return undefined; }
|
|
1051
|
+
const timer = window.setTimeout(markRead, delayMs);
|
|
1052
|
+
return () => window.clearTimeout(timer);
|
|
1053
|
+
}, [stayKey, currentId, delayMs]);
|
|
1001
1054
|
React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
|
|
1002
1055
|
// The workspace rows are labelled with the workspace TITLE, which is not
|
|
1003
1056
|
// always the cwd basename, so map session → title from the official snapshot.
|
|
@@ -1176,7 +1229,8 @@ window.__ModuleLoader__.load({
|
|
|
1176
1229
|
event.preventDefault();
|
|
1177
1230
|
event.stopPropagation();
|
|
1178
1231
|
const rect = node.getBoundingClientRect();
|
|
1179
|
-
|
|
1232
|
+
// `bottom` is what the panel drops below; see unreadPanelPlacement.
|
|
1233
|
+
toggleUnreadPanel({ left: rect.left, top: rect.top, bottom: rect.bottom });
|
|
1180
1234
|
});
|
|
1181
1235
|
}
|
|
1182
1236
|
const count = effectiveCount();
|
|
@@ -1231,6 +1285,52 @@ window.__ModuleLoader__.load({
|
|
|
1231
1285
|
headerBadgeHost = null;
|
|
1232
1286
|
headerBadgeHostPosition = "";
|
|
1233
1287
|
}
|
|
1288
|
+
/** Gap between the panel and whatever it is anchored to. */
|
|
1289
|
+
const UNREAD_PANEL_GAP = 6;
|
|
1290
|
+
/** Breathing room from the window edges. */
|
|
1291
|
+
const UNREAD_PANEL_MARGIN = 8;
|
|
1292
|
+
/** The panel's practical width (between its minWidth and maxWidth below). */
|
|
1293
|
+
const UNREAD_PANEL_WIDTH = 300;
|
|
1294
|
+
/** How tall the scrolling session list may get before it scrolls. */
|
|
1295
|
+
const UNREAD_PANEL_LIST_MAX = 320;
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Where the unread panel goes, in viewport coordinates.
|
|
1299
|
+
*
|
|
1300
|
+
* It opens DOWNWARD from the badge. The original math pinned the panel's
|
|
1301
|
+
* BOTTOM edge just above the anchor — correct while the trigger sat in the
|
|
1302
|
+
* sidebar footer, and wrong the moment that trigger moved to the section
|
|
1303
|
+
* header: the same formula then parked the panel's bottom near the top of the
|
|
1304
|
+
* window and grew the panel upward, off the top of the screen (the title and
|
|
1305
|
+
* the first workspace heading were what got cut off). Anchoring on the
|
|
1306
|
+
* anchor's `bottom` is what drops it below the badge instead.
|
|
1307
|
+
*
|
|
1308
|
+
* Horizontally it stays clear of the sidebar: the panel is a frame-wide
|
|
1309
|
+
* overlay, and covering the sidebar — and the very badge that opened it — was
|
|
1310
|
+
* the other half of the complaint.
|
|
1311
|
+
* @param anchor - the badge's viewport rect (`{left, top, bottom}`), or null.
|
|
1312
|
+
* @param sidebarRight - the browsing region's right edge, or null.
|
|
1313
|
+
* @param viewportWidth - window width.
|
|
1314
|
+
* @param viewportHeight - window height.
|
|
1315
|
+
* @returns `{ left, top, listMaxHeight }` in viewport pixels.
|
|
1316
|
+
*/
|
|
1317
|
+
function unreadPanelPlacement(anchor, sidebarRight, viewportWidth, viewportHeight) {
|
|
1318
|
+
let left = UNREAD_PANEL_MARGIN;
|
|
1319
|
+
let top = UNREAD_PANEL_MARGIN;
|
|
1320
|
+
if (anchor !== null && typeof anchor === "object") {
|
|
1321
|
+
if (typeof anchor.left === "number") left = anchor.left;
|
|
1322
|
+
const below = typeof anchor.bottom === "number" ? anchor.bottom : anchor.top;
|
|
1323
|
+
if (typeof below === "number") top = below + UNREAD_PANEL_GAP;
|
|
1324
|
+
}
|
|
1325
|
+
if (typeof sidebarRight === "number" && sidebarRight > 0) left = Math.max(left, sidebarRight + UNREAD_PANEL_GAP);
|
|
1326
|
+
left = Math.max(UNREAD_PANEL_MARGIN, Math.min(left, viewportWidth - UNREAD_PANEL_WIDTH - UNREAD_PANEL_MARGIN));
|
|
1327
|
+
top = Math.max(UNREAD_PANEL_MARGIN, top);
|
|
1328
|
+
// Never taller than what is left below the panel's own top, so a long list
|
|
1329
|
+
// scrolls inside the panel instead of pushing it off the bottom edge.
|
|
1330
|
+
const listMaxHeight = Math.max(120, Math.min(UNREAD_PANEL_LIST_MAX, viewportHeight - top - 72));
|
|
1331
|
+
return { left: Math.round(left), top: Math.round(top), listMaxHeight: Math.round(listMaxHeight) };
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1234
1334
|
/**
|
|
1235
1335
|
* The list behind that number, in the frame-wide overlay layer so it escapes
|
|
1236
1336
|
* the sidebar's clipping and scroll container. Clicking a row opens that
|
|
@@ -1259,22 +1359,26 @@ window.__ModuleLoader__.load({
|
|
|
1259
1359
|
if (!open) return null;
|
|
1260
1360
|
const t = props.t;
|
|
1261
1361
|
const anchor = unreadPanelAnchor;
|
|
1362
|
+
// Measured, not assumed: the panel has to clear whatever width the sidebar
|
|
1363
|
+
// currently has (it is resizable and collapsible).
|
|
1364
|
+
let sidebarRight = null;
|
|
1365
|
+
try {
|
|
1366
|
+
const region = workspaceDotContainer();
|
|
1367
|
+
if (region !== null) sidebarRight = region.getBoundingClientRect().right;
|
|
1368
|
+
} catch { /* measurement is best effort; the margin fallback still applies */ }
|
|
1369
|
+
const place = unreadPanelPlacement(anchor, sidebarRight, window.innerWidth, window.innerHeight);
|
|
1262
1370
|
const style = {
|
|
1263
|
-
position: "fixed", left: "
|
|
1371
|
+
position: "fixed", left: place.left + "px", top: place.top + "px", zIndex: 60, pointerEvents: "auto",
|
|
1264
1372
|
minWidth: "240px", maxWidth: "340px", padding: "8px", borderRadius: "12px",
|
|
1265
1373
|
border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-1)",
|
|
1266
1374
|
boxShadow: "0 10px 30px rgba(0,0,0,.18)", color: "var(--dsw-alias-label-primary)", fontSize: "12px"
|
|
1267
1375
|
};
|
|
1268
|
-
if (anchor !== null && typeof anchor === "object") {
|
|
1269
|
-
style.left = Math.max(8, Math.round(anchor.left)) + "px";
|
|
1270
|
-
style.bottom = Math.max(8, Math.round(window.innerHeight - anchor.top + 6)) + "px";
|
|
1271
|
-
}
|
|
1272
1376
|
const items = Array.isArray(snapshot.items) ? snapshot.items : [];
|
|
1273
1377
|
return React.createElement("div", { "data-icon-custom-unread": "1", style },
|
|
1274
1378
|
React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t("unreadPanelTitle")),
|
|
1275
1379
|
items.length === 0
|
|
1276
1380
|
? React.createElement("div", { style: { padding: "2px 6px 8px", opacity: 0.7 } }, t("unreadPanelEmpty"))
|
|
1277
|
-
: React.createElement("div", { style: { display: "flex", flexDirection: "column", maxHeight: "
|
|
1381
|
+
: React.createElement("div", { style: { display: "flex", flexDirection: "column", maxHeight: place.listMaxHeight + "px", overflowY: "auto" } },
|
|
1278
1382
|
groupUnreadByWorkspace(items).map((group) => React.createElement("div", { key: group.where === "" ? "\u0000none" : group.where },
|
|
1279
1383
|
React.createElement("div", { style: { padding: "7px 8px 3px", fontSize: "11px", fontWeight: 600, opacity: 0.55, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
|
|
1280
1384
|
group.where === "" ? t("unreadPanelOther") : group.where
|
|
@@ -1373,6 +1477,11 @@ window.__ModuleLoader__.load({
|
|
|
1373
1477
|
const [badgeSourceValue, setBadgeSourceValue] = React.useState(badgeSource);
|
|
1374
1478
|
const [unread, setUnread] = React.useState(unreadConfig);
|
|
1375
1479
|
React.useEffect(() => subscribeUnreadConfig(setUnread), []);
|
|
1480
|
+
// Draft text for the delay box: typing must not save on every keystroke,
|
|
1481
|
+
// and the box has to show the CLAMPED value once the save lands (type
|
|
1482
|
+
// 99999 and it settles on the 600 cap instead of lying about it).
|
|
1483
|
+
const [clearDelayDraft, setClearDelayDraft] = React.useState(String(unreadConfig.clearDelaySec));
|
|
1484
|
+
React.useEffect(() => { setClearDelayDraft(String(unread.clearDelaySec)); }, [unread.clearDelaySec]);
|
|
1376
1485
|
const inputRef = React.useRef(null);
|
|
1377
1486
|
|
|
1378
1487
|
const load = React.useCallback(async () => {
|
|
@@ -1430,28 +1539,35 @@ window.__ModuleLoader__.load({
|
|
|
1430
1539
|
setBadgeSourceValue(value);
|
|
1431
1540
|
emitBadgeSource(value);
|
|
1432
1541
|
}, []);
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
if (key === "pending") pending = checked;
|
|
1440
|
-
else reasons[key] = checked;
|
|
1441
|
-
const next = normalizeUnreadConfig({ reasons, pending, workspaceDot: current.workspaceDot });
|
|
1542
|
+
// One save path for the whole unread rule: the patch is merged onto the
|
|
1543
|
+
// current config, so every field is carried over by construction. Letting
|
|
1544
|
+
// each control build its own request is how a new field gets silently
|
|
1545
|
+
// dropped by whichever caller forgot it.
|
|
1546
|
+
const saveUnreadRule = React.useCallback((patch) => {
|
|
1547
|
+
const next = normalizeUnreadConfig({ ...unreadConfig, ...patch });
|
|
1442
1548
|
emitUnreadConfig(next);
|
|
1443
1549
|
rpc.call("/api", "iconCustom/setUnreadRule", {
|
|
1444
|
-
args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot } }
|
|
1550
|
+
args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, clearDelaySec: next.clearDelaySec } }
|
|
1445
1551
|
}).catch(() => {});
|
|
1446
1552
|
}, [rpc]);
|
|
1553
|
+
// Toggle one unread reason. Applies locally at once; the host keeps the
|
|
1554
|
+
// durable copy so the rule survives a restart and other browsers.
|
|
1555
|
+
const onUnreadToggle = React.useCallback((key, checked) => {
|
|
1556
|
+
if (key === "pending") { saveUnreadRule({ pending: checked }); return; }
|
|
1557
|
+
const reasons = Object.assign({}, unreadConfig.reasons);
|
|
1558
|
+
reasons[key] = checked;
|
|
1559
|
+
saveUnreadRule({ reasons });
|
|
1560
|
+
}, [saveUnreadRule]);
|
|
1447
1561
|
// The fuse for the one fragile piece (see the workspace-dots region).
|
|
1448
1562
|
const onWorkspaceDotToggle = React.useCallback((checked) => {
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1563
|
+
saveUnreadRule({ workspaceDot: checked });
|
|
1564
|
+
}, [saveUnreadRule]);
|
|
1565
|
+
// Seconds you must stay in a session before it counts as read; 0 = as soon
|
|
1566
|
+
// as you enter. Clamped by the shared normalizer, so a stray keystroke can
|
|
1567
|
+
// never persist an absurd delay.
|
|
1568
|
+
const onClearDelayChange = React.useCallback((value) => {
|
|
1569
|
+
saveUnreadRule({ clearDelaySec: normalizeClearDelay(value) });
|
|
1570
|
+
}, [saveUnreadRule]);
|
|
1455
1571
|
/** One unread checkbox; `key === "pending"` is the "someone is waiting" row. */
|
|
1456
1572
|
const renderReasonCheckbox = (key, labelKey) => React.createElement("label", {
|
|
1457
1573
|
key,
|
|
@@ -1687,6 +1803,22 @@ window.__ModuleLoader__.load({
|
|
|
1687
1803
|
renderReasonCheckbox("pending", "reasonPending")
|
|
1688
1804
|
)
|
|
1689
1805
|
),
|
|
1806
|
+
React.createElement("div", { style: { marginTop: "12px" } },
|
|
1807
|
+
React.createElement("div", { style: style.desc }, t("clearDelayLabel")),
|
|
1808
|
+
React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", marginTop: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)" } },
|
|
1809
|
+
React.createElement("input", {
|
|
1810
|
+
type: "number", min: 0, max: UNREAD_CLEAR_DELAY_MAX_SEC, step: 1, inputMode: "numeric",
|
|
1811
|
+
"aria-label": t("clearDelayLabel"),
|
|
1812
|
+
value: clearDelayDraft,
|
|
1813
|
+
onChange: (event) => setClearDelayDraft(event.target.value),
|
|
1814
|
+
onBlur: () => onClearDelayChange(clearDelayDraft),
|
|
1815
|
+
onKeyDown: (event) => { if (event.key === "Enter") onClearDelayChange(clearDelayDraft); },
|
|
1816
|
+
style: { width: "76px", padding: "3px 6px", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "6px", background: "transparent", color: "inherit", font: "inherit", fontSize: 12 }
|
|
1817
|
+
}),
|
|
1818
|
+
React.createElement("span", null, t("clearDelayUnit"))
|
|
1819
|
+
),
|
|
1820
|
+
React.createElement("div", { style: style.hint }, t("clearDelayHint"))
|
|
1821
|
+
),
|
|
1690
1822
|
React.createElement("div", { style: { marginTop: "10px" } },
|
|
1691
1823
|
React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
|
|
1692
1824
|
React.createElement("input", {
|
package/lib/index.js
CHANGED
|
@@ -462,14 +462,14 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
462
462
|
* @returns `{ reasons, pending }`.
|
|
463
463
|
*/
|
|
464
464
|
async getUnreadRule() {
|
|
465
|
-
return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot }
|
|
465
|
+
return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot, clearDelaySec: this.unread.clearDelaySec }
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
/**
|
|
469
469
|
* typert endpoint: replace the unread-badge rule — which turn-end reasons
|
|
470
470
|
* light the badge. The rule is applied on the client, so this takes effect
|
|
471
471
|
* without re-folding any session log.
|
|
472
|
-
* @param request - `{ reasons, pending? }`; unknown or missing keys fall back to defaults.
|
|
472
|
+
* @param request - `{ reasons, pending?, workspaceDot?, clearDelaySec? }`; unknown or missing keys fall back to defaults.
|
|
473
473
|
* @returns the stored rule.
|
|
474
474
|
*/
|
|
475
475
|
async setUnreadRule(request) {
|
|
@@ -478,7 +478,7 @@ 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 { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot }
|
|
481
|
+
return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot, clearDelaySec: this.unread.clearDelaySec }
|
|
482
482
|
}
|
|
483
483
|
|
|
484
484
|
/** typert endpoint: store a new icon uploaded as a data URL and make it active. */
|
package/lib/typert.host.js
CHANGED
|
@@ -52,13 +52,16 @@ const unreadRuleSchema = z.object({
|
|
|
52
52
|
reasons: z.record(z.string(), z.boolean()),
|
|
53
53
|
pending: z.boolean(),
|
|
54
54
|
/** 实验项:工作区图标上的红点(贴产品 DOM,可一键关闭)。 */
|
|
55
|
-
workspaceDot: z.boolean()
|
|
55
|
+
workspaceDot: z.boolean(),
|
|
56
|
+
/** 进入会话后要连续待满多少秒才算"看过";0 = 进入即已读。上限 10 分钟。 */
|
|
57
|
+
clearDelaySec: z.number().int().nonnegative().max(600)
|
|
56
58
|
})
|
|
57
59
|
|
|
58
60
|
const setUnreadRuleRequestSchema = z.object({
|
|
59
61
|
reasons: z.record(z.string(), z.boolean()),
|
|
60
62
|
pending: z.boolean().optional(),
|
|
61
|
-
workspaceDot: z.boolean().optional()
|
|
63
|
+
workspaceDot: z.boolean().optional(),
|
|
64
|
+
clearDelaySec: z.number().int().nonnegative().max(600).optional()
|
|
62
65
|
})
|
|
63
66
|
|
|
64
67
|
const setIconResultSchema = statusSchema
|
|
@@ -269,7 +272,7 @@ export const TYPERT = {
|
|
|
269
272
|
{
|
|
270
273
|
kind: 'method',
|
|
271
274
|
name: 'getUnreadRule',
|
|
272
|
-
signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean }>',
|
|
275
|
+
signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number }>',
|
|
273
276
|
description: "Read the unread-badge rule.",
|
|
274
277
|
summary: "Read the unread-badge rule.",
|
|
275
278
|
jsDoc: "/** Read the unread-badge rule. */",
|
|
@@ -278,7 +281,7 @@ export const TYPERT = {
|
|
|
278
281
|
{
|
|
279
282
|
kind: 'method',
|
|
280
283
|
name: 'setUnreadRule',
|
|
281
|
-
signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean }>',
|
|
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 }>',
|
|
282
285
|
description: "Replace the unread-badge rule: which turn-end reasons light the red dot.",
|
|
283
286
|
summary: "Set the unread-badge rule.",
|
|
284
287
|
jsDoc: "/** Set the unread-badge rule. */",
|
package/lib/unread.js
CHANGED
|
@@ -52,6 +52,29 @@ export const UNREAD_PENDING_DEFAULT = true
|
|
|
52
52
|
*/
|
|
53
53
|
export const UNREAD_WORKSPACE_DOT_DEFAULT = true
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* 默认:进入会话即视为已读(0 秒)。
|
|
57
|
+
* 大于 0 时,你必须在那个会话里**连续**待满这么多秒它才算"看过";中途切走会
|
|
58
|
+
* 清零重数。填 0 就是"不设置",行为跟没有这一项完全一样。
|
|
59
|
+
*/
|
|
60
|
+
export const UNREAD_CLEAR_DELAY_DEFAULT = 0
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 秒数上限:10 分钟。
|
|
64
|
+
* 挡住误填的大数(想填 5 却打成 50000)——那会让红点实际上永远清不掉,
|
|
65
|
+
* 而症状是"点进去也不消",很难联想到是这里填错了。
|
|
66
|
+
*/
|
|
67
|
+
export const UNREAD_CLEAR_DELAY_MAX_SEC = 600
|
|
68
|
+
|
|
69
|
+
/** 把任意输入夹成 `0..上限` 的整数秒(小数向下取整,脏值回落默认)。 */
|
|
70
|
+
export function normalizeClearDelay(input) {
|
|
71
|
+
const value = typeof input === 'string' && input.trim() !== '' ? Number(input) : input
|
|
72
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return UNREAD_CLEAR_DELAY_DEFAULT
|
|
73
|
+
const seconds = Math.floor(value)
|
|
74
|
+
if (seconds <= 0) return 0
|
|
75
|
+
return Math.min(seconds, UNREAD_CLEAR_DELAY_MAX_SEC)
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
/** 把任意输入夹成一张完整、只有布尔值的开关表(缺项取默认)。 */
|
|
56
79
|
export function normalizeReasons(input) {
|
|
57
80
|
const out = {}
|
|
@@ -64,15 +87,16 @@ export function normalizeReasons(input) {
|
|
|
64
87
|
|
|
65
88
|
/**
|
|
66
89
|
* 把任意输入夹成完整设置。
|
|
67
|
-
* @param input - `{ reasons?, pending? }`,可以是脏的、缺项的、null
|
|
68
|
-
* @returns `{ reasons, pending }` ——
|
|
90
|
+
* @param input - `{ reasons?, pending?, workspaceDot?, clearDelaySec? }`,可以是脏的、缺项的、null
|
|
91
|
+
* @returns `{ reasons, pending, workspaceDot, clearDelaySec }` —— 每一项都完整
|
|
69
92
|
*/
|
|
70
93
|
export function normalizeUnreadConfig(input) {
|
|
71
94
|
const source = input !== null && typeof input === 'object' ? input : {}
|
|
72
95
|
return {
|
|
73
96
|
reasons: normalizeReasons(source.reasons),
|
|
74
97
|
pending: typeof source.pending === 'boolean' ? source.pending : UNREAD_PENDING_DEFAULT,
|
|
75
|
-
workspaceDot: typeof source.workspaceDot === 'boolean' ? source.workspaceDot : UNREAD_WORKSPACE_DOT_DEFAULT
|
|
98
|
+
workspaceDot: typeof source.workspaceDot === 'boolean' ? source.workspaceDot : UNREAD_WORKSPACE_DOT_DEFAULT,
|
|
99
|
+
clearDelaySec: normalizeClearDelay(source.clearDelaySec)
|
|
76
100
|
}
|
|
77
101
|
}
|
|
78
102
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cowwo/dsh-icon-custom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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",
|