From f9b8e40e2cc0ffeef6c9a5e3daa244d44663b7b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 15:00:31 +0000 Subject: [PATCH] Finish session architecture with heartbeat, routing, and tap-to-resend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract heartbeat, payload routing, and status derivation from ChatSession so it only orchestrates. Outbound keeps payloads for late acks and resend, presence prunes silent peers, and the room cache drops in-flight pending markers. RoomManager no longer leaks the session. Failed self messages show as 发送中/未送达 and can be tapped to retry over the same v1 wire. Co-authored-by: Andy --- 404.html | 4 +- README.md | 26 +- ...{index-Bou1xdtq.css => index-9O3FLWNJ.css} | 2 +- assets/index-Ckc1AzqL.js | 3 - assets/index-DfhOSYdI.js | 3 + index.html | 4 +- src/config/app.ts | 1 + src/core/cache.test.ts | 12 + src/core/cache.ts | 19 +- src/core/heartbeat.test.ts | 36 +++ src/core/heartbeat.ts | 58 ++++ src/core/outbound.test.ts | 13 +- src/core/outbound.ts | 27 +- src/core/payload-router.test.ts | 24 ++ src/core/payload-router.ts | 23 ++ src/core/presence.test.ts | 11 + src/core/presence.ts | 7 + src/core/room-manager.test.ts | 21 +- src/core/room-manager.ts | 24 +- src/core/session.test.ts | 47 ++++ src/core/session.ts | 266 ++++++++---------- src/core/status.test.ts | 39 ++- src/core/status.ts | 40 +++ src/core/transcript.test.ts | 14 +- src/core/transcript.ts | 16 +- src/ui/app.ts | 1 + src/ui/chat-pane.ts | 6 +- src/ui/copy.ts | 2 + src/ui/log-view.ts | 31 +- src/ui/styles.css | 15 + 30 files changed, 581 insertions(+), 214 deletions(-) rename assets/{index-Bou1xdtq.css => index-9O3FLWNJ.css} (69%) delete mode 100644 assets/index-Ckc1AzqL.js create mode 100644 assets/index-DfhOSYdI.js create mode 100644 src/core/heartbeat.test.ts create mode 100644 src/core/heartbeat.ts create mode 100644 src/core/payload-router.test.ts create mode 100644 src/core/payload-router.ts diff --git a/404.html b/404.html index 50af4e6..61d7008 100644 --- a/404.html +++ b/404.html @@ -19,8 +19,8 @@ 禅聊 ZenChat - - + +
diff --git a/README.md b/README.md index 84e0547..a1110cc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ flowchart LR RM[RoomManager] CS[ChatSession] SM[SessionMachine] + HB[Heartbeat] + PR[PayloadRouter] OB[Outbound] P[Presence] L[Transcript] @@ -34,10 +36,12 @@ flowchart LR end UI --> RM --> CS - CS --> SM[SessionMachine] - CS --> OB[Outbound] - CS --> P[Presence] - CS --> L[Transcript] + CS --> SM + CS --> HB + CS --> PR + CS --> OB + CS --> P + CS --> L CS --> TR TR -->|"SDP / ICE (signalling only)"| WT @@ -48,12 +52,14 @@ flowchart LR | Layer / 层 | Role / 职责 | |---|---| -| UI Shell | Lobby, tabs, theme. Chat pane is a separate view; shell does not remount on switch. | -| RoomManager | Room lifecycle, log cache, skip reconnect if the same room is already joined. | -| ChatSession | Thin orchestrator: transport in, machine + outbound + presence + transcript out. | -| SessionMachine | Pure join/wait/live/error transitions. | -| Outbound | Delivery timers. Peers that advertise `ack` in hello get a receipt. | -| Presence / Transcript | Members + typing TTL; capped message log and dedupe ids. | +| UI Shell | Lobby, tabs, theme. Chat pane is a separate view; shell does not remount on switch. Failed self lines are tappable to resend. | +| RoomManager | Room lifecycle, durable log cache, skip reconnect if the same room is already joined. UI never reaches into `ChatSession`. | +| ChatSession | Thin orchestrator: transport in, machine + heartbeat + router + outbound + presence + transcript out. | +| SessionMachine | Pure join/wait/live/error transitions. Status copy is derived from the machine. | +| Heartbeat | Hello interval, relay/RTT poll, visibility pause, stale presence prune. | +| PayloadRouter | Decode v1 hello/typing/ack/chat; session only applies effects. | +| Outbound | Delivery timers plus the original payload so a late ack or tap-to-resend can settle. Peers that advertise `ack` in hello get a receipt. | +| Presence / Transcript | Members + typing TTL + last-seen prune; capped message log, dedupe ids, durable hydrate (drop pending, keep failed). | | Transport factory | Default Trystero torrent/nostr. Tests inject a fake. A `Libp2pTransport` can plug in here. | | DataChannel | Encrypted chat after ICE succeeds. Trackers never see plaintext. | diff --git a/assets/index-Bou1xdtq.css b/assets/index-9O3FLWNJ.css similarity index 69% rename from assets/index-Bou1xdtq.css rename to assets/index-9O3FLWNJ.css index ca82f56..198ce3d 100644 --- a/assets/index-Bou1xdtq.css +++ b/assets/index-9O3FLWNJ.css @@ -1 +1 @@ -:root,[data-theme=light]{--bg: #faf9f5;--grouped: #f3eee4;--fg: #1a1612;--muted: #6f665c;--hair: #e4d9c8;--accent: #d97757;--accent-deep: #c15f3c;--on-accent: #faf6f0;--danger: #9f1239;--ok: #4f8a6a;--fill: rgba(44, 28, 16, .06);--shadow: 0 18px 48px rgba(48, 28, 12, .1);--glow-1: color-mix(in srgb, var(--accent) 22%, transparent);--glow-2: color-mix(in srgb, #e8b86a 18%, transparent)}[data-theme=dark]{--bg: #161412;--grouped: #211e1a;--fg: #f4f0e8;--muted: #a89f94;--hair: #3a332b;--accent: #e39472;--accent-deep: #d97757;--on-accent: #1a1410;--danger: #fb7185;--ok: #7dba8e;--fill: rgba(255, 236, 214, .07);--shadow: 0 18px 48px rgba(0, 0, 0, .4);--glow-1: color-mix(in srgb, var(--accent) 18%, transparent);--glow-2: color-mix(in srgb, #c96442 12%, transparent)}*{box-sizing:border-box}html,body,#app{height:var(--app-h, 100dvh);margin:0;overflow:hidden}body{font:16px/1.5 "Source Serif 4",Iowan Old Style,Palatino Linotype,Songti SC,"Noto Serif SC",PingFang SC,serif;background:radial-gradient(1100px 460px at 8% -12%,var(--glow-1),transparent 54%),radial-gradient(820px 380px at 108% -4%,var(--glow-2),transparent 50%),var(--bg);color:var(--fg);letter-spacing:.01em;-webkit-font-smoothing:antialiased;overscroll-behavior:none}::selection{background:color-mix(in srgb,var(--accent) 34%,transparent)}input,select,textarea,.tab,.btn,.status,.member,.footer,.hint,.field span{font-family:IBM Plex Sans,"Source Sans 3",-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Noto Sans SC,sans-serif}#app{display:flex}.shell{flex:1;min-height:0;width:min(920px,100%);margin:0 auto;padding:0 16px calc(10px + env(safe-area-inset-bottom,0px));padding-top:env(safe-area-inset-top,0px);display:flex;flex-direction:column}.topbar{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:8px;height:52px;flex:0 0 auto}.brand{display:flex;align-items:center;gap:9px;text-decoration:none;color:inherit}.enso{width:18px;height:18px;border:2px solid var(--accent);border-radius:50%;border-right-color:transparent;transform:rotate(-18deg);flex:0 0 auto}.brand strong{font-size:1.22rem;font-weight:600;letter-spacing:.04em}.tagline{color:var(--muted);font-size:12px;font-family:IBM Plex Sans,"Source Sans 3",-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,sans-serif;white-space:nowrap}.mode-chat .tagline{display:none}.tabs{display:flex;gap:2px;overflow-x:auto;min-width:0;scrollbar-width:none}.tabs::-webkit-scrollbar{display:none}.tab,.btn{font:inherit;color:inherit;background:transparent;border:0;cursor:pointer;touch-action:manipulation}.tab{flex:0 0 auto;padding:6px 10px;border-radius:999px;color:var(--muted);text-decoration:none;font-size:13px}.tab.on{color:var(--accent-deep);background:color-mix(in srgb,var(--accent) 14%,transparent)}.tab.add{width:32px;padding:6px 0}.toolbar{display:flex;gap:2px}.btn{padding:6px 11px;border-radius:999px;color:var(--muted);font-size:13px}.btn.ghost:hover,.tab:hover{background:var(--fill);color:var(--fg)}.btn.primary,.btn.send{background:var(--accent);color:var(--on-accent);border:1px solid transparent}.btn.primary:hover,.btn.send:hover{background:var(--accent-deep);color:var(--on-accent)}.btn.send{width:32px;height:32px;padding:0;border-radius:50%;font-size:16px;line-height:32px}.btn:focus-visible,.tab:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid color-mix(in srgb,var(--accent) 70%,transparent);outline-offset:2px}.main{flex:1;min-height:0;display:flex;flex-direction:column}.lobby{display:grid;gap:12px;align-content:start;min-height:0;overflow:auto}.lede{margin:4px 0 2px;max-width:36em;color:var(--muted);font-size:15px;line-height:1.65}.panel{background:color-mix(in srgb,var(--grouped) 78%,var(--bg));border:1px solid var(--hair);border-radius:20px;padding:16px;box-shadow:var(--shadow)}.lobby-form{display:grid;gap:10px}.join-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;align-items:end}.join-btn{min-height:40px;padding:0 18px}.row{display:grid;grid-template-columns:1fr 1fr;gap:8px}.field{display:grid;gap:4px}.field span,.hint,.muted,.footer,.status,.system{color:var(--muted);font-size:13px}.field input,.field select,.composer textarea,.jump input,.jump select{width:100%;font:inherit;font-size:16px;color:inherit;background:color-mix(in srgb,var(--bg) 80%,white);border:1px solid var(--hair);border-radius:12px;padding:8px 10px}[data-theme=dark] .field input,[data-theme=dark] .field select,[data-theme=dark] .composer textarea,[data-theme=dark] .jump input,[data-theme=dark] .jump select{background:color-mix(in srgb,var(--grouped) 70%,#000)}.more{color:var(--muted);font-size:13px}.more summary{cursor:pointer;padding:4px 0}.actions{display:flex;gap:8px}.chat{position:relative;flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 200px;gap:12px}.transcript{display:flex;flex-direction:column;min-height:0;min-width:0}.status-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:8px;min-height:28px}.status-actions{display:flex;gap:4px}.btn.compact{padding:4px 8px;font-size:12px}.btn:disabled{opacity:.45;cursor:not-allowed}.vacant{margin:24px auto;max-width:22em;text-align:center;color:var(--muted);font-size:14px;line-height:1.6}.vacant[hidden]{display:none!important}.lock{margin-left:4px;font-size:10px;color:var(--accent-deep)}.tips{margin:0 0 4px;padding-left:1.2em;color:var(--muted);font-size:13px;line-height:1.65}.banner.warn{color:var(--accent-deep)}.dot.err{background:var(--danger)}.nick-label{display:grid;gap:2px;min-width:0}.nick-edit{width:100%;font:inherit;font-size:14px;color:inherit;background:transparent;border:0;border-bottom:1px solid var(--hair);padding:2px 0}.status{display:flex;align-items:center;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.log{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:8px;padding:4px 0 8px;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}.bubble{max-width:min(78%,36em);padding:8px 12px;border-radius:16px 16px 16px 4px;background:var(--grouped);border:1px solid color-mix(in srgb,var(--hair) 70%,transparent)}.bubble.self{align-self:flex-end;border-radius:16px 16px 4px;background:var(--accent);color:var(--on-accent);border-color:transparent}.bubble.self .delivery{color:color-mix(in srgb,var(--on-accent) 74%,transparent);font-size:11px}.bubble.self header,.bubble.self .muted{color:color-mix(in srgb,var(--on-accent) 74%,transparent)}.bubble.self .delivery.fail,.bubble.failed{opacity:.86}.bubble header{display:flex;gap:8px;font-size:11px;color:var(--muted)}.bubble p{margin:2px 0 0;white-space:pre-wrap;word-break:break-word}.bubble.stamp p{font-size:22px;line-height:1.3}.system{align-self:center}.composer{display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:end;padding:8px 0 4px;background:transparent}.composer textarea{resize:none;min-height:36px;max-height:96px;line-height:1.3;border-radius:18px;padding:8px 14px}.dot{width:6px;height:6px;border-radius:50%;background:var(--muted);display:inline-block;margin-right:6px;flex:0 0 auto}.dot.ok{background:var(--ok)}.side{min-height:0;overflow:auto;padding:8px 4px}.side h2{margin:0 0 8px;font-size:12px;font-weight:600;color:var(--muted);letter-spacing:.06em}.member{display:flex;justify-content:space-between;gap:8px;padding:8px 0;border-bottom:1px solid var(--hair);font-size:14px}.banner{color:var(--danger);font-size:13px;padding:8px 0}.footer{margin:0;padding:8px 0 0}.footer a{color:inherit}.picker{position:absolute;left:12px;right:12px;bottom:56px;background:var(--grouped);border:1px solid var(--hair);border-radius:16px;padding:10px;z-index:3;box-shadow:var(--shadow)}.picker-tabs{display:flex;gap:4px;margin-bottom:8px}.picker-grid{display:flex;flex-wrap:wrap;gap:6px;max-height:140px;overflow:auto}.stamp-btn{font:inherit;color:inherit;background:var(--bg);border:1px solid var(--hair);border-radius:10px;padding:6px 8px;cursor:pointer}.stamp-btn:hover{border-color:var(--accent)}.emoji .stamp-btn{font-size:18px}.hidden{display:none!important}.jump{border:1px solid var(--hair);border-radius:20px;background:var(--grouped);color:inherit;padding:0;width:min(400px,calc(100% - 32px));box-shadow:var(--shadow)}.jump::backdrop{background:#1c120a6b}.jump-form{display:grid;gap:10px;padding:18px}.jump h2{margin:0;font-size:1.15rem;font-family:"Source Serif 4",Iowan Old Style,Palatino Linotype,Songti SC,serif}@media(max-width:720px){.tagline{display:none}.join-row,.row{grid-template-columns:1fr}.join-btn{width:100%;min-height:44px}.chat{grid-template-columns:1fr}.side{display:none;position:absolute;top:36px;right:0;width:min(240px,80vw);max-height:50%;background:var(--grouped);border:1px solid var(--hair);border-radius:16px;padding:10px 12px;z-index:2;box-shadow:var(--shadow)}.chat.show-side .side{display:block}.topbar{grid-template-columns:auto 1fr auto}} +:root,[data-theme=light]{--bg: #faf9f5;--grouped: #f3eee4;--fg: #1a1612;--muted: #6f665c;--hair: #e4d9c8;--accent: #d97757;--accent-deep: #c15f3c;--on-accent: #faf6f0;--danger: #9f1239;--ok: #4f8a6a;--fill: rgba(44, 28, 16, .06);--shadow: 0 18px 48px rgba(48, 28, 12, .1);--glow-1: color-mix(in srgb, var(--accent) 22%, transparent);--glow-2: color-mix(in srgb, #e8b86a 18%, transparent)}[data-theme=dark]{--bg: #161412;--grouped: #211e1a;--fg: #f4f0e8;--muted: #a89f94;--hair: #3a332b;--accent: #e39472;--accent-deep: #d97757;--on-accent: #1a1410;--danger: #fb7185;--ok: #7dba8e;--fill: rgba(255, 236, 214, .07);--shadow: 0 18px 48px rgba(0, 0, 0, .4);--glow-1: color-mix(in srgb, var(--accent) 18%, transparent);--glow-2: color-mix(in srgb, #c96442 12%, transparent)}*{box-sizing:border-box}html,body,#app{height:var(--app-h, 100dvh);margin:0;overflow:hidden}body{font:16px/1.5 "Source Serif 4",Iowan Old Style,Palatino Linotype,Songti SC,"Noto Serif SC",PingFang SC,serif;background:radial-gradient(1100px 460px at 8% -12%,var(--glow-1),transparent 54%),radial-gradient(820px 380px at 108% -4%,var(--glow-2),transparent 50%),var(--bg);color:var(--fg);letter-spacing:.01em;-webkit-font-smoothing:antialiased;overscroll-behavior:none}::selection{background:color-mix(in srgb,var(--accent) 34%,transparent)}input,select,textarea,.tab,.btn,.status,.member,.footer,.hint,.field span{font-family:IBM Plex Sans,"Source Sans 3",-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Noto Sans SC,sans-serif}#app{display:flex}.shell{flex:1;min-height:0;width:min(920px,100%);margin:0 auto;padding:0 16px calc(10px + env(safe-area-inset-bottom,0px));padding-top:env(safe-area-inset-top,0px);display:flex;flex-direction:column}.topbar{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:8px;height:52px;flex:0 0 auto}.brand{display:flex;align-items:center;gap:9px;text-decoration:none;color:inherit}.enso{width:18px;height:18px;border:2px solid var(--accent);border-radius:50%;border-right-color:transparent;transform:rotate(-18deg);flex:0 0 auto}.brand strong{font-size:1.22rem;font-weight:600;letter-spacing:.04em}.tagline{color:var(--muted);font-size:12px;font-family:IBM Plex Sans,"Source Sans 3",-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,sans-serif;white-space:nowrap}.mode-chat .tagline{display:none}.tabs{display:flex;gap:2px;overflow-x:auto;min-width:0;scrollbar-width:none}.tabs::-webkit-scrollbar{display:none}.tab,.btn{font:inherit;color:inherit;background:transparent;border:0;cursor:pointer;touch-action:manipulation}.tab{flex:0 0 auto;padding:6px 10px;border-radius:999px;color:var(--muted);text-decoration:none;font-size:13px}.tab.on{color:var(--accent-deep);background:color-mix(in srgb,var(--accent) 14%,transparent)}.tab.add{width:32px;padding:6px 0}.toolbar{display:flex;gap:2px}.btn{padding:6px 11px;border-radius:999px;color:var(--muted);font-size:13px}.btn.ghost:hover,.tab:hover{background:var(--fill);color:var(--fg)}.btn.primary,.btn.send{background:var(--accent);color:var(--on-accent);border:1px solid transparent}.btn.primary:hover,.btn.send:hover{background:var(--accent-deep);color:var(--on-accent)}.btn.send{width:32px;height:32px;padding:0;border-radius:50%;font-size:16px;line-height:32px}.btn:focus-visible,.tab:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid color-mix(in srgb,var(--accent) 70%,transparent);outline-offset:2px}.main{flex:1;min-height:0;display:flex;flex-direction:column}.lobby{display:grid;gap:12px;align-content:start;min-height:0;overflow:auto}.lede{margin:4px 0 2px;max-width:36em;color:var(--muted);font-size:15px;line-height:1.65}.panel{background:color-mix(in srgb,var(--grouped) 78%,var(--bg));border:1px solid var(--hair);border-radius:20px;padding:16px;box-shadow:var(--shadow)}.lobby-form{display:grid;gap:10px}.join-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;align-items:end}.join-btn{min-height:40px;padding:0 18px}.row{display:grid;grid-template-columns:1fr 1fr;gap:8px}.field{display:grid;gap:4px}.field span,.hint,.muted,.footer,.status,.system{color:var(--muted);font-size:13px}.field input,.field select,.composer textarea,.jump input,.jump select{width:100%;font:inherit;font-size:16px;color:inherit;background:color-mix(in srgb,var(--bg) 80%,white);border:1px solid var(--hair);border-radius:12px;padding:8px 10px}[data-theme=dark] .field input,[data-theme=dark] .field select,[data-theme=dark] .composer textarea,[data-theme=dark] .jump input,[data-theme=dark] .jump select{background:color-mix(in srgb,var(--grouped) 70%,#000)}.more{color:var(--muted);font-size:13px}.more summary{cursor:pointer;padding:4px 0}.actions{display:flex;gap:8px}.chat{position:relative;flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 200px;gap:12px}.transcript{display:flex;flex-direction:column;min-height:0;min-width:0}.status-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:8px;min-height:28px}.status-actions{display:flex;gap:4px}.btn.compact{padding:4px 8px;font-size:12px}.btn:disabled{opacity:.45;cursor:not-allowed}.vacant{margin:24px auto;max-width:22em;text-align:center;color:var(--muted);font-size:14px;line-height:1.6}.vacant[hidden]{display:none!important}.lock{margin-left:4px;font-size:10px;color:var(--accent-deep)}.tips{margin:0 0 4px;padding-left:1.2em;color:var(--muted);font-size:13px;line-height:1.65}.banner.warn{color:var(--accent-deep)}.dot.err{background:var(--danger)}.nick-label{display:grid;gap:2px;min-width:0}.nick-edit{width:100%;font:inherit;font-size:14px;color:inherit;background:transparent;border:0;border-bottom:1px solid var(--hair);padding:2px 0}.status{display:flex;align-items:center;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.log{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:8px;padding:4px 0 8px;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}.bubble{max-width:min(78%,36em);padding:8px 12px;border-radius:16px 16px 16px 4px;background:var(--grouped);border:1px solid color-mix(in srgb,var(--hair) 70%,transparent)}.bubble.self{align-self:flex-end;border-radius:16px 16px 4px;background:var(--accent);color:var(--on-accent);border-color:transparent}.bubble.self .delivery{color:color-mix(in srgb,var(--on-accent) 74%,transparent);font-size:11px}.bubble.self .delivery.pending{opacity:.85}.bubble.self button.delivery{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer;text-decoration:underline;text-underline-offset:2px}.bubble.self header,.bubble.self .muted{color:color-mix(in srgb,var(--on-accent) 74%,transparent)}.bubble.self .delivery.fail,.bubble.failed{opacity:.86}.bubble header{display:flex;gap:8px;font-size:11px;color:var(--muted)}.bubble p{margin:2px 0 0;white-space:pre-wrap;word-break:break-word}.bubble.stamp p{font-size:22px;line-height:1.3}.system{align-self:center}.composer{display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:end;padding:8px 0 4px;background:transparent}.composer textarea{resize:none;min-height:36px;max-height:96px;line-height:1.3;border-radius:18px;padding:8px 14px}.dot{width:6px;height:6px;border-radius:50%;background:var(--muted);display:inline-block;margin-right:6px;flex:0 0 auto}.dot.ok{background:var(--ok)}.side{min-height:0;overflow:auto;padding:8px 4px}.side h2{margin:0 0 8px;font-size:12px;font-weight:600;color:var(--muted);letter-spacing:.06em}.member{display:flex;justify-content:space-between;gap:8px;padding:8px 0;border-bottom:1px solid var(--hair);font-size:14px}.banner{color:var(--danger);font-size:13px;padding:8px 0}.footer{margin:0;padding:8px 0 0}.footer a{color:inherit}.picker{position:absolute;left:12px;right:12px;bottom:56px;background:var(--grouped);border:1px solid var(--hair);border-radius:16px;padding:10px;z-index:3;box-shadow:var(--shadow)}.picker-tabs{display:flex;gap:4px;margin-bottom:8px}.picker-grid{display:flex;flex-wrap:wrap;gap:6px;max-height:140px;overflow:auto}.stamp-btn{font:inherit;color:inherit;background:var(--bg);border:1px solid var(--hair);border-radius:10px;padding:6px 8px;cursor:pointer}.stamp-btn:hover{border-color:var(--accent)}.emoji .stamp-btn{font-size:18px}.hidden{display:none!important}.jump{border:1px solid var(--hair);border-radius:20px;background:var(--grouped);color:inherit;padding:0;width:min(400px,calc(100% - 32px));box-shadow:var(--shadow)}.jump::backdrop{background:#1c120a6b}.jump-form{display:grid;gap:10px;padding:18px}.jump h2{margin:0;font-size:1.15rem;font-family:"Source Serif 4",Iowan Old Style,Palatino Linotype,Songti SC,serif}@media(max-width:720px){.tagline{display:none}.join-row,.row{grid-template-columns:1fr}.join-btn{width:100%;min-height:44px}.chat{grid-template-columns:1fr}.side{display:none;position:absolute;top:36px;right:0;width:min(240px,80vw);max-height:50%;background:var(--grouped);border:1px solid var(--hair);border-radius:16px;padding:10px 12px;z-index:2;box-shadow:var(--shadow)}.chat.show-side .side{display:block}.topbar{grid-template-columns:auto 1fr auto}} diff --git a/assets/index-Ckc1AzqL.js b/assets/index-Ckc1AzqL.js deleted file mode 100644 index 5aa9e26..0000000 --- a/assets/index-Ckc1AzqL.js +++ /dev/null @@ -1,3 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-BgLl3BAq.js","assets/strategy-C52a_06u.js","assets/index-DSJFV8_4.js"])))=>i.map(i=>d[i]); -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const c of o.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&n(c)}).observe(document,{childList:!0,subtree:!0});function s(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function n(i){if(i.ep)return;i.ep=!0;const o=s(i);fetch(i.href,o)}})();const Et="zenchat.andyccr.v1",u=1,St=24,Tt=64,it=4e3,rt=10,E=200,Lt=E*2,Ct="lobby",It=2500,Pt=1800,Rt=25e3,At=5e3,Nt=8e3,_t="https://github.com/Andyccr/ZenChat",R="zenchat.nick",D="zenchat.peerId",B=["闲云","青石","疏影","晚风","折柳","听雨","归舟","松风"];function A(e){const t=new Uint8Array(e);return crypto.getRandomValues(t),[...t].map(s=>s.toString(16).padStart(2,"0")).join("")}const J=["#c15f3c","#8b5e3c","#5c6b4a","#6b4f3a","#9a6b3f","#4a6b62","#7a4e3a","#5a5568"];function ot(e){let t=2166136261;for(let s=0;s{const i=n.codePointAt(0)??0;return i>=32&&i!==127}).join("").slice(0,St)}function at(e){const t=Number.parseInt(e.slice(0,4),16)%B.length;return B[t]??"闲云"}function ct(){const e=sessionStorage.getItem(D),t=e&&/^[0-9a-f]{16,}$/i.test(e)?e:A(12);sessionStorage.setItem(D,t);const s=localStorage.getItem(R),n=b(s??"")||at(t);return localStorage.setItem(R,n),{id:t,nick:n}}function H(e){const t=b(e)||at(ct().id);return localStorage.setItem(R,t),t}function jt(e){return e==="torrent"||e==="nostr"}function g(e){return e.normalize("NFKC").trim().replace(/\s+/g,"-").replace(/[/#?&]+/g,"").slice(0,Tt)}function xt(e){const t=g(e.name);return e.password?`${t}::${e.password}`:t}function N(e){return`${e.strategy}:${g(e.name)}`}function _(e){return`${e.strategy}:${g(e.name)}:${e.password}`}function Mt(e,t){return _(e)===_(t)}const Ot="zenchat.cache.v1.";function lt(e){return Ot+_(e)}function $t(e){try{const t=sessionStorage.getItem(lt(e));if(!t)return[];const s=JSON.parse(t);return Array.isArray(s)?s.filter(Dt).slice(-E):[]}catch{return[]}}function K(e,t){try{sessionStorage.setItem(lt(e),JSON.stringify(t.slice(-E)))}catch{}}function F(e){return e.length>E?e.slice(-E):e}function Dt(e){if(typeof e!="object"||e===null)return!1;const t=e;return t.kind==="system"?typeof t.id=="string"&&typeof t.text=="string"&&typeof t.ts=="number":t.kind==="chat"?typeof t.id=="string"&&typeof t.fromId=="string"&&typeof t.nick=="string"&&typeof t.text=="string"&&typeof t.ts=="number"&&typeof t.self=="boolean":!1}const Bt="zenchat.pass.v1.";function ht(e){return Bt+N(e)}function j(e){try{const t=ht(e);e.password?sessionStorage.setItem(t,e.password):sessionStorage.removeItem(t)}catch{}}function dt(e){try{return sessionStorage.getItem(ht(e))??""}catch{return""}}function V(e){if(e.password)return j(e),e;const t=dt(e);return t?{...e,password:t}:e}const ut="zenchat.recentRooms";function pt(){try{const e=localStorage.getItem(ut);if(!e)return[];const t=JSON.parse(e);return Array.isArray(t)?t.filter(s=>typeof s=="object"&&s!==null&&typeof s.name=="string"&&(s.strategy==="torrent"||s.strategy==="nostr")&&typeof s.hasPassword=="boolean"&&typeof s.visitedAt=="number").slice(0,rt):[]}catch{return[]}}function Jt(e){const t={name:e.name,strategy:e.strategy,hasPassword:!!e.password,visitedAt:Date.now()},s=pt().filter(i=>N(i)!==N(e)),n=[t,...s].slice(0,rt);return localStorage.setItem(ut,JSON.stringify(n)),n}function Ht(e,t){const s=dt(e),i=(!!(t&&g(e.name)===g(t.name)&&e.strategy===t.strategy)?t?.password??"":"")||s;return e.hasPassword&&!i?"need-password":{name:e.name,password:i,strategy:e.strategy}}class Kt{constructor(t,s,n){this.runtime=t,this.timeoutMs=s,this.onExpire=n}runtime;timeoutMs;onExpire;timers=new Map;expect(t){this.cancel(t),this.timers.set(t,this.runtime.setTimeout(()=>{this.timers.delete(t),this.onExpire(t)},this.timeoutMs))}ack(t){return this.timers.has(t)?(this.cancel(t),!0):!1}cancel(t){const s=this.timers.get(t);s!==void 0&&this.runtime.clearTimeout(s),this.timers.delete(t)}clear(){for(const t of[...this.timers.keys()])this.cancel(t)}}class Ft{constructor(t,s=()=>{},n=It){this.runtime=t,this.onTypingEnd=s,this.typingTtl=n}runtime;onTypingEnd;typingTtl;members=new Map;typingTimers=new Map;upsert(t,s,n){const i=this.members.get(t),o=this.runtime.now(),c={id:t,nick:s,joinedAt:i?.joinedAt??o,lastSeenAt:o,rttMs:i?.rttMs??null,typing:i?.typing??!1,features:n??i?.features??[]};return this.members.set(t,c),c}supports(t){return this.list().some(s=>s.features.includes(t))}remove(t){this.clearTypingTimer(t);const s=this.members.get(t);return this.members.delete(t),s}markTyping(t){const s=this.members.get(t);return s?(s.typing=!0,this.clearTypingTimer(t),this.typingTimers.set(t,this.runtime.setTimeout(()=>{const n=this.members.get(t);n&&(n.typing=!1,this.onTypingEnd(t))},this.typingTtl)),!0):!1}setRtt(t,s){const n=this.members.get(t);return n?(n.rttMs=s,!0):!1}list(){return[...this.members.values()].sort((t,s)=>t.joinedAt-s.joinedAt)}clear(){for(const t of this.typingTimers.keys())this.clearTypingTimer(t);this.members.clear()}clearTypingTimer(t){const s=this.typingTimers.get(t);s!==void 0&&this.runtime.clearTimeout(s),this.typingTimers.delete(t)}}const O="ack";function Vt(e){return typeof e=="object"&&e!==null}function zt(e){return e===u}function Ut(e){return Array.isArray(e)?[...new Set(e.filter(t=>t===O))]:[]}function z(e){return typeof e!="string"||e.length<8||e.length>80?null:e}function Wt(e,t,s,n){return{v:u,type:"chat",id:s,ts:n,nick:b(e),text:t.trim().slice(0,it)}}function k(e){return{v:u,type:"hello",nick:b(e),features:[O]}}function Gt(e){return{v:u,type:"typing",nick:b(e)}}function U(e,t){return{v:u,type:"ack",id:t,nick:b(e)}}function qt(e){if(!Vt(e)||!zt(e.v)||typeof e.type!="string")return null;const t=typeof e.nick=="string"?b(e.nick):"";if(!t)return null;if(e.type==="hello")return{v:u,type:"hello",nick:t,features:Ut(e.features)};if(e.type==="typing")return{v:u,type:"typing",nick:t};if(e.type==="ack"){const s=z(e.id);return s?{v:u,type:"ack",id:s,nick:t}:null}if(e.type==="chat"){const s=z(e.id);if(!s||typeof e.ts!="number"||!Number.isFinite(e.ts)||typeof e.text!="string")return null;const n=e.text.trim().slice(0,it);return n?{v:u,type:"chat",id:s,ts:e.ts,nick:t,text:n}:null}return null}const Yt={now:()=>Date.now(),hidden:()=>document.hidden,setTimeout:(e,t)=>window.setTimeout(e,t),clearTimeout:e=>window.clearTimeout(e),setInterval:(e,t)=>window.setInterval(e,t),clearInterval:e=>window.clearInterval(e),onVisibilityChange(e){return document.addEventListener("visibilitychange",e),()=>document.removeEventListener("visibilitychange",e)}};function Xt(e,t){let s=Number.NEGATIVE_INFINITY;return(...n)=>{const i=Date.now();i-se(...n);let t=0,s=null;return(...n)=>{s=n,!t&&(t=requestAnimationFrame(()=>{t=0;const i=s;s=null,i&&e(...i)}))}}function mt(){return{state:"idle",peers:0,relaysDown:!1}}function T(e){return e.state==="joining"||e.state==="waiting"||e.state==="live"||e.state==="relay_down"}function C(e){return e.state==="idle"||e.state==="failed"||e.state==="joining"||e.state==="leaving"?e:e.peers>0?{...e,state:"live"}:e.relaysDown?{...e,state:"relay_down"}:{...e,state:"waiting"}}function y(e,t){switch(t.type){case"join":return e.state==="leaving"?e:{state:"joining",peers:0,relaysDown:!1};case"join_ok":return e.state!=="joining"?e:C({...e,state:"waiting"});case"join_err":return e.state!=="joining"?e:{state:"failed",peers:0,relaysDown:!1};case"peers":return T(e)?C({...e,peers:t.count}):e;case"relays":return T(e)?C({...e,relaysDown:t.down}):e;case"force_down":return!T(e)||e.peers>0?e:{...e,relaysDown:!0,state:"relay_down"};case"leave":return e.state==="idle"?e:{...e,state:"leaving"};case"left":return mt()}}function Qt(e){return e.state==="live"?"connected":e.state==="failed"||e.state==="relay_down"?"error":e.state==="idle"?"idle":"connecting"}const p={idle:"尚未连接",left:"已离开房间",connectingTorrent:"正在连接 Tracker…",connectingNostr:"正在连接 Nostr…",waiting:"还没有同伴,把链接发给对方",handshake:"握手受阻,仍在重试",relaysDownTorrent:"Tracker 连不上,可改用 Nostr",relaysDownNostr:"Nostr 中继连不上,可改用 Tracker",sendFailed:"刚才那条没发出去,请再试一次"};function W(e){return e==="nostr"?p.connectingNostr:p.connectingTorrent}function te(e){return`已直连 ${e} 人`}function ee(e){return`无法启动 P2P:${e}`}function se(e){return e.filter(t=>t.readyState===1).length}function ne(e){return e.length>0&&se(e)===0}function ie(e,t){return ne(t)?{phase:"error",detail:e==="nostr"?p.relaysDownNostr:p.relaysDownTorrent}:{phase:"connecting",detail:p.waiting}}function re(e){return e.kind!=="chat"||e.delivery!=="pending"?e:x({id:e.id,fromId:e.fromId,nick:e.nick,text:e.text,ts:e.ts,self:e.self})}class oe{lines=[];seen=new Set;seenOrder=[];hydrate(t){this.clear(),this.lines=F(t.map(re));for(const s of this.lines)s.kind==="chat"&&this.remember(s.id);return this.lines}snapshot(){return this.lines}has(t){return this.seen.has(t)}remember(t){if(!this.seen.has(t))for(this.seen.add(t),this.seenOrder.push(t);this.seenOrder.length>Lt;){const s=this.seenOrder.shift();s&&this.seen.delete(s)}}append(t){if(t.kind==="chat"){if(this.seen.has(t.id))return null;this.remember(t.id)}return this.lines.push(t),this.lines=F(this.lines),t}patchDelivery(t,s){const n=this.lines.findIndex(c=>c.id===t),i=n>=0?this.lines[n]:void 0;if(n<0||!i||i.kind!=="chat")return null;const o={...i,delivery:s};return this.lines[n]=o,o}clear(){this.lines=[],this.seen.clear(),this.seenOrder=[]}}function x(e){return e.delivery?{kind:"chat",...e,delivery:e.delivery}:{kind:"chat",id:e.id,fromId:e.fromId,nick:e.nick,text:e.text,ts:e.ts,self:e.self}}function ae(e,t,s){return{kind:"system",id:e,text:t,ts:s}}const ce="modulepreload",le=function(e){return"/ZenChat/"+e},G={},q=function(t,s,n){let i=Promise.resolve();if(s&&s.length>0){let m=function(d){return Promise.all(d.map(w=>Promise.resolve(w).then(v=>({status:"fulfilled",value:v}),v=>({status:"rejected",reason:v}))))};var c=m;document.getElementsByTagName("link");const h=document.querySelector("meta[property=csp-nonce]"),l=h?.nonce||h?.getAttribute("nonce");i=m(s.map(d=>{if(d=le(d),d in G)return;G[d]=!0;const w=d.endsWith(".css"),v=w?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${d}"]${v}`))return;const f=document.createElement("link");if(f.rel=w?"stylesheet":ce,w||(f.as="script"),f.crossOrigin="",f.href=d,l&&f.setAttribute("nonce",l),document.head.appendChild(f),w)return new Promise((vt,kt)=>{f.addEventListener("load",vt),f.addEventListener("error",()=>kt(new Error(`Unable to preload CSS for ${d}`)))})}))}function o(h){const l=new Event("vite:preloadError",{cancelable:!0});if(l.payload=h,window.dispatchEvent(l),!l.defaultPrevented)throw h}return i.then(h=>{for(const l of h||[])l.status==="rejected"&&o(l.reason);return t().catch(o)})},he=[{urls:"stun:stun.cloudflare.com:3478"},{urls:"stun:stun.l.google.com:19302"}],de={iceServers:he,iceCandidatePoolSize:8,bundlePolicy:"max-bundle",rtcpMuxPolicy:"require"},ue=["wss://tracker.webtorrent.dev","wss://tracker.openwebtorrent.com","wss://open.ftorrent.com:443"],pe=["wss://relay.damus.io","wss://nos.lol","wss://relay.snort.social","wss://nostr.wine"];function me(e){return Object.entries(e).map(([t,s])=>({url:t,readyState:s?.readyState??WebSocket.CLOSED}))}class fe{strategy;room=null;action=null;module=null;constructor(t){this.strategy=t}selfId(){return this.module?.selfId??""}async join(t,s){await this.leave(),this.module=await M(t.strategy);const n=t.strategy==="torrent"?[...ue]:[...pe],i=this.module.joinRoom({appId:t.appId,...t.password?{password:t.password}:{},rtcConfig:de,trickleIce:!0,relayConfig:{urls:n}},t.roomId,{onJoinError:c=>{s.onJoinError(c.error||"无法与对等节点完成握手")}}),o=i.makeAction("zen");o.onMessage=(c,{peerId:h})=>{s.onPayload(h,c)},i.onPeerJoin=c=>s.onPeerJoin(c),i.onPeerLeave=c=>s.onPeerLeave(c),this.room=i,this.action=o}async leave(){const t=this.room;if(this.room=null,this.action=null,t)try{await t.leave()}catch{}}async send(t,s){if(!this.action)throw new Error("尚未加入房间");await this.action.send(t,s?{target:s}:void 0)}ping(t){return this.room?this.room.ping(t):Promise.reject(new Error("未连接"))}peerIds(){return this.room?Object.keys(this.room.getPeers()):[]}relays(){return this.module?me(this.module.getRelaySockets()):[]}}const Y=new Map;async function M(e){const t=Y.get(e);if(t)return t;const s=e==="torrent"?q(()=>import("./index-BgLl3BAq.js"),__vite__mapDeps([0,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId})):q(()=>import("./index-DSJFV8_4.js"),__vite__mapDeps([2,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId}));return Y.set(e,s),s}function ye(){M("torrent"),M("nostr")}function ge(e){return new fe(e)}class be{transport;identity;listeners;createTransport;runtime;transcript=new oe;presence;outbound;life=mt();helloTimer=null;relayTimer=null;unbindVisibility=null;strategy="torrent";joinErrors=0;status={phase:"idle",detail:p.idle,relays:[],peerCount:0};emitMembers=Zt(()=>{this.listeners.onMembers?.(this.presence.list())});sendTypingThrottled=Xt(()=>{this.isJoined()&&this.transport.send(Gt(this.identity.nick))},Pt);constructor(t,s,n={}){this.identity=t,this.listeners=s,this.createTransport=n.createTransport??ge,this.runtime=n.runtime??Yt,this.transport=this.createTransport("torrent"),this.presence=new Ft(this.runtime,()=>this.emitMembers()),this.outbound=new Kt(this.runtime,Nt,i=>this.expireAck(i))}get selfId(){return this.transport.selfId()||this.identity.id}isJoined(){return T(this.life)}getLines(){return this.transcript.snapshot()}hydrate(t){this.listeners.onReset?.(this.transcript.hydrate(t))}async join(t){this.isJoined()&&await this.leave({silent:!0}),this.strategy=t.strategy,this.joinErrors=0,this.transport=this.createTransport(t.strategy),this.life=y(this.life,{type:"join"}),this.setStatus({phase:"connecting",detail:W(t.strategy),relays:[],peerCount:0});try{await this.transport.join({appId:Et,roomId:xt({name:g(t.name),password:t.password}),password:t.password,strategy:t.strategy},{onPeerJoin:s=>{this.presence.upsert(s,"访客"),this.pushSystem(`${s.slice(0,6)} 加入了房间`),this.transport.send(k(this.identity.nick),s),this.emitMembers(),this.notePeers(),this.runtime.setTimeout(()=>{this.measure(s)},350)},onPeerLeave:s=>{const n=this.presence.remove(s);this.pushSystem(`${n?.nick??s.slice(0,6)} 离开了房间`),this.emitMembers(),this.notePeers()},onPayload:(s,n)=>this.handlePayload(s,n),onJoinError:()=>{this.joinErrors+=1,!(this.transport.peerIds().length>0)&&(this.joinErrors>=3?(this.life=y(this.life,{type:"force_down"}),this.emitStatus()):this.refreshStatus("connecting",p.handshake))}})}catch(s){this.life=y(this.life,{type:"join_err"});const n=s instanceof Error?s.message:"连接失败";throw this.refreshStatus("error",ee(n)),s}this.life=y(this.life,{type:"join_ok"}),this.bindVisibility(),this.startTimers(),this.transport.send(k(this.identity.nick)),this.emitStatus()}async sendChat(t){if(!this.isJoined())return"closed";const s=Wt(this.identity.nick,t,A(8),this.runtime.now());if(!s.text)return"empty";const n=this.presence.supports(O);this.pushLine(x({id:s.id,fromId:this.selfId,nick:this.identity.nick,text:s.text,ts:s.ts,self:!0,...n?{delivery:"pending"}:{}}));try{await this.transport.send(s)}catch{return this.patchDelivery(s.id,"failed"),this.pushSystem(p.sendFailed),"failed"}return n&&this.outbound.expect(s.id),"sent"}sendTyping(){this.sendTypingThrottled()}setNick(t){this.identity={...this.identity,nick:t},this.isJoined()&&this.transport.send(k(t))}async leave(t={}){this.life=y(this.life,{type:"leave"}),this.unbindVisibility?.(),this.unbindVisibility=null,this.stopTimers(),this.outbound.clear(),this.presence.clear(),await this.transport.leave(),this.transcript.clear(),this.life=y(this.life,{type:"left"}),t.silent||(this.setStatus({phase:"idle",detail:p.left,relays:[],peerCount:0}),this.emitMembers(),this.listeners.onReset?.([]))}handlePayload(t,s){const n=qt(s);if(n){if(n.type==="hello"?this.presence.upsert(t,n.nick,n.features):this.presence.upsert(t,n.nick),n.type==="hello"){this.emitMembers();return}if(n.type==="typing"){this.presence.markTyping(t),this.emitMembers();return}if(n.type==="ack"){this.outbound.ack(n.id)&&this.patchDelivery(n.id,"acked");return}if(this.transcript.has(n.id)){this.transport.send(U(this.identity.nick,n.id),t);return}this.pushLine(x({id:n.id,fromId:t,nick:n.nick,text:n.text,ts:n.ts,self:!1})),this.transport.send(U(this.identity.nick,n.id),t),this.emitMembers()}}expireAck(t){this.isJoined()&&this.patchDelivery(t,"failed")}patchDelivery(t,s){const n=this.transcript.patchDelivery(t,s);n&&this.listeners.onLine?.(n)}async measure(t){if(this.isJoined())try{const s=await this.transport.ping(t);this.isJoined()&&this.presence.setRtt(t,s)&&this.emitMembers()}catch{}}pushSystem(t){this.pushLine(ae(A(6),t,this.runtime.now()))}pushLine(t){const s=this.transcript.append(t);s&&this.listeners.onLine?.(s)}notePeers(){this.life=y(this.life,{type:"peers",count:this.transport.peerIds().length}),this.emitStatus()}emitStatus(){const t=this.transport.peerIds().length,s=this.transport.relays();this.life=y(this.life,{type:"relays",down:s.length>0&&s.every(o=>o.readyState!==1)});const n=Qt(this.life);if(this.life.state==="live"){this.setStatus({phase:n,detail:te(t),relays:s,peerCount:t});return}if(this.life.state==="failed")return;const i=ie(this.strategy,this.life.state==="relay_down"&&s.length===0?[{url:"local",readyState:3}]:s);this.setStatus({phase:i.phase,detail:this.life.state==="joining"?W(this.strategy):i.detail,relays:s,peerCount:t})}refreshStatus(t,s){this.setStatus({phase:t,detail:s,relays:this.transport.relays(),peerCount:this.transport.peerIds().length})}setStatus(t){const s=this.status;s.phase===t.phase&&s.detail===t.detail&&s.peerCount===t.peerCount&&s.relays.length===t.relays.length&&s.relays.every((n,i)=>n.readyState===t.relays[i]?.readyState)||(this.status=t,this.listeners.onStatus?.(t))}onVisibility=()=>{if(this.runtime.hidden()){this.stopTimers();return}this.isJoined()&&(this.startTimers(),this.transport.send(k(this.identity.nick)),this.emitStatus())};bindVisibility(){this.unbindVisibility?.(),this.unbindVisibility=this.runtime.onVisibilityChange(this.onVisibility)}startTimers(){this.stopTimers(),this.helloTimer=this.runtime.setInterval(()=>{this.transport.send(k(this.identity.nick))},Rt),this.relayTimer=this.runtime.setInterval(()=>{this.emitStatus();for(const t of this.transport.peerIds())this.measure(t)},At)}stopTimers(){this.helloTimer!==null&&(this.runtime.clearInterval(this.helloTimer),this.helloTimer=null),this.relayTimer!==null&&(this.runtime.clearInterval(this.relayTimer),this.relayTimer=null)}}class we{identity;listeners;options;session=null;spec=null;generation=0;constructor(t,s,n={}){this.identity=t,this.listeners=s,this.options=n}current(){return this.spec}getSession(){return this.session}selfId(){return this.session?.selfId??this.identity.id}sendChat(t){return this.session?.sendChat(t)??Promise.resolve("closed")}sendTyping(){this.session?.sendTyping()}retry(){const t=this.spec;return t?this.open(t,!0):Promise.resolve(null)}setIdentity(t){this.identity=t,this.session?.setNick(t.nick)}async open(t,s=!1){if(!s&&this.spec&&this.session?.isJoined()&&Mt(this.spec,t))return this.session;const n=++this.generation;if(await this.snapshotAndClose(),n!==this.generation)return null;Jt(t),this.listeners.onMembers?.([]);const i=new be(this.identity,this.listeners,this.options),o=$t(t);o.length>0?i.hydrate(o):this.listeners.onReset?.([]),this.session=i,this.spec=t;try{await i.join(t)}catch(c){if(n!==this.generation)return await i.leave({silent:!0}),null;throw c}return n!==this.generation?(await i.leave({silent:!0}),null):i}async close(){this.generation+=1,await this.snapshotAndClose()}snapshot(){this.session&&this.spec&&K(this.spec,this.session.getLines())}async snapshotAndClose(){this.session&&this.spec&&(K(this.spec,this.session.getLines()),await this.session.leave({silent:!0})),this.session=null,this.spec=null}}function X(e,t){return e.get(t)??""}function ve(e){const t=e.startsWith("#")?e.slice(1):e,s=new URL(t||"/","https://zenchat.local"),n=s.pathname.split("/").filter(Boolean);if(n[0]==="r"&&n[1]){const i=g(decodeURIComponent(n[1]));if(!i)return{name:"lobby"};const o=X(s.searchParams,"s"),c=jt(o)?o:"torrent";return{name:"room",spec:{name:i,password:X(s.searchParams,"k"),strategy:c}}}return{name:"lobby"}}function L(e,t=!1){if(e.name==="lobby")return"#/";const s=new URLSearchParams;s.set("s",e.spec.strategy),t&&e.spec.password&&s.set("k",e.spec.password);const n=s.toString();return`#/r/${encodeURIComponent(e.spec.name)}${n?`?${n}`:""}`}function ke(e){if(typeof BroadcastChannel>"u")return()=>{};const t=new BroadcastChannel("zenchat.tab"),s=n=>{n.data==="ping"&&t.postMessage("pong"),n.data==="pong"&&e()};return t.addEventListener("message",s),t.postMessage("ping"),()=>{t.removeEventListener("message",s),t.close()}}const a={title:"禅聊",tagline:"无服务器 · 浏览器直连",source:"源码",nick:"昵称",room:"房间",password:"口令",passwordHint:"口令只隔离房间,存在这个标签页,不会上传。",strategy:"信令",torrent:"Tracker",nostr:"Nostr",join:"加入",more:"更多选项",lobbyHint:"同一房间即可直连,无需账号。",tipSame:"两个窗口进同一个房间就能互发。",tipHttps:"请用 HTTPS 或 localhost。",tipNat:"对称 NAT 可能连不上(静态站没有 TURN)。",recent:"最近",share:"分享",shareWithKey:"含口令",leave:"大厅",send:"发送",placeholder:"发消息",members:"在线",you:"你",waiting:"还没有同伴,把链接发给对方。",copied:"已复制",shared:"已分享",copyFailed:"无法复制,请手动复制地址栏。",webrtcMissing:"需要支持 WebRTC 的浏览器,并用 HTTPS 打开。",insecure:"需要 HTTPS 或 localhost。",switch:"切换房间",addRoom:"加入房间",stamps:"表情",kaomoji:"颜文字",emoji:"表情",people:"成员",cancel:"取消",retry:"重试",switchSignal:"换信令",locked:"口令",duplicateTab:"另一个标签页也开着禅聊,会显示成两个人。",theme:"主题",delivered:"送达",undelivered:"未送达"};function r(e,t={},s=[]){const n=document.createElement(e);for(const[i,o]of Object.entries(t))if(!(o===void 0||o===!1)){if(i==="class"){n.className=String(o);continue}if(typeof o=="boolean"){n.toggleAttribute(i,o);continue}if(i==="style"){n.setAttribute("style",String(o));continue}if(i in n&&i!=="list"){n[i]=o;continue}n.setAttribute(i,String(o))}for(const i of s)n.append(typeof i=="string"?document.createTextNode(i):i);return n}function $(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function Ee(e){return new Intl.DateTimeFormat("zh-CN",{hour:"2-digit",minute:"2-digit",hour12:!1}).format(e)}function Se(e){switch(e){case WebSocket.CONNECTING:return"连接中";case WebSocket.OPEN:return"已连接";case WebSocket.CLOSING:return"断开中";default:return"未连接"}}function Te(e){try{return new URL(e).host}catch{return e}}function ft(){return typeof RTCPeerConnection=="function"}function Z(){return window.isSecureContext&&ft()}function Le(){return window.isSecureContext?ft()?r("div"):r("div",{class:"banner"},[a.webrtcMissing]):r("div",{class:"banner"},[a.insecure])}const yt=["(´・ω・`)","(。•́︿•̀。)","(´▽`ʃ♡ƪ)","(╯°□°)╯︵ ┻━┻","¯\\_(ツ)_/¯","(づ。◕‿‿◕。)づ","(ง'̀-'́)ง","( ̄▽ ̄)","(•̀ᴗ•́)و","ฅ^•ﻌ•^ฅ","(´;ω;`)","(≧∇≦)ノ","(´∀`)♡","┐( ̄ヘ ̄)┌"],gt=["😀","😂","🥰","😎","🤔","😭","🙏","✨","🔥","🎉","👍","👋","❤️","🌸","🍵","🌙"],Ce=new Set(yt),Ie=new Set(gt);function Pe(e){const t=e.trim();return t?Ce.has(t)||Ie.has(t)?!0:t.length<=8&&/^[\p{Emoji_Presentation}\s]+$/u.test(t):!1}function Q(e,t){const s=e.selectionStart,n=e.selectionEnd,i=`${e.value.slice(0,s)}${t}${e.value.slice(n)}`;e.value=i;const o=s+t.length;e.setSelectionRange(o,o),e.focus(),e.dispatchEvent(new Event("input",{bubbles:!0}))}class Re{el;ids=new Set;nodes=new Map;constructor(){this.el=r("div",{class:"log",role:"log","aria-live":"polite"})}get size(){return this.ids.size}reset(t){this.el.replaceChildren(),this.ids.clear(),this.nodes.clear();for(const s of t)this.append(s,!1);this.stick()}append(t,s=!0){if(this.ids.has(t.id)){this.replace(t);return}this.ids.add(t.id);const n=tt(t);this.nodes.set(t.id,n),this.el.append(n),s&&this.stick()}replace(t){const s=this.nodes.get(t.id);if(!s)return;const n=tt(t);this.nodes.set(t.id,n),s.replaceWith(n)}stick(){const t=this.el;requestAnimationFrame(()=>{t.scrollHeight-t.scrollTop-t.clientHeight<96&&(t.scrollTop=t.scrollHeight)})}}function tt(e){if(e.kind==="system")return r("div",{class:"system","data-id":e.id},[e.text]);const t=Pe(e.text),s=e.self&&e.delivery==="acked"?r("span",{class:"delivery"},[a.delivered]):e.self&&e.delivery==="failed"?r("span",{class:"delivery fail"},[a.undelivered]):null,n=r("header",{},[r("b",{style:`color:${ot(e.fromId)}`},[e.nick]),r("time",{},[Ee(e.ts)]),...s?[s]:[]]);return r("article",{class:`bubble${e.self?" self":""}${t?" stamp":""}${e.delivery==="failed"?" failed":""}`,"data-id":e.id},[n,r("p",{},[e.text])])}class Ae{el;open=!1;constructor(t){const s=r("div",{class:"picker-grid kaomoji"},yt.map(c=>et(c,()=>Q(t,c)))),n=r("div",{class:"picker-grid emoji hidden"},gt.map(c=>et(c,()=>Q(t,c)))),i=r("button",{class:"tab on",type:"button"},[a.kaomoji]),o=r("button",{class:"tab",type:"button"},[a.emoji]);i.addEventListener("click",()=>{s.classList.remove("hidden"),n.classList.add("hidden"),i.classList.add("on"),o.classList.remove("on")}),o.addEventListener("click",()=>{n.classList.remove("hidden"),s.classList.add("hidden"),o.classList.add("on"),i.classList.remove("on")}),this.el=r("div",{class:"picker hidden",role:"dialog","aria-label":a.stamps},[r("div",{class:"picker-tabs"},[i,o]),s,n])}toggle(){this.open=!this.open,this.el.classList.toggle("hidden",!this.open)}hide(){this.open=!1,this.el.classList.add("hidden")}}function et(e,t){const s=r("button",{class:"stamp-btn",type:"button"},[e]);return s.addEventListener("click",t),s}class Ne{constructor(t){this.handlers=t,this.statusEl=r("div",{class:"status"}),this.actionsEl=r("div",{class:"status-actions"}),this.membersEl=r("div",{class:"members"}),this.vacantEl=r("div",{class:"vacant"},[a.waiting]),this.peopleBtn=r("button",{class:"btn ghost people",type:"button","aria-expanded":"false"},[a.people]),this.peopleBtn.addEventListener("click",l=>{l.stopPropagation(),this.root.classList.toggle("show-side"),this.peopleBtn.setAttribute("aria-expanded",this.root.classList.contains("show-side")?"true":"false")});const s=r("textarea",{rows:1,placeholder:a.placeholder,maxlength:4e3,enterkeyhint:"send","aria-label":a.placeholder});s.setAttribute("enterkeyhint","send"),s.setAttribute("autocapitalize","sentences");const n=new Ae(s),i=r("button",{class:"btn ghost",type:"button",title:a.stamps},["顔"]),o=r("button",{class:"btn primary send",type:"button","aria-label":a.send},["↑"]);i.addEventListener("click",l=>{l.stopPropagation(),n.toggle()}),this.peopleBtn.addEventListener("pointerdown",l=>l.stopPropagation()),document.addEventListener("pointerdown",l=>{this.root.contains(l.target)&&(l.target.closest(".picker, .people, .side")||n.hide())}),n.el.addEventListener("click",l=>l.stopPropagation());const c=()=>{const l=s.value;l.trim()&&(s.value="",s.style.height="auto",n.hide(),this.handlers.send(l).then(m=>{(m==="failed"||m==="closed")&&(s.value=l)}))};o.addEventListener("click",c),s.addEventListener("keydown",l=>{l.key==="Enter"&&!l.shiftKey&&(l.preventDefault(),c())}),s.addEventListener("input",()=>{s.style.height="auto",s.style.height=`${Math.min(s.scrollHeight,96)}px`,this.handlers.typing()}),this.nickInput=r("input",{class:"nick-edit",maxlength:24,value:t.nick,"aria-label":a.nick});const h=()=>this.handlers.onNick(this.nickInput.value);this.nickInput.addEventListener("change",h),this.nickInput.addEventListener("keydown",l=>{l.key==="Enter"&&(l.preventDefault(),this.nickInput.blur())}),this.selfIdEl=r("span",{class:"muted"},[t.selfId.slice(0,8)]),this.selfRow=r("div",{class:"member self"},[r("label",{class:"nick-label"},[r("span",{},[a.you]),this.nickInput]),this.selfIdEl]),this.membersEl.append(this.selfRow),this.root=r("div",{class:"chat"},[r("section",{class:"transcript"},[r("div",{class:"status-row"},[this.statusEl,this.actionsEl,this.peopleBtn]),this.vacantEl,this.log.el,n.el,r("div",{class:"composer"},[i,s,o])]),r("aside",{class:"side"},[r("h2",{},[a.members]),this.membersEl])])}handlers;root;log=new Re;statusEl;actionsEl;membersEl;selfRow;selfIdEl;vacantEl;peopleBtn;nickInput;lastStatus="";lastPeerCount=0;hide(){this.root.hidden=!0,this.root.classList.remove("show-side"),this.peopleBtn.setAttribute("aria-expanded","false")}show(){this.root.hidden=!1}push(t){this.log.append(t),this.vacantEl.hidden=!0}reset(t){this.log.reset(t),this.vacantEl.hidden=t.length>0||this.lastPeerCount>0}setNick(t){document.activeElement!==this.nickInput&&(this.nickInput.value=t)}status(t){const s=t.relays.filter(o=>o.readyState===WebSocket.OPEN).length,n=`${t.phase}|${t.detail}|${t.peerCount}|${s}|${this.log.size}`;if(n===this.lastStatus)return;this.lastStatus=n,this.lastPeerCount=t.peerCount;const i=t.phase==="connected"?" ok":t.phase==="error"?" err":"";this.statusEl.replaceChildren(r("i",{class:`dot${i}`}),r("span",{},[` ${t.detail}`])),this.peopleBtn.textContent=`${a.people} ${t.peerCount}`,this.statusEl.title=t.relays.map(o=>`${Te(o.url)} ${Se(o.readyState)}`).join(` -`),this.renderActions(t),this.vacantEl.hidden=t.peerCount>0||this.log.size>0}members(t,s){for(this.selfIdEl.textContent=s.slice(0,8);this.membersEl.lastChild&&this.membersEl.lastChild!==this.selfRow;)this.membersEl.removeChild(this.membersEl.lastChild);for(const n of t)this.membersEl.append(r("div",{class:"member"},[r("b",{style:`color:${ot(n.id)}`},[n.nick]),r("span",{class:"muted"},[n.typing?"输入中":n.rttMs!==null?`${Math.round(n.rttMs)}ms`:""])]))}renderActions(t){if($(this.actionsEl),t.phase==="error"){this.actionsEl.append(I(a.retry,this.handlers.onRetry),I(a.switchSignal,this.handlers.onSwitchSignal));return}t.phase==="connecting"&&t.peerCount===0&&this.actionsEl.append(I(a.share,this.handlers.onShare))}}function I(e,t){const s=r("button",{class:"btn ghost compact",type:"button"},[e]);return s.addEventListener("click",n=>{n.stopPropagation(),t()}),s}function _e(e){const t=r("input",{placeholder:a.room,maxlength:64,autocomplete:"off"}),s=r("input",{placeholder:a.password,type:"password",autocomplete:"off"}),n=r("select",{},[r("option",{value:"torrent"},[a.torrent]),r("option",{value:"nostr"},[a.nostr])]),i=r("form",{class:"jump-form",method:"dialog"},[r("h2",{},[a.switch]),t,s,n,r("div",{class:"actions"},[r("button",{class:"btn primary",type:"submit"},[a.join]),r("button",{class:"btn ghost",type:"button",value:"cancel"},[a.cancel])])]),o=r("dialog",{class:"jump"},[i]);return i.addEventListener("submit",c=>{c.preventDefault(),o.close(),e({name:t.value,password:s.value,strategy:n.value})}),i.querySelector('button[value="cancel"]')?.addEventListener("click",()=>o.close()),{el:o,open(c){t.value=c?.name??"",s.value=c?.password??"",n.value=c?.strategy??"torrent",o.showModal(),c?.needPassword?s.focus():t.focus()},close:()=>o.close()}}function je(e){const t=r("input",{maxlength:24,value:e.nick,autocomplete:"nickname"}),s=r("input",{maxlength:64,value:Ct,autocomplete:"off"}),n=r("input",{type:"password",autocomplete:"off"}),i=r("select",{},[r("option",{value:"torrent"},[a.torrent]),r("option",{value:"nostr"},[a.nostr])]),o=r("button",{class:"btn primary join-btn",type:"submit"},[a.join]);Z()||(o.disabled=!0);const c=r("form",{class:"panel form lobby-form"},[r("div",{class:"join-row"},[S(a.nick,t),S(a.room,s),o]),r("details",{class:"more"},[r("summary",{},[a.more]),r("div",{class:"row"},[S(a.password,n),S(a.strategy,i)]),r("span",{class:"hint"},[a.passwordHint])])]);return c.addEventListener("submit",h=>{h.preventDefault(),Z()&&e.onJoin({nick:t.value,spec:{name:s.value,password:n.value,strategy:i.value}})}),r("div",{class:"lobby"},[r("p",{class:"lede"},[a.lobbyHint]),r("ul",{class:"tips"},[r("li",{},[a.tipSame]),r("li",{},[a.tipHttps]),r("li",{},[a.tipNat])]),c])}function S(e,t){return r("label",{class:"field"},[r("span",{},[e]),t])}function xe(e,t,s,n=!1){return`${e}${t}${L({name:"room",spec:s},n)}`}async function Me(e,t="禅聊"){try{if(typeof navigator.share=="function")return await navigator.share({title:t,url:e}),"shared"}catch(s){if(s instanceof Error&&s.name==="AbortError")return"failed"}try{return await navigator.clipboard.writeText(e),"copied"}catch{return"failed"}}function Oe(e,t,s){$(e);for(const i of pt()){const o=!!(t&&i.name===g(t.name)&&i.strategy===t.strategy),c=i.hasPassword?[i.name,r("span",{class:"lock",title:a.locked},["锁"])]:[i.name],h=r("a",{class:`tab${o?" on":""}`,href:L({name:"room",spec:{name:i.name,password:"",strategy:i.strategy}})},c);h.addEventListener("click",l=>{l.preventDefault();const m=Ht(i,t);if(m==="need-password"){s.onNeedPassword(i);return}s.onGo(m)}),e.append(h)}const n=r("button",{class:"tab add",type:"button",title:a.addRoom},["+"]);n.addEventListener("click",()=>s.onAdd()),e.append(n)}function $e(e,t,s,n){if($(e),e.append(t),!s)return;const i=r("button",{class:"btn ghost",type:"button"},[a.share]);if(i.addEventListener("click",()=>n(s,!1,i)),e.append(i),s.password){const o=r("button",{class:"btn ghost",type:"button"},[a.shareWithKey]);o.addEventListener("click",()=>n(s,!0,o)),e.append(o)}e.append(r("a",{class:"btn ghost",href:"#/"},[a.leave]))}async function st(e,t,s){const n=xe(location.origin,location.pathname,e,t),i=await Me(n);if(!s)return;const o=s.textContent;s.textContent=i==="shared"?a.shared:i==="copied"?a.copied:a.copyFailed,window.setTimeout(()=>{o&&(s.textContent=o)},1400)}const bt="zenchat.theme";function De(){const e=localStorage.getItem(bt);return e==="light"||e==="dark"||e==="system"?e:"system"}function Be(e){localStorage.setItem(bt,e)}function Je(e){return e==="system"?"light":e==="light"?"dark":"system"}function He(){return window.matchMedia("(prefers-color-scheme: dark)").matches}function Ke(e,t=He()){return e==="system"?t?"dark":"light":e}function P(e){const t=Ke(e);return document.documentElement.dataset.theme=t,document.documentElement.dataset.themePref=e,document.documentElement.style.colorScheme=t,t}function nt(e){return e==="light"?"白天":e==="dark"?"黑夜":"跟随系统"}class Fe{root;theme;identity=ct();manager;themeBtn;tabsEl;toolsEl;main;jump;footerEl;dupBanner;chat=null;lobbyEl=null;routing=!1;stopTabWatch=null;constructor(t){this.root=t,this.theme=De(),P(this.theme),this.themeBtn=r("button",{class:"btn ghost",type:"button",title:a.theme},[nt(this.theme)]),this.tabsEl=r("nav",{class:"tabs","aria-label":a.recent}),this.toolsEl=r("div",{class:"toolbar"}),this.main=r("main",{class:"main"}),this.jump=_e(s=>this.go(s)),this.dupBanner=r("div",{class:"banner warn hidden"},[a.duplicateTab]),this.footerEl=r("p",{class:"footer"},[r("a",{href:_t,target:"_blank",rel:"noreferrer"},[a.source])," · AGPL-3.0"]),this.manager=new we(this.identity,{onStatus:s=>this.chat?.status(s),onMembers:s=>this.chat?.members(s,this.manager.selfId()),onLine:s=>this.chat?.push(s),onReset:s=>this.chat?.reset(s)})}start(){this.mount(),this.stopTabWatch=ke(()=>this.dupBanner.classList.remove("hidden")),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>P(this.theme)),window.addEventListener("hashchange",()=>{this.routing||this.route()}),window.addEventListener("pagehide",()=>{this.stopTabWatch?.(),this.manager.snapshot(),this.manager.close()}),window.addEventListener("pageshow",t=>{t.persisted&&this.route()}),window.addEventListener("keydown",t=>{(t.metaKey||t.ctrlKey)&&t.key.toLowerCase()==="k"&&(t.preventDefault(),this.jump.open()),t.key==="Escape"&&this.jump.close()}),this.themeBtn.addEventListener("click",()=>{this.theme=Je(this.theme),Be(this.theme),P(this.theme),this.themeBtn.textContent=nt(this.theme)}),this.route()}mount(){this.root.append(r("div",{class:"shell"},[r("header",{class:"topbar"},[r("a",{class:"brand",href:"#/"},[r("div",{class:"enso","aria-hidden":"true"}),r("strong",{},[a.title]),r("span",{class:"tagline"},[a.tagline])]),this.tabsEl,this.toolsEl]),Le(),this.dupBanner,this.main,this.footerEl]),this.jump.el)}async route(){const t=ve(location.hash);if(this.refreshChrome(t.name==="room"?V(t.spec):void 0),t.name==="lobby"){await this.manager.close(),this.showLobby();return}await this.showChat(this.stripPasswordFromHash(V(t.spec)))}stripPasswordFromHash(t){j(t);const s=L({name:"room",spec:t},!1);return t.password&&location.hash!==s&&(this.routing=!0,history.replaceState(null,"",`${location.pathname}${location.search}${s}`),this.routing=!1),t}go(t){j(t);const s=L({name:"room",spec:t},!1);if(location.hash===s){this.showChat(t);return}this.routing=!0,location.hash=s,this.routing=!1,this.showChat(t)}showLobby(){this.chat?.hide(),this.footerEl.hidden=!1,this.root.classList.remove("mode-chat");const t=je({nick:this.identity.nick,onJoin:({nick:s,spec:n})=>{this.identity={...this.identity,nick:H(s)},this.manager.setIdentity(this.identity),this.go(n)}});this.lobbyEl&&this.lobbyEl.replaceWith(t),this.lobbyEl=t,this.main.replaceChildren(this.lobbyEl),this.refreshChrome()}async showChat(t){this.chat?this.chat.setNick(this.identity.nick):this.chat=new Ne({selfId:this.identity.id,nick:this.identity.nick,send:s=>this.manager.sendChat(s),typing:()=>this.manager.sendTyping(),onRetry:()=>{this.manager.retry()},onShare:()=>{const s=this.manager.current()??t;st(s,!1)},onSwitchSignal:()=>{const s=this.manager.current()??t;this.go({...s,strategy:s.strategy==="torrent"?"nostr":"torrent"})},onNick:s=>{this.identity={...this.identity,nick:H(s)},this.manager.setIdentity(this.identity),this.chat?.setNick(this.identity.nick)}}),this.lobbyEl=null,this.footerEl.hidden=!0,this.root.classList.add("mode-chat"),this.main.replaceChildren(this.chat.root),this.chat.show(),this.refreshChrome(t);try{await this.manager.open(t)}catch(s){const n=s instanceof Error?s.message:"连接失败";this.chat.status({phase:"error",detail:`无法启动 P2P:${n}`,relays:[],peerCount:0})}}refreshChrome(t){Oe(this.tabsEl,t,{onGo:s=>this.go(s),onNeedPassword:s=>this.jump.open({name:s.name,strategy:s.strategy,needPassword:!0}),onAdd:()=>this.jump.open()}),$e(this.toolsEl,this.themeBtn,t??null,(s,n,i)=>{st(s,n,i)})}}function Ve(){const e=window.visualViewport,t=()=>{const s=e?.height??window.innerHeight;document.documentElement.style.setProperty("--app-h",`${Math.round(s)}px`)};t(),e?.addEventListener("resize",t,{passive:!0}),e?.addEventListener("scroll",t,{passive:!0}),window.addEventListener("orientationchange",t,{passive:!0})}const wt=document.querySelector("#app");if(!wt)throw new Error("缺少 #app 挂载点");Ve();ye();new Fe(wt).start(); diff --git a/assets/index-DfhOSYdI.js b/assets/index-DfhOSYdI.js new file mode 100644 index 0000000..ccb7658 --- /dev/null +++ b/assets/index-DfhOSYdI.js @@ -0,0 +1,3 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-BgLl3BAq.js","assets/strategy-C52a_06u.js","assets/index-DSJFV8_4.js"])))=>i.map(i=>d[i]); +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const c of o.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&n(c)}).observe(document,{childList:!0,subtree:!0});function s(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function n(i){if(i.ep)return;i.ep=!0;const o=s(i);fetch(i.href,o)}})();const Tt="zenchat.andyccr.v1",u=1,Lt=24,Pt=64,nt=4e3,it=10,S=200,Ct=S*2,It="lobby",Rt=2500,At=1800,rt=25e3,Mt=5e3,Nt=rt*3,_t=8e3,xt="https://github.com/Andyccr/ZenChat",A="zenchat.nick",D="zenchat.peerId",J=["闲云","青石","疏影","晚风","折柳","听雨","归舟","松风"];function M(e){const t=new Uint8Array(e);return crypto.getRandomValues(t),[...t].map(s=>s.toString(16).padStart(2,"0")).join("")}const B=["#c15f3c","#8b5e3c","#5c6b4a","#6b4f3a","#9a6b3f","#4a6b62","#7a4e3a","#5a5568"];function ot(e){let t=2166136261;for(let s=0;s{const i=n.codePointAt(0)??0;return i>=32&&i!==127}).join("").slice(0,Lt)}function at(e){const t=Number.parseInt(e.slice(0,4),16)%J.length;return J[t]??"闲云"}function ct(){const e=sessionStorage.getItem(D),t=e&&/^[0-9a-f]{16,}$/i.test(e)?e:M(12);sessionStorage.setItem(D,t);const s=localStorage.getItem(A),n=b(s??"")||at(t);return localStorage.setItem(A,n),{id:t,nick:n}}function H(e){const t=b(e)||at(ct().id);return localStorage.setItem(A,t),t}function jt(e){return e==="torrent"||e==="nostr"}function g(e){return e.normalize("NFKC").trim().replace(/\s+/g,"-").replace(/[/#?&]+/g,"").slice(0,Pt)}function Ot(e){const t=g(e.name);return e.password?`${t}::${e.password}`:t}function N(e){return`${e.strategy}:${g(e.name)}`}function _(e){return`${e.strategy}:${g(e.name)}:${e.password}`}function $t(e,t){return _(e)===_(t)}const Dt="zenchat.cache.v1.";function lt(e){return Dt+_(e)}function Jt(e){try{const t=sessionStorage.getItem(lt(e));if(!t)return[];const s=JSON.parse(t);return Array.isArray(s)?s.filter(Kt).slice(-S):[]}catch{return[]}}function Bt(e){return x(e.map(Ht))}function K(e,t){try{sessionStorage.setItem(lt(e),JSON.stringify(Bt(t)))}catch{}}function Ht(e){return e.kind!=="chat"||e.delivery!=="pending"?e:{kind:"chat",id:e.id,fromId:e.fromId,nick:e.nick,text:e.text,ts:e.ts,self:e.self}}function x(e){return e.length>S?e.slice(-S):e}function Kt(e){if(typeof e!="object"||e===null)return!1;const t=e;return t.kind==="system"?typeof t.id=="string"&&typeof t.text=="string"&&typeof t.ts=="number":t.kind==="chat"?typeof t.id=="string"&&typeof t.fromId=="string"&&typeof t.nick=="string"&&typeof t.text=="string"&&typeof t.ts=="number"&&typeof t.self=="boolean":!1}const Ft="zenchat.pass.v1.";function ht(e){return Ft+N(e)}function j(e){try{const t=ht(e);e.password?sessionStorage.setItem(t,e.password):sessionStorage.removeItem(t)}catch{}}function dt(e){try{return sessionStorage.getItem(ht(e))??""}catch{return""}}function F(e){if(e.password)return j(e),e;const t=dt(e);return t?{...e,password:t}:e}const ut="zenchat.recentRooms";function pt(){try{const e=localStorage.getItem(ut);if(!e)return[];const t=JSON.parse(e);return Array.isArray(t)?t.filter(s=>typeof s=="object"&&s!==null&&typeof s.name=="string"&&(s.strategy==="torrent"||s.strategy==="nostr")&&typeof s.hasPassword=="boolean"&&typeof s.visitedAt=="number").slice(0,it):[]}catch{return[]}}function zt(e){const t={name:e.name,strategy:e.strategy,hasPassword:!!e.password,visitedAt:Date.now()},s=pt().filter(i=>N(i)!==N(e)),n=[t,...s].slice(0,it);return localStorage.setItem(ut,JSON.stringify(n)),n}function Vt(e,t){const s=dt(e),i=(!!(t&&g(e.name)===g(t.name)&&e.strategy===t.strategy)?t?.password??"":"")||s;return e.hasPassword&&!i?"need-password":{name:e.name,password:i,strategy:e.strategy}}class Ut{constructor(t,s,n,i){this.runtime=t,this.helloMs=s,this.pollMs=n,this.handlers=i}runtime;helloMs;pollMs;handlers;helloTimer=null;pollTimer=null;unbindVisibility=null;start(){this.stop(),this.unbindVisibility=this.runtime.onVisibilityChange(this.onVisibility),this.startTimers()}stop(){this.unbindVisibility?.(),this.unbindVisibility=null,this.stopTimers()}onVisibility=()=>{if(this.runtime.hidden()){this.stopTimers();return}this.startTimers(),this.handlers.onHello(),this.handlers.onPoll()};startTimers(){this.stopTimers(),this.helloTimer=this.runtime.setInterval(()=>this.handlers.onHello(),this.helloMs),this.pollTimer=this.runtime.setInterval(()=>this.handlers.onPoll(),this.pollMs)}stopTimers(){this.helloTimer!==null&&(this.runtime.clearInterval(this.helloTimer),this.helloTimer=null),this.pollTimer!==null&&(this.runtime.clearInterval(this.pollTimer),this.pollTimer=null)}}class Wt{constructor(t,s,n){this.runtime=t,this.timeoutMs=s,this.onExpire=n}runtime;timeoutMs;onExpire;timers=new Map;payloads=new Map;expect(t,s){this.cancelTimer(t),s!==void 0&&this.payloads.set(t,s),this.timers.set(t,this.runtime.setTimeout(()=>{this.timers.delete(t),this.onExpire(t)},this.timeoutMs))}ack(t){return!this.timers.has(t)&&!this.payloads.has(t)?!1:(this.cancel(t),!0)}payload(t){return this.payloads.get(t)}cancel(t){this.cancelTimer(t),this.payloads.delete(t)}clear(){for(const t of[...this.timers.keys(),...this.payloads.keys()])this.cancel(t)}cancelTimer(t){const s=this.timers.get(t);s!==void 0&&this.runtime.clearTimeout(s),this.timers.delete(t)}}const T="ack";function Gt(e){return typeof e=="object"&&e!==null}function qt(e){return e===u}function Yt(e){return Array.isArray(e)?[...new Set(e.filter(t=>t===T))]:[]}function z(e){return typeof e!="string"||e.length<8||e.length>80?null:e}function V(e,t,s,n){return{v:u,type:"chat",id:s,ts:n,nick:b(e),text:t.trim().slice(0,nt)}}function U(e){return{v:u,type:"hello",nick:b(e),features:[T]}}function Xt(e){return{v:u,type:"typing",nick:b(e)}}function W(e,t){return{v:u,type:"ack",id:t,nick:b(e)}}function Zt(e){if(!Gt(e)||!qt(e.v)||typeof e.type!="string")return null;const t=typeof e.nick=="string"?b(e.nick):"";if(!t)return null;if(e.type==="hello")return{v:u,type:"hello",nick:t,features:Yt(e.features)};if(e.type==="typing")return{v:u,type:"typing",nick:t};if(e.type==="ack"){const s=z(e.id);return s?{v:u,type:"ack",id:s,nick:t}:null}if(e.type==="chat"){const s=z(e.id);if(!s||typeof e.ts!="number"||!Number.isFinite(e.ts)||typeof e.text!="string")return null;const n=e.text.trim().slice(0,nt);return n?{v:u,type:"chat",id:s,ts:e.ts,nick:t,text:n}:null}return null}function Qt(e,t){const s=Zt(e);return s?s.type==="hello"?{type:"hello",nick:s.nick,features:s.features}:s.type==="typing"?{type:"typing",nick:s.nick}:s.type==="ack"?{type:"ack",nick:s.nick,id:s.id}:{type:"chat",nick:s.nick,id:s.id,text:s.text,ts:s.ts,duplicate:t(s.id)}:null}class te{constructor(t,s=()=>{},n=Rt){this.runtime=t,this.onTypingEnd=s,this.typingTtl=n}runtime;onTypingEnd;typingTtl;members=new Map;typingTimers=new Map;upsert(t,s,n){const i=this.members.get(t),o=this.runtime.now(),c={id:t,nick:s,joinedAt:i?.joinedAt??o,lastSeenAt:o,rttMs:i?.rttMs??null,typing:i?.typing??!1,features:n??i?.features??[]};return this.members.set(t,c),c}supports(t){return this.list().some(s=>s.features.includes(t))}remove(t){this.clearTypingTimer(t);const s=this.members.get(t);return this.members.delete(t),s}markTyping(t){const s=this.members.get(t);return s?(s.typing=!0,this.clearTypingTimer(t),this.typingTimers.set(t,this.runtime.setTimeout(()=>{const n=this.members.get(t);n&&(n.typing=!1,this.onTypingEnd(t))},this.typingTtl)),!0):!1}setRtt(t,s){const n=this.members.get(t);return n?(n.rttMs=s,!0):!1}list(){return[...this.members.values()].sort((t,s)=>t.joinedAt-s.joinedAt)}prune(t){const s=this.runtime.now()-t,n=this.list().filter(i=>i.lastSeenAtDate.now(),hidden:()=>document.hidden,setTimeout:(e,t)=>window.setTimeout(e,t),clearTimeout:e=>window.clearTimeout(e),setInterval:(e,t)=>window.setInterval(e,t),clearInterval:e=>window.clearInterval(e),onVisibilityChange(e){return document.addEventListener("visibilitychange",e),()=>document.removeEventListener("visibilitychange",e)}};function se(e,t){let s=Number.NEGATIVE_INFINITY;return(...n)=>{const i=Date.now();i-se(...n);let t=0,s=null;return(...n)=>{s=n,!t&&(t=requestAnimationFrame(()=>{t=0;const i=s;s=null,i&&e(...i)}))}}function mt(){return{state:"idle",peers:0,relaysDown:!1}}function E(e){return e.state==="joining"||e.state==="waiting"||e.state==="live"||e.state==="relay_down"}function C(e){return e.state==="idle"||e.state==="failed"||e.state==="joining"||e.state==="leaving"?e:e.peers>0?{...e,state:"live"}:e.relaysDown?{...e,state:"relay_down"}:{...e,state:"waiting"}}function y(e,t){switch(t.type){case"join":return e.state==="leaving"?e:{state:"joining",peers:0,relaysDown:!1};case"join_ok":return e.state!=="joining"?e:C({...e,state:"waiting"});case"join_err":return e.state!=="joining"?e:{state:"failed",peers:0,relaysDown:!1};case"peers":return E(e)?C({...e,peers:t.count}):e;case"relays":return E(e)?C({...e,relaysDown:t.down}):e;case"force_down":return!E(e)||e.peers>0?e:{...e,relaysDown:!0,state:"relay_down"};case"leave":return e.state==="idle"?e:{...e,state:"leaving"};case"left":return mt()}}function ie(e){return e.state==="live"?"connected":e.state==="failed"||e.state==="relay_down"?"error":e.state==="idle"?"idle":"connecting"}const p={idle:"尚未连接",left:"已离开房间",connectingTorrent:"正在连接 Tracker…",connectingNostr:"正在连接 Nostr…",waiting:"还没有同伴,把链接发给对方",handshake:"握手受阻,仍在重试",relaysDownTorrent:"Tracker 连不上,可改用 Nostr",relaysDownNostr:"Nostr 中继连不上,可改用 Tracker",sendFailed:"刚才那条没发出去,请再试一次"};function ft(e){return e==="nostr"?p.connectingNostr:p.connectingTorrent}function re(e){return`已直连 ${e} 人`}function oe(e){return`无法启动 P2P:${e}`}function ae(e){return e.filter(t=>t.readyState===1).length}function yt(e){return e.length>0&&ae(e)===0}function ce(e,t){return yt(t)?{phase:"error",detail:e==="nostr"?p.relaysDownNostr:p.relaysDownTorrent}:{phase:"connecting",detail:p.waiting}}function le(e){if(e.life.state==="failed")return null;if(e.life.state==="live")return{phase:ie(e.life),detail:re(e.peerCount),relays:e.relays,peerCount:e.peerCount};const t=e.life.state==="relay_down"&&e.relays.length===0?[{url:"local",readyState:3}]:e.relays,s=ce(e.strategy,t);return{phase:s.phase,detail:e.life.state==="joining"?ft(e.strategy):s.detail,relays:e.relays,peerCount:e.peerCount}}function he(e,t){return e.phase===t.phase&&e.detail===t.detail&&e.peerCount===t.peerCount&&e.relays.length===t.relays.length&&e.relays.every((s,n)=>s.readyState===t.relays[n]?.readyState)}function de(e){return e.kind!=="chat"||e.delivery!=="pending"?e:L({id:e.id,fromId:e.fromId,nick:e.nick,text:e.text,ts:e.ts,self:e.self})}class ue{lines=[];seen=new Set;seenOrder=[];hydrate(t){this.clear(),this.lines=x(t.map(de));for(const s of this.lines)s.kind==="chat"&&this.remember(s.id);return this.lines}snapshot(){return this.lines}get(t){return this.lines.find(s=>s.id===t)}has(t){return this.seen.has(t)}remember(t){if(!this.seen.has(t))for(this.seen.add(t),this.seenOrder.push(t);this.seenOrder.length>Ct;){const s=this.seenOrder.shift();s&&this.seen.delete(s)}}append(t){if(t.kind==="chat"){if(this.seen.has(t.id))return null;this.remember(t.id)}return this.lines.push(t),this.lines=x(this.lines),t}patchDelivery(t,s){const n=this.lines.findIndex(c=>c.id===t),i=n>=0?this.lines[n]:void 0;if(n<0||!i||i.kind!=="chat")return null;const o=L({id:i.id,fromId:i.fromId,nick:i.nick,text:i.text,ts:i.ts,self:i.self,...s?{delivery:s}:{}});return this.lines[n]=o,o}clear(){this.lines=[],this.seen.clear(),this.seenOrder=[]}}function L(e){return e.delivery?{kind:"chat",...e,delivery:e.delivery}:{kind:"chat",id:e.id,fromId:e.fromId,nick:e.nick,text:e.text,ts:e.ts,self:e.self}}function pe(e,t,s){return{kind:"system",id:e,text:t,ts:s}}const me="modulepreload",fe=function(e){return"/ZenChat/"+e},G={},q=function(t,s,n){let i=Promise.resolve();if(s&&s.length>0){let m=function(d){return Promise.all(d.map(w=>Promise.resolve(w).then(v=>({status:"fulfilled",value:v}),v=>({status:"rejected",reason:v}))))};var c=m;document.getElementsByTagName("link");const h=document.querySelector("meta[property=csp-nonce]"),l=h?.nonce||h?.getAttribute("nonce");i=m(s.map(d=>{if(d=fe(d),d in G)return;G[d]=!0;const w=d.endsWith(".css"),v=w?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${d}"]${v}`))return;const f=document.createElement("link");if(f.rel=w?"stylesheet":me,w||(f.as="script"),f.crossOrigin="",f.href=d,l&&f.setAttribute("nonce",l),document.head.appendChild(f),w)return new Promise((Et,St)=>{f.addEventListener("load",Et),f.addEventListener("error",()=>St(new Error(`Unable to preload CSS for ${d}`)))})}))}function o(h){const l=new Event("vite:preloadError",{cancelable:!0});if(l.payload=h,window.dispatchEvent(l),!l.defaultPrevented)throw h}return i.then(h=>{for(const l of h||[])l.status==="rejected"&&o(l.reason);return t().catch(o)})},ye=[{urls:"stun:stun.cloudflare.com:3478"},{urls:"stun:stun.l.google.com:19302"}],ge={iceServers:ye,iceCandidatePoolSize:8,bundlePolicy:"max-bundle",rtcpMuxPolicy:"require"},be=["wss://tracker.webtorrent.dev","wss://tracker.openwebtorrent.com","wss://open.ftorrent.com:443"],we=["wss://relay.damus.io","wss://nos.lol","wss://relay.snort.social","wss://nostr.wine"];function ve(e){return Object.entries(e).map(([t,s])=>({url:t,readyState:s?.readyState??WebSocket.CLOSED}))}class ke{strategy;room=null;action=null;module=null;constructor(t){this.strategy=t}selfId(){return this.module?.selfId??""}async join(t,s){await this.leave(),this.module=await O(t.strategy);const n=t.strategy==="torrent"?[...be]:[...we],i=this.module.joinRoom({appId:t.appId,...t.password?{password:t.password}:{},rtcConfig:ge,trickleIce:!0,relayConfig:{urls:n}},t.roomId,{onJoinError:c=>{s.onJoinError(c.error||"无法与对等节点完成握手")}}),o=i.makeAction("zen");o.onMessage=(c,{peerId:h})=>{s.onPayload(h,c)},i.onPeerJoin=c=>s.onPeerJoin(c),i.onPeerLeave=c=>s.onPeerLeave(c),this.room=i,this.action=o}async leave(){const t=this.room;if(this.room=null,this.action=null,t)try{await t.leave()}catch{}}async send(t,s){if(!this.action)throw new Error("尚未加入房间");await this.action.send(t,s?{target:s}:void 0)}ping(t){return this.room?this.room.ping(t):Promise.reject(new Error("未连接"))}peerIds(){return this.room?Object.keys(this.room.getPeers()):[]}relays(){return this.module?ve(this.module.getRelaySockets()):[]}}const Y=new Map;async function O(e){const t=Y.get(e);if(t)return t;const s=e==="torrent"?q(()=>import("./index-BgLl3BAq.js"),__vite__mapDeps([0,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId})):q(()=>import("./index-DSJFV8_4.js"),__vite__mapDeps([2,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId}));return Y.set(e,s),s}function Ee(){O("torrent"),O("nostr")}function Se(e){return new ke(e)}class Te{transport;identity;listeners;createTransport;runtime;transcript=new ue;presence;outbound;heartbeat;life=mt();strategy="torrent";joinErrors=0;status={phase:"idle",detail:p.idle,relays:[],peerCount:0};emitMembers=ne(()=>{this.listeners.onMembers?.(this.presence.list())});sendTypingThrottled=se(()=>{this.isJoined()&&this.transport.send(Xt(this.identity.nick))},At);constructor(t,s,n={}){this.identity=t,this.listeners=s,this.createTransport=n.createTransport??Se,this.runtime=n.runtime??ee,this.transport=this.createTransport("torrent"),this.presence=new te(this.runtime,()=>this.emitMembers()),this.outbound=new Wt(this.runtime,_t,i=>this.expireAck(i)),this.heartbeat=new Ut(this.runtime,rt,Mt,{onHello:()=>this.announce(),onPoll:()=>this.poll()})}get selfId(){return this.transport.selfId()||this.identity.id}isJoined(){return E(this.life)}getLines(){return this.transcript.snapshot()}hydrate(t){this.listeners.onReset?.(this.transcript.hydrate(t))}async join(t){this.isJoined()&&await this.leave({silent:!0}),this.strategy=t.strategy,this.joinErrors=0,this.transport=this.createTransport(t.strategy),this.life=y(this.life,{type:"join"}),this.setStatus({phase:"connecting",detail:ft(t.strategy),relays:[],peerCount:0});try{await this.transport.join({appId:Tt,roomId:Ot({name:g(t.name),password:t.password}),password:t.password,strategy:t.strategy},{onPeerJoin:s=>this.onPeerJoin(s),onPeerLeave:s=>this.onPeerLeave(s),onPayload:(s,n)=>this.handlePayload(s,n),onJoinError:()=>this.onJoinError()})}catch(s){this.life=y(this.life,{type:"join_err"});const n=s instanceof Error?s.message:"连接失败";throw this.refreshStatus("error",oe(n)),s}this.life=y(this.life,{type:"join_ok"}),this.heartbeat.start(),this.announce(),this.emitStatus()}async sendChat(t){if(!this.isJoined())return"closed";const s=V(this.identity.nick,t,M(8),this.runtime.now());return s.text?this.dispatch(s,this.presence.supports(T)):"empty"}async resend(t){if(!this.isJoined())return"closed";const s=this.transcript.get(t);if(!s||s.kind!=="chat"||!s.self||s.delivery!=="failed"&&s.delivery!=="pending")return"empty";const i=this.outbound.payload(t)??V(this.identity.nick,s.text,s.id,s.ts);return this.dispatch(i,this.presence.supports(T),!0)}sendTyping(){this.sendTypingThrottled()}setNick(t){this.identity={...this.identity,nick:t},this.isJoined()&&this.announce()}async leave(t={}){this.life=y(this.life,{type:"leave"}),this.heartbeat.stop(),this.outbound.clear(),this.presence.clear(),await this.transport.leave(),this.transcript.clear(),this.life=y(this.life,{type:"left"}),t.silent||(this.setStatus({phase:"idle",detail:p.left,relays:[],peerCount:0}),this.emitMembers(),this.listeners.onReset?.([]))}async dispatch(t,s,n=!1){n?this.patchDelivery(t.id,s?"pending":void 0):this.pushLine(L({id:t.id,fromId:this.selfId,nick:this.identity.nick,text:t.text,ts:t.ts,self:!0,...s?{delivery:"pending"}:{}}));try{await this.transport.send(t)}catch{return this.patchDelivery(t.id,"failed"),this.pushSystem(p.sendFailed),"failed"}return s&&this.outbound.expect(t.id,t),"sent"}handlePayload(t,s){const n=Qt(s,i=>this.transcript.has(i));if(n){if(n.type==="hello"?this.presence.upsert(t,n.nick,n.features):this.presence.upsert(t,n.nick),n.type==="hello"){this.emitMembers();return}if(n.type==="typing"){this.presence.markTyping(t),this.emitMembers();return}if(n.type==="ack"){this.outbound.ack(n.id)&&this.patchDelivery(n.id,"acked");return}if(n.duplicate){this.transport.send(W(this.identity.nick,n.id),t);return}this.pushLine(L({id:n.id,fromId:t,nick:n.nick,text:n.text,ts:n.ts,self:!1})),this.transport.send(W(this.identity.nick,n.id),t),this.emitMembers()}}onPeerJoin(t){this.presence.upsert(t,"访客"),this.pushSystem(`${t.slice(0,6)} 加入了房间`),this.transport.send(U(this.identity.nick),t),this.emitMembers(),this.notePeers(),this.runtime.setTimeout(()=>{this.measure(t)},350)}onPeerLeave(t){const s=this.presence.remove(t);this.pushSystem(`${s?.nick??t.slice(0,6)} 离开了房间`),this.emitMembers(),this.notePeers()}onJoinError(){this.joinErrors+=1,!(this.transport.peerIds().length>0)&&(this.joinErrors>=3?(this.life=y(this.life,{type:"force_down"}),this.emitStatus()):this.refreshStatus("connecting",p.handshake))}expireAck(t){this.isJoined()&&this.patchDelivery(t,"failed")}patchDelivery(t,s){const n=this.transcript.patchDelivery(t,s);n&&this.listeners.onLine?.(n)}async measure(t){if(this.isJoined())try{const s=await this.transport.ping(t);this.isJoined()&&this.presence.setRtt(t,s)&&this.emitMembers()}catch{}}announce(){this.isJoined()&&this.transport.send(U(this.identity.nick))}poll(){if(!this.isJoined())return;const t=this.presence.prune(Nt);for(const s of t)this.pushSystem(`${s.nick} 离开了房间`);t.length>0&&this.emitMembers(),this.notePeers();for(const s of this.transport.peerIds())this.measure(s)}pushSystem(t){this.pushLine(pe(M(6),t,this.runtime.now()))}pushLine(t){const s=this.transcript.append(t);s&&this.listeners.onLine?.(s)}notePeers(){this.life=y(this.life,{type:"peers",count:this.presence.list().length}),this.emitStatus()}emitStatus(){this.life=y(this.life,{type:"relays",down:yt(this.transport.relays())});const t=le({life:this.life,strategy:this.strategy,peerCount:this.presence.list().length,relays:this.transport.relays()});t&&this.setStatus(t)}refreshStatus(t,s){this.setStatus({phase:t,detail:s,relays:this.transport.relays(),peerCount:this.presence.list().length})}setStatus(t){he(this.status,t)||(this.status=t,this.listeners.onStatus?.(t))}}class Le{identity;listeners;options;session=null;spec=null;generation=0;constructor(t,s,n={}){this.identity=t,this.listeners=s,this.options=n}current(){return this.spec}isJoined(){return this.session?.isJoined()??!1}selfId(){return this.session?.selfId??this.identity.id}sendChat(t){return this.session?.sendChat(t)??Promise.resolve("closed")}resend(t){return this.session?.resend(t)??Promise.resolve("closed")}sendTyping(){this.session?.sendTyping()}retry(){const t=this.spec;return t?this.open(t,!0):Promise.resolve(!1)}setIdentity(t){this.identity=t,this.session?.setNick(t.nick)}async open(t,s=!1){if(!s&&this.spec&&this.session?.isJoined()&&$t(this.spec,t))return!0;const n=++this.generation;if(await this.snapshotAndClose(),n!==this.generation)return!1;zt(t),this.listeners.onMembers?.([]);const i=new Te(this.identity,this.listeners,this.options),o=Jt(t);o.length>0?i.hydrate(o):this.listeners.onReset?.([]),this.session=i,this.spec=t;try{await i.join(t)}catch(c){if(n!==this.generation)return await i.leave({silent:!0}),!1;throw c}return n!==this.generation?(await i.leave({silent:!0}),!1):!0}async close(){this.generation+=1,await this.snapshotAndClose()}snapshot(){this.session&&this.spec&&K(this.spec,this.session.getLines())}async snapshotAndClose(){this.session&&this.spec&&(K(this.spec,this.session.getLines()),await this.session.leave({silent:!0})),this.session=null,this.spec=null}}function X(e,t){return e.get(t)??""}function Pe(e){const t=e.startsWith("#")?e.slice(1):e,s=new URL(t||"/","https://zenchat.local"),n=s.pathname.split("/").filter(Boolean);if(n[0]==="r"&&n[1]){const i=g(decodeURIComponent(n[1]));if(!i)return{name:"lobby"};const o=X(s.searchParams,"s"),c=jt(o)?o:"torrent";return{name:"room",spec:{name:i,password:X(s.searchParams,"k"),strategy:c}}}return{name:"lobby"}}function P(e,t=!1){if(e.name==="lobby")return"#/";const s=new URLSearchParams;s.set("s",e.spec.strategy),t&&e.spec.password&&s.set("k",e.spec.password);const n=s.toString();return`#/r/${encodeURIComponent(e.spec.name)}${n?`?${n}`:""}`}function Ce(e){if(typeof BroadcastChannel>"u")return()=>{};const t=new BroadcastChannel("zenchat.tab"),s=n=>{n.data==="ping"&&t.postMessage("pong"),n.data==="pong"&&e()};return t.addEventListener("message",s),t.postMessage("ping"),()=>{t.removeEventListener("message",s),t.close()}}const a={title:"禅聊",tagline:"无服务器 · 浏览器直连",source:"源码",nick:"昵称",room:"房间",password:"口令",passwordHint:"口令只隔离房间,存在这个标签页,不会上传。",strategy:"信令",torrent:"Tracker",nostr:"Nostr",join:"加入",more:"更多选项",lobbyHint:"同一房间即可直连,无需账号。",tipSame:"两个窗口进同一个房间就能互发。",tipHttps:"请用 HTTPS 或 localhost。",tipNat:"对称 NAT 可能连不上(静态站没有 TURN)。",recent:"最近",share:"分享",shareWithKey:"含口令",leave:"大厅",send:"发送",placeholder:"发消息",members:"在线",you:"你",waiting:"还没有同伴,把链接发给对方。",copied:"已复制",shared:"已分享",copyFailed:"无法复制,请手动复制地址栏。",webrtcMissing:"需要支持 WebRTC 的浏览器,并用 HTTPS 打开。",insecure:"需要 HTTPS 或 localhost。",switch:"切换房间",addRoom:"加入房间",stamps:"表情",kaomoji:"颜文字",emoji:"表情",people:"成员",cancel:"取消",retry:"重试",switchSignal:"换信令",locked:"口令",duplicateTab:"另一个标签页也开着禅聊,会显示成两个人。",theme:"主题",delivered:"送达",sending:"发送中",undelivered:"未送达",resend:"点此重试"};function r(e,t={},s=[]){const n=document.createElement(e);for(const[i,o]of Object.entries(t))if(!(o===void 0||o===!1)){if(i==="class"){n.className=String(o);continue}if(typeof o=="boolean"){n.toggleAttribute(i,o);continue}if(i==="style"){n.setAttribute("style",String(o));continue}if(i in n&&i!=="list"){n[i]=o;continue}n.setAttribute(i,String(o))}for(const i of s)n.append(typeof i=="string"?document.createTextNode(i):i);return n}function $(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function Ie(e){return new Intl.DateTimeFormat("zh-CN",{hour:"2-digit",minute:"2-digit",hour12:!1}).format(e)}function Re(e){switch(e){case WebSocket.CONNECTING:return"连接中";case WebSocket.OPEN:return"已连接";case WebSocket.CLOSING:return"断开中";default:return"未连接"}}function Ae(e){try{return new URL(e).host}catch{return e}}function gt(){return typeof RTCPeerConnection=="function"}function Z(){return window.isSecureContext&>()}function Me(){return window.isSecureContext?gt()?r("div"):r("div",{class:"banner"},[a.webrtcMissing]):r("div",{class:"banner"},[a.insecure])}const bt=["(´・ω・`)","(。•́︿•̀。)","(´▽`ʃ♡ƪ)","(╯°□°)╯︵ ┻━┻","¯\\_(ツ)_/¯","(づ。◕‿‿◕。)づ","(ง'̀-'́)ง","( ̄▽ ̄)","(•̀ᴗ•́)و","ฅ^•ﻌ•^ฅ","(´;ω;`)","(≧∇≦)ノ","(´∀`)♡","┐( ̄ヘ ̄)┌"],wt=["😀","😂","🥰","😎","🤔","😭","🙏","✨","🔥","🎉","👍","👋","❤️","🌸","🍵","🌙"],Ne=new Set(bt),_e=new Set(wt);function xe(e){const t=e.trim();return t?Ne.has(t)||_e.has(t)?!0:t.length<=8&&/^[\p{Emoji_Presentation}\s]+$/u.test(t):!1}function Q(e,t){const s=e.selectionStart,n=e.selectionEnd,i=`${e.value.slice(0,s)}${t}${e.value.slice(n)}`;e.value=i;const o=s+t.length;e.setSelectionRange(o,o),e.focus(),e.dispatchEvent(new Event("input",{bubbles:!0}))}class je{constructor(t){this.onResend=t,this.el=r("div",{class:"log",role:"log","aria-live":"polite"})}onResend;el;ids=new Set;nodes=new Map;get size(){return this.ids.size}reset(t){this.el.replaceChildren(),this.ids.clear(),this.nodes.clear();for(const s of t)this.append(s,!1);this.stick()}append(t,s=!0){if(this.ids.has(t.id)){this.replace(t);return}this.ids.add(t.id);const n=this.render(t);this.nodes.set(t.id,n),this.el.append(n),s&&this.stick()}replace(t){const s=this.nodes.get(t.id);if(!s)return;const n=this.render(t);this.nodes.set(t.id,n),s.replaceWith(n)}render(t){return Oe(t,this.onResend)}stick(){const t=this.el;requestAnimationFrame(()=>{t.scrollHeight-t.scrollTop-t.clientHeight<96&&(t.scrollTop=t.scrollHeight)})}}function Oe(e,t){if(e.kind==="system")return r("div",{class:"system","data-id":e.id},[e.text]);const s=xe(e.text),n=$e(e,t),i=r("header",{},[r("b",{style:`color:${ot(e.fromId)}`},[e.nick]),r("time",{},[Ie(e.ts)]),...n?[n]:[]]);return r("article",{class:`bubble${e.self?" self":""}${s?" stamp":""}${e.delivery==="failed"?" failed":""}`,"data-id":e.id},[i,r("p",{},[e.text])])}function $e(e,t){if(!e.self||!e.delivery)return null;if(e.delivery==="acked")return r("span",{class:"delivery"},[a.delivered]);if(e.delivery==="pending")return r("span",{class:"delivery pending"},[a.sending]);const s=r("button",{class:"delivery fail",type:"button",title:a.resend},[a.undelivered]);return s.addEventListener("click",n=>{n.stopPropagation(),t?.(e.id)}),s}class De{el;open=!1;constructor(t){const s=r("div",{class:"picker-grid kaomoji"},bt.map(c=>tt(c,()=>Q(t,c)))),n=r("div",{class:"picker-grid emoji hidden"},wt.map(c=>tt(c,()=>Q(t,c)))),i=r("button",{class:"tab on",type:"button"},[a.kaomoji]),o=r("button",{class:"tab",type:"button"},[a.emoji]);i.addEventListener("click",()=>{s.classList.remove("hidden"),n.classList.add("hidden"),i.classList.add("on"),o.classList.remove("on")}),o.addEventListener("click",()=>{n.classList.remove("hidden"),s.classList.add("hidden"),o.classList.add("on"),i.classList.remove("on")}),this.el=r("div",{class:"picker hidden",role:"dialog","aria-label":a.stamps},[r("div",{class:"picker-tabs"},[i,o]),s,n])}toggle(){this.open=!this.open,this.el.classList.toggle("hidden",!this.open)}hide(){this.open=!1,this.el.classList.add("hidden")}}function tt(e,t){const s=r("button",{class:"stamp-btn",type:"button"},[e]);return s.addEventListener("click",t),s}class Je{constructor(t){this.handlers=t,this.log=new je(l=>{this.handlers.resend(l)}),this.statusEl=r("div",{class:"status"}),this.actionsEl=r("div",{class:"status-actions"}),this.membersEl=r("div",{class:"members"}),this.vacantEl=r("div",{class:"vacant"},[a.waiting]),this.peopleBtn=r("button",{class:"btn ghost people",type:"button","aria-expanded":"false"},[a.people]),this.peopleBtn.addEventListener("click",l=>{l.stopPropagation(),this.root.classList.toggle("show-side"),this.peopleBtn.setAttribute("aria-expanded",this.root.classList.contains("show-side")?"true":"false")});const s=r("textarea",{rows:1,placeholder:a.placeholder,maxlength:4e3,enterkeyhint:"send","aria-label":a.placeholder});s.setAttribute("enterkeyhint","send"),s.setAttribute("autocapitalize","sentences");const n=new De(s),i=r("button",{class:"btn ghost",type:"button",title:a.stamps},["顔"]),o=r("button",{class:"btn primary send",type:"button","aria-label":a.send},["↑"]);i.addEventListener("click",l=>{l.stopPropagation(),n.toggle()}),this.peopleBtn.addEventListener("pointerdown",l=>l.stopPropagation()),document.addEventListener("pointerdown",l=>{this.root.contains(l.target)&&(l.target.closest(".picker, .people, .side")||n.hide())}),n.el.addEventListener("click",l=>l.stopPropagation());const c=()=>{const l=s.value;l.trim()&&(s.value="",s.style.height="auto",n.hide(),this.handlers.send(l).then(m=>{(m==="failed"||m==="closed")&&(s.value=l)}))};o.addEventListener("click",c),s.addEventListener("keydown",l=>{l.key==="Enter"&&!l.shiftKey&&(l.preventDefault(),c())}),s.addEventListener("input",()=>{s.style.height="auto",s.style.height=`${Math.min(s.scrollHeight,96)}px`,this.handlers.typing()}),this.nickInput=r("input",{class:"nick-edit",maxlength:24,value:t.nick,"aria-label":a.nick});const h=()=>this.handlers.onNick(this.nickInput.value);this.nickInput.addEventListener("change",h),this.nickInput.addEventListener("keydown",l=>{l.key==="Enter"&&(l.preventDefault(),this.nickInput.blur())}),this.selfIdEl=r("span",{class:"muted"},[t.selfId.slice(0,8)]),this.selfRow=r("div",{class:"member self"},[r("label",{class:"nick-label"},[r("span",{},[a.you]),this.nickInput]),this.selfIdEl]),this.membersEl.append(this.selfRow),this.root=r("div",{class:"chat"},[r("section",{class:"transcript"},[r("div",{class:"status-row"},[this.statusEl,this.actionsEl,this.peopleBtn]),this.vacantEl,this.log.el,n.el,r("div",{class:"composer"},[i,s,o])]),r("aside",{class:"side"},[r("h2",{},[a.members]),this.membersEl])])}handlers;root;log;statusEl;actionsEl;membersEl;selfRow;selfIdEl;vacantEl;peopleBtn;nickInput;lastStatus="";lastPeerCount=0;hide(){this.root.hidden=!0,this.root.classList.remove("show-side"),this.peopleBtn.setAttribute("aria-expanded","false")}show(){this.root.hidden=!1}push(t){this.log.append(t),this.vacantEl.hidden=!0}reset(t){this.log.reset(t),this.vacantEl.hidden=t.length>0||this.lastPeerCount>0}setNick(t){document.activeElement!==this.nickInput&&(this.nickInput.value=t)}status(t){const s=t.relays.filter(o=>o.readyState===WebSocket.OPEN).length,n=`${t.phase}|${t.detail}|${t.peerCount}|${s}|${this.log.size}`;if(n===this.lastStatus)return;this.lastStatus=n,this.lastPeerCount=t.peerCount;const i=t.phase==="connected"?" ok":t.phase==="error"?" err":"";this.statusEl.replaceChildren(r("i",{class:`dot${i}`}),r("span",{},[` ${t.detail}`])),this.peopleBtn.textContent=`${a.people} ${t.peerCount}`,this.statusEl.title=t.relays.map(o=>`${Ae(o.url)} ${Re(o.readyState)}`).join(` +`),this.renderActions(t),this.vacantEl.hidden=t.peerCount>0||this.log.size>0}members(t,s){for(this.selfIdEl.textContent=s.slice(0,8);this.membersEl.lastChild&&this.membersEl.lastChild!==this.selfRow;)this.membersEl.removeChild(this.membersEl.lastChild);for(const n of t)this.membersEl.append(r("div",{class:"member"},[r("b",{style:`color:${ot(n.id)}`},[n.nick]),r("span",{class:"muted"},[n.typing?"输入中":n.rttMs!==null?`${Math.round(n.rttMs)}ms`:""])]))}renderActions(t){if($(this.actionsEl),t.phase==="error"){this.actionsEl.append(I(a.retry,this.handlers.onRetry),I(a.switchSignal,this.handlers.onSwitchSignal));return}t.phase==="connecting"&&t.peerCount===0&&this.actionsEl.append(I(a.share,this.handlers.onShare))}}function I(e,t){const s=r("button",{class:"btn ghost compact",type:"button"},[e]);return s.addEventListener("click",n=>{n.stopPropagation(),t()}),s}function Be(e){const t=r("input",{placeholder:a.room,maxlength:64,autocomplete:"off"}),s=r("input",{placeholder:a.password,type:"password",autocomplete:"off"}),n=r("select",{},[r("option",{value:"torrent"},[a.torrent]),r("option",{value:"nostr"},[a.nostr])]),i=r("form",{class:"jump-form",method:"dialog"},[r("h2",{},[a.switch]),t,s,n,r("div",{class:"actions"},[r("button",{class:"btn primary",type:"submit"},[a.join]),r("button",{class:"btn ghost",type:"button",value:"cancel"},[a.cancel])])]),o=r("dialog",{class:"jump"},[i]);return i.addEventListener("submit",c=>{c.preventDefault(),o.close(),e({name:t.value,password:s.value,strategy:n.value})}),i.querySelector('button[value="cancel"]')?.addEventListener("click",()=>o.close()),{el:o,open(c){t.value=c?.name??"",s.value=c?.password??"",n.value=c?.strategy??"torrent",o.showModal(),c?.needPassword?s.focus():t.focus()},close:()=>o.close()}}function He(e){const t=r("input",{maxlength:24,value:e.nick,autocomplete:"nickname"}),s=r("input",{maxlength:64,value:It,autocomplete:"off"}),n=r("input",{type:"password",autocomplete:"off"}),i=r("select",{},[r("option",{value:"torrent"},[a.torrent]),r("option",{value:"nostr"},[a.nostr])]),o=r("button",{class:"btn primary join-btn",type:"submit"},[a.join]);Z()||(o.disabled=!0);const c=r("form",{class:"panel form lobby-form"},[r("div",{class:"join-row"},[k(a.nick,t),k(a.room,s),o]),r("details",{class:"more"},[r("summary",{},[a.more]),r("div",{class:"row"},[k(a.password,n),k(a.strategy,i)]),r("span",{class:"hint"},[a.passwordHint])])]);return c.addEventListener("submit",h=>{h.preventDefault(),Z()&&e.onJoin({nick:t.value,spec:{name:s.value,password:n.value,strategy:i.value}})}),r("div",{class:"lobby"},[r("p",{class:"lede"},[a.lobbyHint]),r("ul",{class:"tips"},[r("li",{},[a.tipSame]),r("li",{},[a.tipHttps]),r("li",{},[a.tipNat])]),c])}function k(e,t){return r("label",{class:"field"},[r("span",{},[e]),t])}function Ke(e,t,s,n=!1){return`${e}${t}${P({name:"room",spec:s},n)}`}async function Fe(e,t="禅聊"){try{if(typeof navigator.share=="function")return await navigator.share({title:t,url:e}),"shared"}catch(s){if(s instanceof Error&&s.name==="AbortError")return"failed"}try{return await navigator.clipboard.writeText(e),"copied"}catch{return"failed"}}function ze(e,t,s){$(e);for(const i of pt()){const o=!!(t&&i.name===g(t.name)&&i.strategy===t.strategy),c=i.hasPassword?[i.name,r("span",{class:"lock",title:a.locked},["锁"])]:[i.name],h=r("a",{class:`tab${o?" on":""}`,href:P({name:"room",spec:{name:i.name,password:"",strategy:i.strategy}})},c);h.addEventListener("click",l=>{l.preventDefault();const m=Vt(i,t);if(m==="need-password"){s.onNeedPassword(i);return}s.onGo(m)}),e.append(h)}const n=r("button",{class:"tab add",type:"button",title:a.addRoom},["+"]);n.addEventListener("click",()=>s.onAdd()),e.append(n)}function Ve(e,t,s,n){if($(e),e.append(t),!s)return;const i=r("button",{class:"btn ghost",type:"button"},[a.share]);if(i.addEventListener("click",()=>n(s,!1,i)),e.append(i),s.password){const o=r("button",{class:"btn ghost",type:"button"},[a.shareWithKey]);o.addEventListener("click",()=>n(s,!0,o)),e.append(o)}e.append(r("a",{class:"btn ghost",href:"#/"},[a.leave]))}async function et(e,t,s){const n=Ke(location.origin,location.pathname,e,t),i=await Fe(n);if(!s)return;const o=s.textContent;s.textContent=i==="shared"?a.shared:i==="copied"?a.copied:a.copyFailed,window.setTimeout(()=>{o&&(s.textContent=o)},1400)}const vt="zenchat.theme";function Ue(){const e=localStorage.getItem(vt);return e==="light"||e==="dark"||e==="system"?e:"system"}function We(e){localStorage.setItem(vt,e)}function Ge(e){return e==="system"?"light":e==="light"?"dark":"system"}function qe(){return window.matchMedia("(prefers-color-scheme: dark)").matches}function Ye(e,t=qe()){return e==="system"?t?"dark":"light":e}function R(e){const t=Ye(e);return document.documentElement.dataset.theme=t,document.documentElement.dataset.themePref=e,document.documentElement.style.colorScheme=t,t}function st(e){return e==="light"?"白天":e==="dark"?"黑夜":"跟随系统"}class Xe{root;theme;identity=ct();manager;themeBtn;tabsEl;toolsEl;main;jump;footerEl;dupBanner;chat=null;lobbyEl=null;routing=!1;stopTabWatch=null;constructor(t){this.root=t,this.theme=Ue(),R(this.theme),this.themeBtn=r("button",{class:"btn ghost",type:"button",title:a.theme},[st(this.theme)]),this.tabsEl=r("nav",{class:"tabs","aria-label":a.recent}),this.toolsEl=r("div",{class:"toolbar"}),this.main=r("main",{class:"main"}),this.jump=Be(s=>this.go(s)),this.dupBanner=r("div",{class:"banner warn hidden"},[a.duplicateTab]),this.footerEl=r("p",{class:"footer"},[r("a",{href:xt,target:"_blank",rel:"noreferrer"},[a.source])," · AGPL-3.0"]),this.manager=new Le(this.identity,{onStatus:s=>this.chat?.status(s),onMembers:s=>this.chat?.members(s,this.manager.selfId()),onLine:s=>this.chat?.push(s),onReset:s=>this.chat?.reset(s)})}start(){this.mount(),this.stopTabWatch=Ce(()=>this.dupBanner.classList.remove("hidden")),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>R(this.theme)),window.addEventListener("hashchange",()=>{this.routing||this.route()}),window.addEventListener("pagehide",()=>{this.stopTabWatch?.(),this.manager.snapshot(),this.manager.close()}),window.addEventListener("pageshow",t=>{t.persisted&&this.route()}),window.addEventListener("keydown",t=>{(t.metaKey||t.ctrlKey)&&t.key.toLowerCase()==="k"&&(t.preventDefault(),this.jump.open()),t.key==="Escape"&&this.jump.close()}),this.themeBtn.addEventListener("click",()=>{this.theme=Ge(this.theme),We(this.theme),R(this.theme),this.themeBtn.textContent=st(this.theme)}),this.route()}mount(){this.root.append(r("div",{class:"shell"},[r("header",{class:"topbar"},[r("a",{class:"brand",href:"#/"},[r("div",{class:"enso","aria-hidden":"true"}),r("strong",{},[a.title]),r("span",{class:"tagline"},[a.tagline])]),this.tabsEl,this.toolsEl]),Me(),this.dupBanner,this.main,this.footerEl]),this.jump.el)}async route(){const t=Pe(location.hash);if(this.refreshChrome(t.name==="room"?F(t.spec):void 0),t.name==="lobby"){await this.manager.close(),this.showLobby();return}await this.showChat(this.stripPasswordFromHash(F(t.spec)))}stripPasswordFromHash(t){j(t);const s=P({name:"room",spec:t},!1);return t.password&&location.hash!==s&&(this.routing=!0,history.replaceState(null,"",`${location.pathname}${location.search}${s}`),this.routing=!1),t}go(t){j(t);const s=P({name:"room",spec:t},!1);if(location.hash===s){this.showChat(t);return}this.routing=!0,location.hash=s,this.routing=!1,this.showChat(t)}showLobby(){this.chat?.hide(),this.footerEl.hidden=!1,this.root.classList.remove("mode-chat");const t=He({nick:this.identity.nick,onJoin:({nick:s,spec:n})=>{this.identity={...this.identity,nick:H(s)},this.manager.setIdentity(this.identity),this.go(n)}});this.lobbyEl&&this.lobbyEl.replaceWith(t),this.lobbyEl=t,this.main.replaceChildren(this.lobbyEl),this.refreshChrome()}async showChat(t){this.chat?this.chat.setNick(this.identity.nick):this.chat=new Je({selfId:this.identity.id,nick:this.identity.nick,send:s=>this.manager.sendChat(s),typing:()=>this.manager.sendTyping(),resend:s=>this.manager.resend(s),onRetry:()=>{this.manager.retry()},onShare:()=>{const s=this.manager.current()??t;et(s,!1)},onSwitchSignal:()=>{const s=this.manager.current()??t;this.go({...s,strategy:s.strategy==="torrent"?"nostr":"torrent"})},onNick:s=>{this.identity={...this.identity,nick:H(s)},this.manager.setIdentity(this.identity),this.chat?.setNick(this.identity.nick)}}),this.lobbyEl=null,this.footerEl.hidden=!0,this.root.classList.add("mode-chat"),this.main.replaceChildren(this.chat.root),this.chat.show(),this.refreshChrome(t);try{await this.manager.open(t)}catch(s){const n=s instanceof Error?s.message:"连接失败";this.chat.status({phase:"error",detail:`无法启动 P2P:${n}`,relays:[],peerCount:0})}}refreshChrome(t){ze(this.tabsEl,t,{onGo:s=>this.go(s),onNeedPassword:s=>this.jump.open({name:s.name,strategy:s.strategy,needPassword:!0}),onAdd:()=>this.jump.open()}),Ve(this.toolsEl,this.themeBtn,t??null,(s,n,i)=>{et(s,n,i)})}}function Ze(){const e=window.visualViewport,t=()=>{const s=e?.height??window.innerHeight;document.documentElement.style.setProperty("--app-h",`${Math.round(s)}px`)};t(),e?.addEventListener("resize",t,{passive:!0}),e?.addEventListener("scroll",t,{passive:!0}),window.addEventListener("orientationchange",t,{passive:!0})}const kt=document.querySelector("#app");if(!kt)throw new Error("缺少 #app 挂载点");Ze();Ee();new Xe(kt).start(); diff --git a/index.html b/index.html index 50af4e6..61d7008 100644 --- a/index.html +++ b/index.html @@ -19,8 +19,8 @@ 禅聊 ZenChat - - + +
diff --git a/src/config/app.ts b/src/config/app.ts index 3017a8f..520decc 100644 --- a/src/config/app.ts +++ b/src/config/app.ts @@ -12,6 +12,7 @@ export const TYPING_TTL_MS = 2500 export const TYPING_THROTTLE_MS = 1800 export const HELLO_INTERVAL_MS = 25_000 export const RELAY_POLL_MS = 5000 +export const PRESENCE_STALE_MS = HELLO_INTERVAL_MS * 3 export const ACK_TIMEOUT_MS = 8000 export const SOURCE_URL = 'https://github.com/Andyccr/ZenChat' export const PAGES_URL = 'https://andyccr.github.io/ZenChat/' diff --git a/src/core/cache.test.ts b/src/core/cache.test.ts index 5e0419f..137098e 100644 --- a/src/core/cache.test.ts +++ b/src/core/cache.test.ts @@ -26,4 +26,16 @@ describe('room log cache', () => { saveRoomLog(spec, lines) expect(loadRoomLog(spec)).toEqual(lines) }) + + it('strips in-flight pending markers but keeps failed delivery', () => { + const spec = { name: 'lobby', password: '', strategy: 'torrent' as const } + saveRoomLog(spec, [ + { kind: 'chat', id: 'p1', fromId: 'me', nick: '晚风', text: 'hi', ts: 1, self: true, delivery: 'pending' }, + { kind: 'chat', id: 'f1', fromId: 'me', nick: '晚风', text: 'bye', ts: 2, self: true, delivery: 'failed' }, + ]) + expect(loadRoomLog(spec)).toEqual([ + { kind: 'chat', id: 'p1', fromId: 'me', nick: '晚风', text: 'hi', ts: 1, self: true }, + { kind: 'chat', id: 'f1', fromId: 'me', nick: '晚风', text: 'bye', ts: 2, self: true, delivery: 'failed' }, + ]) + }) }) diff --git a/src/core/cache.ts b/src/core/cache.ts index e98f2cd..c3a6fc8 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -20,14 +20,31 @@ export function loadRoomLog(spec: RoomSpec): ChatLine[] { } } +export function durableLines(lines: ChatLine[]): ChatLine[] { + return trimLines(lines.map(dropPendingDelivery)) +} + export function saveRoomLog(spec: RoomSpec, lines: ChatLine[]): void { try { - sessionStorage.setItem(cacheKey(spec), JSON.stringify(lines.slice(-MAX_LOG_LINES))) + sessionStorage.setItem(cacheKey(spec), JSON.stringify(durableLines(lines))) } catch { // Quota errors are non-fatal; the live session still works. } } +function dropPendingDelivery(line: ChatLine): ChatLine { + if (line.kind !== 'chat' || line.delivery !== 'pending') return line + return { + kind: 'chat', + id: line.id, + fromId: line.fromId, + nick: line.nick, + text: line.text, + ts: line.ts, + self: line.self, + } +} + export function trimLines(lines: ChatLine[]): ChatLine[] { return lines.length > MAX_LOG_LINES ? lines.slice(-MAX_LOG_LINES) : lines } diff --git a/src/core/heartbeat.test.ts b/src/core/heartbeat.test.ts new file mode 100644 index 0000000..dc777da --- /dev/null +++ b/src/core/heartbeat.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { Heartbeat } from './heartbeat' +import { createMemoryRuntime } from './runtime' + +describe('Heartbeat', () => { + it('polls and hellos on interval, pauses while hidden, and resumes with a burst', () => { + const clock = createMemoryRuntime(0) + const hellos: number[] = [] + const polls: number[] = [] + const beat = new Heartbeat(clock.runtime, 25, 10, { + onHello: () => hellos.push(clock.runtime.now()), + onPoll: () => polls.push(clock.runtime.now()), + }) + + beat.start() + clock.advance(25) + expect(hellos).toEqual([25]) + expect(polls).toEqual([10, 20]) + + clock.setHidden(true) + clock.advance(40) + expect(hellos).toEqual([25]) + expect(polls).toEqual([10, 20]) + + clock.setHidden(false) + expect(hellos.at(-1)).toBe(65) + expect(polls.at(-1)).toBe(65) + clock.advance(10) + expect(polls.at(-1)).toBe(75) + + beat.stop() + clock.advance(50) + expect(hellos).toHaveLength(2) + expect(polls.filter((at) => at > 75)).toEqual([]) + }) +}) diff --git a/src/core/heartbeat.ts b/src/core/heartbeat.ts new file mode 100644 index 0000000..2b7fcb1 --- /dev/null +++ b/src/core/heartbeat.ts @@ -0,0 +1,58 @@ +import type { Runtime } from './runtime' + +export type HeartbeatHandlers = { + onHello: () => void + onPoll: () => void +} + +export class Heartbeat { + private helloTimer: number | null = null + private pollTimer: number | null = null + private unbindVisibility: (() => void) | null = null + + constructor( + private runtime: Pick, + private helloMs: number, + private pollMs: number, + private handlers: HeartbeatHandlers, + ) {} + + start(): void { + this.stop() + this.unbindVisibility = this.runtime.onVisibilityChange(this.onVisibility) + this.startTimers() + } + + stop(): void { + this.unbindVisibility?.() + this.unbindVisibility = null + this.stopTimers() + } + + private onVisibility = (): void => { + if (this.runtime.hidden()) { + this.stopTimers() + return + } + this.startTimers() + this.handlers.onHello() + this.handlers.onPoll() + } + + private startTimers(): void { + this.stopTimers() + this.helloTimer = this.runtime.setInterval(() => this.handlers.onHello(), this.helloMs) + this.pollTimer = this.runtime.setInterval(() => this.handlers.onPoll(), this.pollMs) + } + + private stopTimers(): void { + if (this.helloTimer !== null) { + this.runtime.clearInterval(this.helloTimer) + this.helloTimer = null + } + if (this.pollTimer !== null) { + this.runtime.clearInterval(this.pollTimer) + this.pollTimer = null + } + } +} diff --git a/src/core/outbound.test.ts b/src/core/outbound.test.ts index a59095e..0e3ca15 100644 --- a/src/core/outbound.test.ts +++ b/src/core/outbound.test.ts @@ -3,25 +3,28 @@ import { Outbound } from './outbound' import { createMemoryRuntime } from './runtime' describe('Outbound', () => { - it('expires pending ids and ignores a late ack', () => { + it('expires pending ids but keeps the payload for a late ack or resend', () => { const clock = createMemoryRuntime(0) const expired: string[] = [] - const outbound = new Outbound(clock.runtime, 100, (id) => expired.push(id)) - outbound.expect('msg-1') + const outbound = new Outbound<{ text: string }>(clock.runtime, 100, (id) => expired.push(id)) + outbound.expect('msg-1', { text: 'hi' }) clock.advance(99) expect(expired).toEqual([]) clock.advance(1) expect(expired).toEqual(['msg-1']) - expect(outbound.ack('msg-1')).toBe(false) + expect(outbound.payload('msg-1')).toEqual({ text: 'hi' }) + expect(outbound.ack('msg-1')).toBe(true) + expect(outbound.payload('msg-1')).toBeUndefined() }) it('clears a timer when the ack arrives first', () => { const clock = createMemoryRuntime(0) const expired: string[] = [] const outbound = new Outbound(clock.runtime, 100, (id) => expired.push(id)) - outbound.expect('msg-2') + outbound.expect('msg-2', { text: 'yo' }) expect(outbound.ack('msg-2')).toBe(true) clock.advance(100) expect(expired).toEqual([]) + expect(outbound.payload('msg-2')).toBeUndefined() }) }) diff --git a/src/core/outbound.ts b/src/core/outbound.ts index 95f06fb..f00e24d 100644 --- a/src/core/outbound.ts +++ b/src/core/outbound.ts @@ -1,7 +1,8 @@ import type { Runtime } from './runtime' -export class Outbound { +export class Outbound { private timers = new Map() + private payloads = new Map() constructor( private runtime: Pick, @@ -9,8 +10,9 @@ export class Outbound { private onExpire: (id: string) => void, ) {} - expect(id: string): void { - this.cancel(id) + expect(id: string, payload?: T): void { + this.cancelTimer(id) + if (payload !== undefined) this.payloads.set(id, payload) this.timers.set( id, this.runtime.setTimeout(() => { @@ -21,18 +23,27 @@ export class Outbound { } ack(id: string): boolean { - if (!this.timers.has(id)) return false + if (!this.timers.has(id) && !this.payloads.has(id)) return false this.cancel(id) return true } + payload(id: string): T | undefined { + return this.payloads.get(id) + } + cancel(id: string): void { - const timer = this.timers.get(id) - if (timer !== undefined) this.runtime.clearTimeout(timer) - this.timers.delete(id) + this.cancelTimer(id) + this.payloads.delete(id) } clear(): void { - for (const id of [...this.timers.keys()]) this.cancel(id) + for (const id of [...this.timers.keys(), ...this.payloads.keys()]) this.cancel(id) + } + + private cancelTimer(id: string): void { + const timer = this.timers.get(id) + if (timer !== undefined) this.runtime.clearTimeout(timer) + this.timers.delete(id) } } diff --git a/src/core/payload-router.test.ts b/src/core/payload-router.test.ts new file mode 100644 index 0000000..adf2abc --- /dev/null +++ b/src/core/payload-router.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { decodeIncoming } from './payload-router' +import { createAckPayload, createChatPayload, createHelloPayload, createTypingPayload } from './protocol' + +describe('decodeIncoming', () => { + it('drops invalid payloads and marks duplicate chats', () => { + expect(decodeIncoming({ v: 1, type: 'leave', nick: '晚风' }, () => false)).toBeNull() + expect(decodeIncoming(createHelloPayload('晚风'), () => false)).toEqual({ + type: 'hello', + nick: '晚风', + features: ['ack'], + }) + expect(decodeIncoming(createTypingPayload('听雨'), () => false)).toEqual({ type: 'typing', nick: '听雨' }) + expect(decodeIncoming(createAckPayload('青石', 'aabbccdd12345678'), () => false)).toEqual({ + type: 'ack', + nick: '青石', + id: 'aabbccdd12345678', + }) + + const chat = createChatPayload('青石', '在吗', 'aabbccdd12345678', 1) + expect(decodeIncoming(chat, () => false)).toMatchObject({ type: 'chat', duplicate: false, text: '在吗' }) + expect(decodeIncoming(chat, (id) => id === chat.id)).toMatchObject({ type: 'chat', duplicate: true }) + }) +}) diff --git a/src/core/payload-router.ts b/src/core/payload-router.ts new file mode 100644 index 0000000..e82595f --- /dev/null +++ b/src/core/payload-router.ts @@ -0,0 +1,23 @@ +import { parsePayload } from './protocol' + +export type Incoming = + | { type: 'hello'; nick: string; features: string[] } + | { type: 'typing'; nick: string } + | { type: 'ack'; nick: string; id: string } + | { type: 'chat'; nick: string; id: string; text: string; ts: number; duplicate: boolean } + +export function decodeIncoming(raw: unknown, seen: (id: string) => boolean): Incoming | null { + const payload = parsePayload(raw) + if (!payload) return null + if (payload.type === 'hello') return { type: 'hello', nick: payload.nick, features: payload.features } + if (payload.type === 'typing') return { type: 'typing', nick: payload.nick } + if (payload.type === 'ack') return { type: 'ack', nick: payload.nick, id: payload.id } + return { + type: 'chat', + nick: payload.nick, + id: payload.id, + text: payload.text, + ts: payload.ts, + duplicate: seen(payload.id), + } +} diff --git a/src/core/presence.test.ts b/src/core/presence.test.ts index b12357a..b513344 100644 --- a/src/core/presence.test.ts +++ b/src/core/presence.test.ts @@ -26,4 +26,15 @@ describe('Presence', () => { expect(presence.remove('p1')?.nick).toBe('晚风') expect(presence.list()).toEqual([]) }) + + it('prunes members whose lastSeenAt is older than the stale window', () => { + const clock = createMemoryRuntime(1_000) + const presence = new Presence(clock.runtime) + presence.upsert('fresh', '晚风') + clock.advance(80) + presence.upsert('stale', '青石') + clock.advance(30) + expect(presence.prune(100).map((member) => member.id)).toEqual(['fresh']) + expect(presence.list().map((member) => member.id)).toEqual(['stale']) + }) }) diff --git a/src/core/presence.ts b/src/core/presence.ts index 3fd0810..ff5a6e2 100644 --- a/src/core/presence.ts +++ b/src/core/presence.ts @@ -68,6 +68,13 @@ export class Presence { return [...this.members.values()].sort((a, b) => a.joinedAt - b.joinedAt) } + prune(maxAgeMs: number): Member[] { + const cutoff = this.runtime.now() - maxAgeMs + const gone = this.list().filter((member) => member.lastSeenAt < cutoff) + for (const member of gone) this.remove(member.id) + return gone + } + clear(): void { for (const id of this.typingTimers.keys()) this.clearTypingTimer(id) this.members.clear() diff --git a/src/core/room-manager.test.ts b/src/core/room-manager.test.ts index 1b9e2b9..89b5dc8 100644 --- a/src/core/room-manager.test.ts +++ b/src/core/room-manager.test.ts @@ -42,17 +42,19 @@ function manager() { describe('RoomManager', () => { it('reuses the live session for the same room', async () => { - const { rm } = manager() + const { rm, created } = manager() const spec = { name: 'lobby', password: '', strategy: 'torrent' as const } - const first = await rm.open(spec) - const second = await rm.open(spec) - expect(second).toBe(first) - expect(rm.getSession()?.isJoined()).toBe(true) + expect(await rm.open(spec)).toBe(true) + const transports = created.length + expect(await rm.open(spec)).toBe(true) + expect(created).toHaveLength(transports) + expect(rm.isJoined()).toBe(true) }) it('returns closed when sending without a live session', async () => { const { rm } = manager() expect(await rm.sendChat('hi')).toBe('closed') + expect(await rm.resend('missing')).toBe('closed') }) it('retries after a failed join instead of sticking on the dead session', async () => { @@ -68,10 +70,10 @@ describe('RoomManager', () => { }) const spec = { name: 'lobby', password: '', strategy: 'torrent' as const } await expect(rm.open(spec)).rejects.toThrow('offline') - expect(rm.getSession()?.isJoined()).toBe(false) + expect(rm.isJoined()).toBe(false) fail = false await rm.open(spec) - expect(rm.getSession()?.isJoined()).toBe(true) + expect(rm.isJoined()).toBe(true) }) it('drops a superseded join when switching rooms mid-handshake', async () => { @@ -95,8 +97,9 @@ describe('RoomManager', () => { const second = rm.open({ name: 'two', password: '', strategy: 'torrent' }) release() const [stale, live] = await Promise.all([first, second]) - expect(stale).toBeNull() - expect(live?.isJoined()).toBe(true) + expect(stale).toBe(false) + expect(live).toBe(true) + expect(rm.isJoined()).toBe(true) expect(rm.current()?.name).toBe('two') }) }) diff --git a/src/core/room-manager.ts b/src/core/room-manager.ts index 42bcafd..66acc71 100644 --- a/src/core/room-manager.ts +++ b/src/core/room-manager.ts @@ -22,8 +22,8 @@ export class RoomManager { return this.spec } - getSession(): ChatSession | null { - return this.session + isJoined(): boolean { + return this.session?.isJoined() ?? false } selfId(): string { @@ -34,13 +34,17 @@ export class RoomManager { return this.session?.sendChat(text) ?? Promise.resolve('closed') } + resend(id: string): Promise { + return this.session?.resend(id) ?? Promise.resolve('closed') + } + sendTyping(): void { this.session?.sendTyping() } - retry(): Promise { + retry(): Promise { const spec = this.spec - if (!spec) return Promise.resolve(null) + if (!spec) return Promise.resolve(false) return this.open(spec, true) } @@ -49,14 +53,14 @@ export class RoomManager { this.session?.setNick(identity.nick) } - async open(spec: RoomSpec, force = false): Promise { + async open(spec: RoomSpec, force = false): Promise { if (!force && this.spec && this.session?.isJoined() && sameRoom(this.spec, spec)) { - return this.session + return true } const token = ++this.generation await this.snapshotAndClose() - if (token !== this.generation) return null + if (token !== this.generation) return false rememberRoom(spec) this.listeners.onMembers?.([]) @@ -72,15 +76,15 @@ export class RoomManager { } catch (error) { if (token !== this.generation) { await session.leave({ silent: true }) - return null + return false } throw error } if (token !== this.generation) { await session.leave({ silent: true }) - return null + return false } - return session + return true } async close(): Promise { diff --git a/src/core/session.test.ts b/src/core/session.test.ts index f4880ea..f27c151 100644 --- a/src/core/session.test.ts +++ b/src/core/session.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createAckPayload, createHelloPayload, createTypingPayload } from './protocol' import { createMemoryRuntime } from './runtime' import { ChatSession } from './session' +import { chatLine } from './transcript' import { FakeTransport } from './transports/fake' import type { ChatLine, Member, SessionStatus } from './types' @@ -137,4 +138,50 @@ describe('ChatSession', () => { clock.advance(5000) expect(statuses.at(-1)).toMatchObject({ phase: 'error', detail: 'Tracker 连不上,可改用 Nostr' }) }) + + it('resends a failed line and accepts a late ack', async () => { + const { session, clock, lines, transport } = harness() + await session.join(spec) + transport().payload('peer-b', createHelloPayload('青石')) + await session.sendChat('第一句') + const pending = [...lines].reverse().find((line) => line.kind === 'chat' && line.self) + expect(pending).toMatchObject({ delivery: 'pending' }) + clock.advance(8000) + expect([...lines].reverse().find((line) => line.kind === 'chat' && line.self)).toMatchObject({ delivery: 'failed' }) + + expect(await session.resend(pending && pending.kind === 'chat' ? pending.id : '')).toBe('sent') + const again = [...lines].reverse().find((line) => line.kind === 'chat' && line.self) + expect(again).toMatchObject({ delivery: 'pending' }) + transport().payload('peer-b', createAckPayload('青石', pending && pending.kind === 'chat' ? pending.id : '')) + expect([...lines].reverse().find((line) => line.kind === 'chat' && line.self)).toMatchObject({ delivery: 'acked' }) + }) + + it('prunes a peer that stopped announcing', async () => { + const { session, clock, lines, transport } = harness() + await session.join(spec) + transport().peerJoin('peer-gone') + transport().payload('peer-gone', createHelloPayload('青石')) + clock.advance(80_000) + expect(lines.some((line) => line.kind === 'system' && line.text.includes('离开'))).toBe(true) + }) + + it('resends a failed line restored from cache', async () => { + const { session, lines, transport } = harness() + session.hydrate([ + chatLine({ + id: 'aabbccdd12345678', + fromId: 'local', + nick: '晚风', + text: '旧信', + ts: 1, + self: true, + delivery: 'failed', + }), + ]) + await session.join(spec) + transport().payload('peer-b', createHelloPayload('青石')) + expect(await session.resend('aabbccdd12345678')).toBe('sent') + expect(transport().sent.some((item) => (item.payload as { id?: string }).id === 'aabbccdd12345678')).toBe(true) + expect(lines.some((line) => line.kind === 'chat' && line.id === 'aabbccdd12345678' && line.delivery === 'pending')).toBe(true) + }) }) diff --git a/src/core/session.ts b/src/core/session.ts index 799a4a8..84c822b 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -1,6 +1,8 @@ -import { ACK_TIMEOUT_MS, APP_ID, HELLO_INTERVAL_MS, RELAY_POLL_MS, TYPING_THROTTLE_MS } from '../config/app' +import { ACK_TIMEOUT_MS, APP_ID, HELLO_INTERVAL_MS, PRESENCE_STALE_MS, RELAY_POLL_MS, TYPING_THROTTLE_MS } from '../config/app' +import { Heartbeat } from './heartbeat' import { randomHex } from './identity' import { Outbound } from './outbound' +import { decodeIncoming } from './payload-router' import { Presence } from './presence' import { createAckPayload, @@ -8,19 +10,13 @@ import { createHelloPayload, createTypingPayload, FEATURE_ACK, - parsePayload, + type ChatPayload, } from './protocol' import { normalizeRoomName, roomNamespace } from './room' import { browserRuntime, type Runtime } from './runtime' import { rafBatch, throttle } from './scheduler' -import { idleLife, isJoined, reduceLife, uiPhase, type Life } from './session-machine' -import { - connectedDetail, - connectingDetail, - startFailedDetail, - STATUS_COPY, - waitingOrDown, -} from './status' +import { idleLife, isJoined, reduceLife, type Life } from './session-machine' +import { connectingDetail, deriveStatus, relaysAreDown, sameStatus, startFailedDetail, STATUS_COPY } from './status' import { chatLine, systemLine, Transcript } from './transcript' import { createTransport, type TransportFactory } from './transports/create' import type { SignallingTransport } from './transports/types' @@ -46,11 +42,9 @@ export class ChatSession { private runtime: Runtime private transcript = new Transcript() private presence: Presence - private outbound: Outbound + private outbound: Outbound + private heartbeat: Heartbeat private life: Life = idleLife() - private helloTimer: number | null = null - private relayTimer: number | null = null - private unbindVisibility: (() => void) | null = null private strategy: SignalStrategy = 'torrent' private joinErrors = 0 private status: SessionStatus = { @@ -77,6 +71,10 @@ export class ChatSession { this.transport = this.createTransport('torrent') this.presence = new Presence(this.runtime, () => this.emitMembers()) this.outbound = new Outbound(this.runtime, ACK_TIMEOUT_MS, (id) => this.expireAck(id)) + this.heartbeat = new Heartbeat(this.runtime, HELLO_INTERVAL_MS, RELAY_POLL_MS, { + onHello: () => this.announce(), + onPoll: () => this.poll(), + }) } get selfId(): string { @@ -117,29 +115,10 @@ export class ChatSession { strategy: spec.strategy, }, { - onPeerJoin: (peerId) => { - this.presence.upsert(peerId, '访客') - this.pushSystem(`${peerId.slice(0, 6)} 加入了房间`) - void this.transport.send(createHelloPayload(this.identity.nick), peerId) - this.emitMembers() - this.notePeers() - this.runtime.setTimeout(() => void this.measure(peerId), 350) - }, - onPeerLeave: (peerId) => { - const member = this.presence.remove(peerId) - this.pushSystem(`${member?.nick ?? peerId.slice(0, 6)} 离开了房间`) - this.emitMembers() - this.notePeers() - }, + onPeerJoin: (peerId) => this.onPeerJoin(peerId), + onPeerLeave: (peerId) => this.onPeerLeave(peerId), onPayload: (peerId, payload) => this.handlePayload(peerId, payload), - onJoinError: () => { - this.joinErrors += 1 - if (this.transport.peerIds().length > 0) return - if (this.joinErrors >= 3) { - this.life = reduceLife(this.life, { type: 'force_down' }) - this.emitStatus() - } else this.refreshStatus('connecting', STATUS_COPY.handshake) - }, + onJoinError: () => this.onJoinError(), }, ) } catch (error) { @@ -150,9 +129,8 @@ export class ChatSession { } this.life = reduceLife(this.life, { type: 'join_ok' }) - this.bindVisibility() - this.startTimers() - void this.transport.send(createHelloPayload(this.identity.nick)) + this.heartbeat.start() + this.announce() this.emitStatus() } @@ -160,27 +138,17 @@ export class ChatSession { if (!this.isJoined()) return 'closed' const payload = createChatPayload(this.identity.nick, text, randomHex(8), this.runtime.now()) if (!payload.text) return 'empty' - const expectAck = this.presence.supports(FEATURE_ACK) - this.pushLine( - chatLine({ - id: payload.id, - fromId: this.selfId, - nick: this.identity.nick, - text: payload.text, - ts: payload.ts, - self: true, - ...(expectAck ? { delivery: 'pending' as const } : {}), - }), - ) - try { - await this.transport.send(payload) - } catch { - this.patchDelivery(payload.id, 'failed') - this.pushSystem(STATUS_COPY.sendFailed) - return 'failed' - } - if (expectAck) this.outbound.expect(payload.id) - return 'sent' + return this.dispatch(payload, this.presence.supports(FEATURE_ACK)) + } + + async resend(id: string): Promise { + if (!this.isJoined()) return 'closed' + const line = this.transcript.get(id) + if (!line || line.kind !== 'chat' || !line.self) return 'empty' + if (line.delivery !== 'failed' && line.delivery !== 'pending') return 'empty' + const stored = this.outbound.payload(id) + const payload = stored ?? createChatPayload(this.identity.nick, line.text, line.id, line.ts) + return this.dispatch(payload, this.presence.supports(FEATURE_ACK), true) } sendTyping(): void { @@ -189,14 +157,12 @@ export class ChatSession { setNick(nick: string): void { this.identity = { ...this.identity, nick } - if (this.isJoined()) void this.transport.send(createHelloPayload(nick)) + if (this.isJoined()) this.announce() } async leave(options: { silent?: boolean } = {}): Promise { this.life = reduceLife(this.life, { type: 'leave' }) - this.unbindVisibility?.() - this.unbindVisibility = null - this.stopTimers() + this.heartbeat.stop() this.outbound.clear() this.presence.clear() await this.transport.leave() @@ -209,44 +175,92 @@ export class ChatSession { } } + private async dispatch(payload: ChatPayload, expectAck: boolean, replace = false): Promise { + if (replace) this.patchDelivery(payload.id, expectAck ? 'pending' : undefined) + else { + this.pushLine( + chatLine({ + id: payload.id, + fromId: this.selfId, + nick: this.identity.nick, + text: payload.text, + ts: payload.ts, + self: true, + ...(expectAck ? { delivery: 'pending' as const } : {}), + }), + ) + } + try { + await this.transport.send(payload) + } catch { + this.patchDelivery(payload.id, 'failed') + this.pushSystem(STATUS_COPY.sendFailed) + return 'failed' + } + if (expectAck) this.outbound.expect(payload.id, payload) + return 'sent' + } + private handlePayload(peerId: string, raw: unknown): void { - const payload = parsePayload(raw) - if (!payload) return - if (payload.type === 'hello') this.presence.upsert(peerId, payload.nick, payload.features) - else this.presence.upsert(peerId, payload.nick) + const incoming = decodeIncoming(raw, (id) => this.transcript.has(id)) + if (!incoming) return + if (incoming.type === 'hello') this.presence.upsert(peerId, incoming.nick, incoming.features) + else this.presence.upsert(peerId, incoming.nick) - if (payload.type === 'hello') { + if (incoming.type === 'hello') { this.emitMembers() return } - - if (payload.type === 'typing') { + if (incoming.type === 'typing') { this.presence.markTyping(peerId) this.emitMembers() return } - - if (payload.type === 'ack') { - if (this.outbound.ack(payload.id)) this.patchDelivery(payload.id, 'acked') + if (incoming.type === 'ack') { + if (this.outbound.ack(incoming.id)) this.patchDelivery(incoming.id, 'acked') return } - - if (this.transcript.has(payload.id)) { - void this.transport.send(createAckPayload(this.identity.nick, payload.id), peerId) + if (incoming.duplicate) { + void this.transport.send(createAckPayload(this.identity.nick, incoming.id), peerId) return } this.pushLine( chatLine({ - id: payload.id, + id: incoming.id, fromId: peerId, - nick: payload.nick, - text: payload.text, - ts: payload.ts, + nick: incoming.nick, + text: incoming.text, + ts: incoming.ts, self: false, }), ) - void this.transport.send(createAckPayload(this.identity.nick, payload.id), peerId) + void this.transport.send(createAckPayload(this.identity.nick, incoming.id), peerId) + this.emitMembers() + } + + private onPeerJoin(peerId: string): void { + this.presence.upsert(peerId, '访客') + this.pushSystem(`${peerId.slice(0, 6)} 加入了房间`) + void this.transport.send(createHelloPayload(this.identity.nick), peerId) this.emitMembers() + this.notePeers() + this.runtime.setTimeout(() => void this.measure(peerId), 350) + } + + private onPeerLeave(peerId: string): void { + const member = this.presence.remove(peerId) + this.pushSystem(`${member?.nick ?? peerId.slice(0, 6)} 离开了房间`) + this.emitMembers() + this.notePeers() + } + + private onJoinError(): void { + this.joinErrors += 1 + if (this.transport.peerIds().length > 0) return + if (this.joinErrors >= 3) { + this.life = reduceLife(this.life, { type: 'force_down' }) + this.emitStatus() + } else this.refreshStatus('connecting', STATUS_COPY.handshake) } private expireAck(id: string): void { @@ -254,7 +268,7 @@ export class ChatSession { this.patchDelivery(id, 'failed') } - private patchDelivery(id: string, delivery: 'acked' | 'failed' | 'pending'): void { + private patchDelivery(id: string, delivery?: 'acked' | 'failed' | 'pending'): void { const next = this.transcript.patchDelivery(id, delivery) if (next) this.listeners.onLine?.(next) } @@ -269,6 +283,20 @@ export class ChatSession { } } + private announce(): void { + if (!this.isJoined()) return + void this.transport.send(createHelloPayload(this.identity.nick)) + } + + private poll(): void { + if (!this.isJoined()) return + const gone = this.presence.prune(PRESENCE_STALE_MS) + for (const member of gone) this.pushSystem(`${member.nick} 离开了房间`) + if (gone.length > 0) this.emitMembers() + this.notePeers() + for (const peerId of this.transport.peerIds()) void this.measure(peerId) + } + private pushSystem(text: string): void { this.pushLine(systemLine(randomHex(6), text, this.runtime.now())) } @@ -279,22 +307,19 @@ export class ChatSession { } private notePeers(): void { - this.life = reduceLife(this.life, { type: 'peers', count: this.transport.peerIds().length }) + this.life = reduceLife(this.life, { type: 'peers', count: this.presence.list().length }) this.emitStatus() } private emitStatus(): void { - const peerCount = this.transport.peerIds().length - const relays = this.transport.relays() - this.life = reduceLife(this.life, { type: 'relays', down: relays.length > 0 && relays.every((relay) => relay.readyState !== 1) }) - const phase = uiPhase(this.life) - if (this.life.state === 'live') { - this.setStatus({ phase, detail: connectedDetail(peerCount), relays, peerCount }) - return - } - if (this.life.state === 'failed') return - const wait = waitingOrDown(this.strategy, this.life.state === 'relay_down' && relays.length === 0 ? [{ url: 'local', readyState: 3 }] : relays) - this.setStatus({ phase: wait.phase, detail: this.life.state === 'joining' ? connectingDetail(this.strategy) : wait.detail, relays, peerCount }) + this.life = reduceLife(this.life, { type: 'relays', down: relaysAreDown(this.transport.relays()) }) + const next = deriveStatus({ + life: this.life, + strategy: this.strategy, + peerCount: this.presence.list().length, + relays: this.transport.relays(), + }) + if (next) this.setStatus(next) } private refreshStatus(phase: SessionStatus['phase'], detail: string): void { @@ -302,60 +327,13 @@ export class ChatSession { phase, detail, relays: this.transport.relays(), - peerCount: this.transport.peerIds().length, + peerCount: this.presence.list().length, }) } private setStatus(status: SessionStatus): void { - const prev = this.status - if ( - prev.phase === status.phase && - prev.detail === status.detail && - prev.peerCount === status.peerCount && - prev.relays.length === status.relays.length && - prev.relays.every((relay, i) => relay.readyState === status.relays[i]?.readyState) - ) { - return - } + if (sameStatus(this.status, status)) return this.status = status this.listeners.onStatus?.(status) } - - private onVisibility = (): void => { - if (this.runtime.hidden()) { - this.stopTimers() - return - } - if (!this.isJoined()) return - this.startTimers() - void this.transport.send(createHelloPayload(this.identity.nick)) - this.emitStatus() - } - - private bindVisibility(): void { - this.unbindVisibility?.() - this.unbindVisibility = this.runtime.onVisibilityChange(this.onVisibility) - } - - private startTimers(): void { - this.stopTimers() - this.helloTimer = this.runtime.setInterval(() => { - void this.transport.send(createHelloPayload(this.identity.nick)) - }, HELLO_INTERVAL_MS) - this.relayTimer = this.runtime.setInterval(() => { - this.emitStatus() - for (const peerId of this.transport.peerIds()) void this.measure(peerId) - }, RELAY_POLL_MS) - } - - private stopTimers(): void { - if (this.helloTimer !== null) { - this.runtime.clearInterval(this.helloTimer) - this.helloTimer = null - } - if (this.relayTimer !== null) { - this.runtime.clearInterval(this.relayTimer) - this.relayTimer = null - } - } } diff --git a/src/core/status.test.ts b/src/core/status.test.ts index 0eed9cf..a658217 100644 --- a/src/core/status.test.ts +++ b/src/core/status.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { relaysAreDown, waitingOrDown } from './status' +import { idleLife, reduceLife } from './session-machine' +import { deriveStatus, relaysAreDown, sameStatus, waitingOrDown } from './status' describe('connection health', () => { it('treats a non-empty all-closed relay list as down', () => { @@ -10,4 +11,40 @@ describe('connection health', () => { detail: 'Tracker 连不上,可改用 Nostr', }) }) + + it('derives waiting, live, and relay-down copy from the life machine', () => { + let life = reduceLife(idleLife(), { type: 'join' }) + expect(deriveStatus({ life, strategy: 'torrent', peerCount: 0, relays: [] })).toMatchObject({ + phase: 'connecting', + detail: '正在连接 Tracker…', + }) + + life = reduceLife(life, { type: 'join_ok' }) + expect(deriveStatus({ life, strategy: 'torrent', peerCount: 0, relays: [] })).toMatchObject({ + phase: 'connecting', + detail: '还没有同伴,把链接发给对方', + }) + + life = reduceLife(life, { type: 'peers', count: 1 }) + expect(deriveStatus({ life, strategy: 'torrent', peerCount: 1, relays: [] })).toMatchObject({ + phase: 'connected', + detail: '已直连 1 人', + }) + + life = reduceLife(life, { type: 'peers', count: 0 }) + life = reduceLife(life, { type: 'force_down' }) + expect(deriveStatus({ life, strategy: 'torrent', peerCount: 0, relays: [] })).toMatchObject({ + phase: 'error', + detail: 'Tracker 连不上,可改用 Nostr', + }) + + const failed = reduceLife(reduceLife(idleLife(), { type: 'join' }), { type: 'join_err' }) + expect(deriveStatus({ life: failed, strategy: 'torrent', peerCount: 0, relays: [] })).toBeNull() + }) + + it('treats identical statuses as unchanged', () => { + const status = { phase: 'connected' as const, detail: '已直连 1 人', relays: [{ url: 'wss://t', readyState: 1 }], peerCount: 1 } + expect(sameStatus(status, { ...status, relays: [{ url: 'wss://t', readyState: 1 }] })).toBe(true) + expect(sameStatus(status, { ...status, peerCount: 2 })).toBe(false) + }) }) diff --git a/src/core/status.ts b/src/core/status.ts index 34dd85f..b160297 100644 --- a/src/core/status.ts +++ b/src/core/status.ts @@ -1,3 +1,5 @@ +import type { Life } from './session-machine' +import { uiPhase } from './session-machine' import type { RelayStatus, SessionStatus, SignalStrategy } from './types' export const STATUS_COPY = { @@ -41,3 +43,41 @@ export function waitingOrDown(strategy: SignalStrategy, relays: RelayStatus[]): } return { phase: 'connecting', detail: STATUS_COPY.waiting } } + +export function deriveStatus(input: { + life: Life + strategy: SignalStrategy + peerCount: number + relays: RelayStatus[] +}): SessionStatus | null { + if (input.life.state === 'failed') return null + if (input.life.state === 'live') { + return { + phase: uiPhase(input.life), + detail: connectedDetail(input.peerCount), + relays: input.relays, + peerCount: input.peerCount, + } + } + const probes = + input.life.state === 'relay_down' && input.relays.length === 0 + ? [{ url: 'local', readyState: 3 }] + : input.relays + const wait = waitingOrDown(input.strategy, probes) + return { + phase: wait.phase, + detail: input.life.state === 'joining' ? connectingDetail(input.strategy) : wait.detail, + relays: input.relays, + peerCount: input.peerCount, + } +} + +export function sameStatus(prev: SessionStatus, next: SessionStatus): boolean { + return ( + prev.phase === next.phase && + prev.detail === next.detail && + prev.peerCount === next.peerCount && + prev.relays.length === next.relays.length && + prev.relays.every((relay, i) => relay.readyState === next.relays[i]?.readyState) + ) +} diff --git a/src/core/transcript.test.ts b/src/core/transcript.test.ts index f5a2d28..0dde62a 100644 --- a/src/core/transcript.test.ts +++ b/src/core/transcript.test.ts @@ -29,11 +29,15 @@ describe('Transcript', () => { expect(ids.has(`seen-${MAX_SEEN_IDS + 9}`)).toBe(true) }) - it('drops pending delivery when hydrating a cached log', () => { + it('drops pending delivery when hydrating a cached log but keeps failed lines', () => { const log = new Transcript() - log.hydrate([chatLine({ id: 'aabbccdd', fromId: 'me', nick: '晚风', text: 'hi', ts: 1, self: true, delivery: 'pending' })]) - const row = log.snapshot()[0] - expect(row).toMatchObject({ kind: 'chat', id: 'aabbccdd', text: 'hi' }) - expect(row && row.kind === 'chat' ? row.delivery : 'missing').toBeUndefined() + log.hydrate([ + chatLine({ id: 'aabbccdd', fromId: 'me', nick: '晚风', text: 'hi', ts: 1, self: true, delivery: 'pending' }), + chatLine({ id: 'eeff0011', fromId: 'me', nick: '晚风', text: 'retry', ts: 2, self: true, delivery: 'failed' }), + ]) + const [pending, failed] = log.snapshot() + expect(pending && pending.kind === 'chat' ? pending.delivery : 'missing').toBeUndefined() + expect(failed).toMatchObject({ kind: 'chat', id: 'eeff0011', delivery: 'failed' }) + expect(log.get('eeff0011')).toMatchObject({ text: 'retry' }) }) }) diff --git a/src/core/transcript.ts b/src/core/transcript.ts index a4a0bf4..7661cfa 100644 --- a/src/core/transcript.ts +++ b/src/core/transcript.ts @@ -32,6 +32,10 @@ export class Transcript { return this.lines } + get(id: string): ChatLine | undefined { + return this.lines.find((line) => line.id === id) + } + has(id: string): boolean { return this.seen.has(id) } @@ -56,11 +60,19 @@ export class Transcript { return line } - patchDelivery(id: string, delivery: Delivery): ChatLine | null { + patchDelivery(id: string, delivery?: Delivery): ChatLine | null { const index = this.lines.findIndex((line) => line.id === id) const current = index >= 0 ? this.lines[index] : undefined if (index < 0 || !current || current.kind !== 'chat') return null - const next = { ...current, delivery } + const next = chatLine({ + id: current.id, + fromId: current.fromId, + nick: current.nick, + text: current.text, + ts: current.ts, + self: current.self, + ...(delivery ? { delivery } : {}), + }) this.lines[index] = next return next } diff --git a/src/ui/app.ts b/src/ui/app.ts index 18509e7..8657248 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -165,6 +165,7 @@ export class App { nick: this.identity.nick, send: (text) => this.manager.sendChat(text), typing: () => this.manager.sendTyping(), + resend: (id) => this.manager.resend(id), onRetry: () => void this.manager.retry(), onShare: () => { const current = this.manager.current() ?? spec diff --git a/src/ui/chat-pane.ts b/src/ui/chat-pane.ts index a10d88c..92b5c02 100644 --- a/src/ui/chat-pane.ts +++ b/src/ui/chat-pane.ts @@ -14,11 +14,12 @@ export type ChatPaneHandlers = { onShare: () => void onSwitchSignal: () => void onNick: (nick: string) => void + resend: (id: string) => Promise } export class ChatPane { readonly root: HTMLElement - readonly log = new LogView() + readonly log: LogView private statusEl: HTMLElement private actionsEl: HTMLElement private membersEl: HTMLElement @@ -31,6 +32,9 @@ export class ChatPane { private lastPeerCount = 0 constructor(private handlers: ChatPaneHandlers) { + this.log = new LogView((id) => { + void this.handlers.resend(id) + }) this.statusEl = el('div', { class: 'status' }) this.actionsEl = el('div', { class: 'status-actions' }) this.membersEl = el('div', { class: 'members' }) diff --git a/src/ui/copy.ts b/src/ui/copy.ts index 39c404b..66eae9e 100644 --- a/src/ui/copy.ts +++ b/src/ui/copy.ts @@ -44,5 +44,7 @@ export const copy = { duplicateTab: '另一个标签页也开着禅聊,会显示成两个人。', theme: '主题', delivered: '送达', + sending: '发送中', undelivered: '未送达', + resend: '点此重试', } diff --git a/src/ui/log-view.ts b/src/ui/log-view.ts index e8d2482..c1d0c1b 100644 --- a/src/ui/log-view.ts +++ b/src/ui/log-view.ts @@ -9,7 +9,7 @@ export class LogView { private ids = new Set() private nodes = new Map() - constructor() { + constructor(private onResend?: (id: string) => void) { this.el = el('div', { class: 'log', role: 'log', 'aria-live': 'polite' }) } @@ -31,7 +31,7 @@ export class LogView { return } this.ids.add(line.id) - const node = renderLine(line) + const node = this.render(line) this.nodes.set(line.id, node) this.el.append(node) if (stick) this.stick() @@ -40,11 +40,15 @@ export class LogView { private replace(line: ChatLine): void { const previous = this.nodes.get(line.id) if (!previous) return - const next = renderLine(line) + const next = this.render(line) this.nodes.set(line.id, next) previous.replaceWith(next) } + private render(line: ChatLine): HTMLElement { + return renderLine(line, this.onResend) + } + private stick(): void { const node = this.el requestAnimationFrame(() => { @@ -54,17 +58,12 @@ export class LogView { } } -function renderLine(line: ChatLine): HTMLElement { +function renderLine(line: ChatLine, onResend?: (id: string) => void): HTMLElement { if (line.kind === 'system') { return el('div', { class: 'system', 'data-id': line.id }, [line.text]) } const stamp = isStamp(line.text) - const mark = - line.self && line.delivery === 'acked' - ? el('span', { class: 'delivery' }, [copy.delivered]) - : line.self && line.delivery === 'failed' - ? el('span', { class: 'delivery fail' }, [copy.undelivered]) - : null + const mark = deliveryMark(line, onResend) const header = el('header', {}, [ el('b', { style: `color:${colorFromId(line.fromId)}` }, [line.nick]), el('time', {}, [formatTime(line.ts)]), @@ -75,3 +74,15 @@ function renderLine(line: ChatLine): HTMLElement { el('p', {}, [line.text]), ]) } + +function deliveryMark(line: Extract, onResend?: (id: string) => void): HTMLElement | null { + if (!line.self || !line.delivery) return null + if (line.delivery === 'acked') return el('span', { class: 'delivery' }, [copy.delivered]) + if (line.delivery === 'pending') return el('span', { class: 'delivery pending' }, [copy.sending]) + const button = el('button', { class: 'delivery fail', type: 'button', title: copy.resend }, [copy.undelivered]) + button.addEventListener('click', (event) => { + event.stopPropagation() + onResend?.(line.id) + }) + return button +} diff --git a/src/ui/styles.css b/src/ui/styles.css index e207628..dc5f240 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -463,6 +463,21 @@ textarea:focus-visible { font-size: 11px; } +.bubble.self .delivery.pending { + opacity: 0.85; +} + +.bubble.self button.delivery { + font: inherit; + color: inherit; + background: none; + border: 0; + padding: 0; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + .bubble.self header, .bubble.self .muted { color: color-mix(in srgb, var(--on-accent) 74%, transparent);