From 3c494f4e20962834b5d991115e0141c497c3f322 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 21:04:45 +0000 Subject: [PATCH] Harden session lifecycle, timers, and canonical room routing Failed joins now tear down the transport instead of leaking a zombie connection. Outbound ack timers pause while the tab is hidden, presence prune skips peers still in the ICE set, and inbound payloads dispatch through the router. Room names are canonical at the navigation boundary, empty rooms are rejected, the hash router is a single pipeline, and the log cache snapshots on a debounce. Still v1 wire, static Pages, no TURN. Co-authored-by: Andy --- 404.html | 2 +- README.md | 9 ++- assets/index-DfhOSYdI.js | 3 - assets/index-DfinYlQ3.js | 3 + index.html | 2 +- src/config/app.ts | 1 + src/core/cache.ts | 41 ++++++---- src/core/outbound.test.ts | 15 ++++ src/core/outbound.ts | 58 +++++++++++--- src/core/payload-router.test.ts | 13 +++- src/core/payload-router.ts | 22 ++++++ src/core/presence.test.ts | 10 +++ src/core/presence.ts | 5 +- src/core/recent.test.ts | 5 ++ src/core/recent.ts | 11 +-- src/core/room-manager.test.ts | 15 +++- src/core/room-manager.ts | 63 +++++++++++---- src/core/room.test.ts | 11 ++- src/core/room.ts | 6 ++ src/core/router.test.ts | 6 ++ src/core/router.ts | 11 +-- src/core/scheduler.ts | 12 ++- src/core/session.test.ts | 32 +++++++- src/core/session.ts | 132 +++++++++++++++++++------------- src/core/transcript.ts | 16 +--- src/core/transports/trystero.ts | 1 + src/ui/app.ts | 27 +++---- src/ui/chat-pane.ts | 9 ++- src/ui/copy.ts | 3 +- src/ui/jump-dialog.ts | 22 +++++- src/ui/lobby.ts | 23 +++--- src/ui/room-chrome.ts | 4 +- 32 files changed, 418 insertions(+), 175 deletions(-) delete mode 100644 assets/index-DfhOSYdI.js create mode 100644 assets/index-DfinYlQ3.js diff --git a/404.html b/404.html index 61d7008..0062527 100644 --- a/404.html +++ b/404.html @@ -19,7 +19,7 @@ 禅聊 ZenChat - + diff --git a/README.md b/README.md index a1110cc..5dca520 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,11 @@ flowchart LR | 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). | +| Heartbeat | Hello interval, relay/RTT poll, visibility pause, stale presence prune (skips peers still in the transport set). | +| PayloadRouter | Decode v1 hello/typing/ack/chat and dispatch to session effects. | +| Outbound | Delivery timers that pause while the tab is hidden, plus the original payload so a late ack or tap-to-resend can settle. | +| Presence / Transcript | Members + typing TTL + last-seen prune; capped message log, shared durable-line normalize (drop pending, keep failed). | +| Room names | One canonical `RoomSpec` at the navigation boundary. Empty names are rejected; lobby/jump/hash/recent all store the same id. | | 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-DfhOSYdI.js b/assets/index-DfhOSYdI.js deleted file mode 100644 index ccb7658..0000000 --- a/assets/index-DfhOSYdI.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 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/assets/index-DfinYlQ3.js b/assets/index-DfinYlQ3.js new file mode 100644 index 0000000..a090633 --- /dev/null +++ b/assets/index-DfinYlQ3.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 l of o.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&n(l)}).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 At="zenchat.andyccr.v1",m=1,H=24,V=64,K=4e3,dt=10,L=200,Nt=L*2,_t="lobby",xt=2500,jt=1800,ut=25e3,Ot=5e3,$t=ut*3,z=8e3,Dt=2e3,Jt="https://github.com/Andyccr/ZenChat",N="zenchat.nick",U="zenchat.peerId",W=["闲云","青石","疏影","晚风","折柳","听雨","归舟","松风"];function _(e){const t=new Uint8Array(e);return crypto.getRandomValues(t),[...t].map(s=>s.toString(16).padStart(2,"0")).join("")}const G=["#c15f3c","#8b5e3c","#5c6b4a","#6b4f3a","#9a6b3f","#4a6b62","#7a4e3a","#5a5568"];function pt(e){let t=2166136261;for(let s=0;s{const i=n.codePointAt(0)??0;return i>=32&&i!==127}).join("").slice(0,H)}function mt(e){const t=Number.parseInt(e.slice(0,4),16)%W.length;return W[t]??"闲云"}function ft(){const e=sessionStorage.getItem(U),t=e&&/^[0-9a-f]{16,}$/i.test(e)?e:_(12);sessionStorage.setItem(U,t);const s=localStorage.getItem(N),n=b(s??"")||mt(t);return localStorage.setItem(N,n),{id:t,nick:n}}function q(e){const t=b(e)||mt(ft().id);return localStorage.setItem(N,t),t}function Bt(e){return e==="torrent"||e==="nostr"}function f(e){return e.normalize("NFKC").trim().replace(/\s+/g,"-").replace(/[/#?&]+/g,"").slice(0,V)}function Ht(e){const t=f(e.name);return e.password?`${t}::${e.password}`:t}function E(e){return`${e.strategy}:${f(e.name)}`}function x(e){return`${e.strategy}:${f(e.name)}:${e.password}`}function Vt(e,t){return x(e)===x(t)}function v(e){const t=f(e.name);return t?{name:t,password:e.password,strategy:e.strategy}:null}const Kt="zenchat.cache.v1.";function yt(e){return Kt+x(e)}function Ft(e){try{const t=sessionStorage.getItem(yt(e));if(!t)return[];const s=JSON.parse(t);return Array.isArray(s)?s.filter(Ut).slice(-L):[]}catch{return[]}}function gt(e){return e.kind!=="chat"?e:e.delivery!=="pending"&&e.delivery!=="acked"&&e.delivery!=="failed"?{kind:"chat",id:e.id,fromId:e.fromId,nick:e.nick,text:e.text,ts:e.ts,self:e.self}: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 zt(e){return j(e.map(gt))}function Y(e,t){try{sessionStorage.setItem(yt(e),JSON.stringify(zt(t)))}catch{}}function j(e){return e.length>L?e.slice(-L):e}function Ut(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"&&(t.delivery===void 0||t.delivery==="pending"||t.delivery==="acked"||t.delivery==="failed"):!1}const Wt="zenchat.pass.v1.";function bt(e){return Wt+E(e)}function O(e){try{const t=bt(e);e.password?sessionStorage.setItem(t,e.password):sessionStorage.removeItem(t)}catch{}}function wt(e){try{return sessionStorage.getItem(bt(e))??""}catch{return""}}function X(e){if(e.password)return O(e),e;const t=wt(e);return t?{...e,password:t}:e}const vt="zenchat.recentRooms";function kt(){try{const e=localStorage.getItem(vt);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,dt):[]}catch{return[]}}function Gt(e){const t=v(e)??e,s={name:f(t.name)||t.name,strategy:t.strategy,hasPassword:!!t.password,visitedAt:Date.now()},n=kt().filter(o=>E(o)!==E(t)),i=[s,...n].slice(0,dt);return localStorage.setItem(vt,JSON.stringify(i)),i}function qt(e,t){const s=wt(e),i=(!!(t&&f(e.name)===f(t.name)&&e.strategy===t.strategy)?t?.password??"":"")||s;return e.hasPassword&&!i?"need-password":{name:e.name,password:i,strategy:e.strategy}}const $={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)}};class Yt{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 Z{constructor(t,s,n){this.runtime=t,this.timeoutMs=s,this.onExpire=n,this.unbindVisibility=this.runtime.onVisibilityChange(this.onVisibility)}runtime;timeoutMs;onExpire;timers=new Map;payloads=new Map;deadlines=new Map;unbindVisibility=null;expect(t,s){this.cancelTimer(t),s!==void 0&&this.payloads.set(t,s),this.deadlines.set(t,this.runtime.now()+this.timeoutMs),this.arm(t)}ack(t){return!this.deadlines.has(t)&&!this.payloads.has(t)?!1:(this.cancel(t),!0)}payload(t){return this.payloads.get(t)}cancel(t){this.cancelTimer(t),this.deadlines.delete(t),this.payloads.delete(t)}clear(){for(const t of new Set([...this.deadlines.keys(),...this.payloads.keys()]))this.cancel(t)}dispose(){this.clear(),this.unbindVisibility?.(),this.unbindVisibility=null}onVisibility=()=>{if(this.runtime.hidden()){for(const t of[...this.timers.keys()])this.cancelTimer(t);return}for(const t of[...this.deadlines.keys()])this.arm(t)};arm(t){const s=this.deadlines.get(t);if(s===void 0||(this.cancelTimer(t),this.runtime.hidden()))return;const n=s-this.runtime.now();if(n<=0){this.deadlines.delete(t),this.onExpire(t);return}this.timers.set(t,this.runtime.setTimeout(()=>{this.timers.delete(t),this.arm(t)},n))}cancelTimer(t){const s=this.timers.get(t);s!==void 0&&this.runtime.clearTimeout(s),this.timers.delete(t)}}const C="ack";function Xt(e){return typeof e=="object"&&e!==null}function Zt(e){return e===m}function Qt(e){return Array.isArray(e)?[...new Set(e.filter(t=>t===C))]:[]}function Q(e){return typeof e!="string"||e.length<8||e.length>80?null:e}function tt(e,t,s,n){return{v:m,type:"chat",id:s,ts:n,nick:b(e),text:t.trim().slice(0,K)}}function et(e){return{v:m,type:"hello",nick:b(e),features:[C]}}function te(e){return{v:m,type:"typing",nick:b(e)}}function st(e,t){return{v:m,type:"ack",id:t,nick:b(e)}}function ee(e){if(!Xt(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:m,type:"hello",nick:t,features:Qt(e.features)};if(e.type==="typing")return{v:m,type:"typing",nick:t};if(e.type==="ack"){const s=Q(e.id);return s?{v:m,type:"ack",id:s,nick:t}:null}if(e.type==="chat"){const s=Q(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,K);return n?{v:m,type:"chat",id:s,ts:e.ts,nick:t,text:n}:null}return null}function se(e,t){const s=ee(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}function ne(e,t,s,n){const i=se(t,s);return i?(i.type==="hello"?n.hello(e,i.nick,i.features):i.type==="typing"?n.typing(e,i.nick):i.type==="ack"?n.ack(e,i.id,i.nick):n.chat(e,i),!0):!1}class ie{constructor(t,s=()=>{},n=xt){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(),l={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,l),l}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,s=[]){const n=new Set(s),i=this.runtime.now()-t,o=this.list().filter(l=>l.lastSeenAt{const o=s();o-ne(...n);let t=0,s=null;return(...n)=>{s=n,!t&&(t=requestAnimationFrame(()=>{t=0;const i=s;s=null,i&&e(...i)}))}}function D(){return{state:"idle",peers:0,relaysDown:!1}}function S(e){return e.state==="joining"||e.state==="waiting"||e.state==="live"||e.state==="relay_down"}function R(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 g(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:R({...e,state:"waiting"});case"join_err":return e.state!=="joining"?e:{state:"failed",peers:0,relaysDown:!1};case"peers":return S(e)?R({...e,peers:t.count}):e;case"relays":return S(e)?R({...e,relaysDown:t.down}):e;case"force_down":return!S(e)||e.peers>0?e:{...e,relaysDown:!0,state:"relay_down"};case"leave":return e.state==="idle"?e:{...e,state:"leaving"};case"left":return D()}}function ae(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 Et(e){return e==="nostr"?p.connectingNostr:p.connectingTorrent}function ce(e){return`已直连 ${e} 人`}function le(e){return`无法启动 P2P:${e}`}function he(e){return e.filter(t=>t.readyState===1).length}function Tt(e){return e.length>0&&he(e)===0}function de(e,t){return Tt(t)?{phase:"error",detail:e==="nostr"?p.relaysDownNostr:p.relaysDownTorrent}:{phase:"connecting",detail:p.waiting}}function ue(e){if(e.life.state==="failed")return null;if(e.life.state==="live")return{phase:ae(e.life),detail:ce(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=de(e.strategy,t);return{phase:s.phase,detail:e.life.state==="joining"?Et(e.strategy):s.detail,relays:e.relays,peerCount:e.peerCount}}function pe(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)}class me{lines=[];seen=new Set;seenOrder=[];hydrate(t){this.clear(),this.lines=j(t.map(gt));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>Nt;){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=j(this.lines),t}patchDelivery(t,s){const n=this.lines.findIndex(l=>l.id===t),i=n>=0?this.lines[n]:void 0;if(n<0||!i||i.kind!=="chat")return null;const o=J({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 J(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 fe(e,t,s){return{kind:"system",id:e,text:t,ts:s}}const ye="modulepreload",ge=function(e){return"/ZenChat/"+e},nt={},it=function(t,s,n){let i=Promise.resolve();if(s&&s.length>0){let d=function(u){return Promise.all(u.map(w=>Promise.resolve(w).then(k=>({status:"fulfilled",value:k}),k=>({status:"rejected",reason:k}))))};var l=d;document.getElementsByTagName("link");const h=document.querySelector("meta[property=csp-nonce]"),c=h?.nonce||h?.getAttribute("nonce");i=d(s.map(u=>{if(u=ge(u),u in nt)return;nt[u]=!0;const w=u.endsWith(".css"),k=w?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${u}"]${k}`))return;const y=document.createElement("link");if(y.rel=w?"stylesheet":ye,w||(y.as="script"),y.crossOrigin="",y.href=u,c&&y.setAttribute("nonce",c),document.head.appendChild(y),w)return new Promise((Rt,Mt)=>{y.addEventListener("load",Rt),y.addEventListener("error",()=>Mt(new Error(`Unable to preload CSS for ${u}`)))})}))}function o(h){const c=new Event("vite:preloadError",{cancelable:!0});if(c.payload=h,window.dispatchEvent(c),!c.defaultPrevented)throw h}return i.then(h=>{for(const c of h||[])c.status==="rejected"&&o(c.reason);return t().catch(o)})},be=[{urls:"stun:stun.cloudflare.com:3478"},{urls:"stun:stun.l.google.com:19302"}],we={iceServers:be,iceCandidatePoolSize:8,bundlePolicy:"max-bundle",rtcpMuxPolicy:"require"},ve=["wss://tracker.webtorrent.dev","wss://tracker.openwebtorrent.com","wss://open.ftorrent.com:443"],ke=["wss://relay.damus.io","wss://nos.lol","wss://relay.snort.social","wss://nostr.wine"];function Ee(e){return Object.entries(e).map(([t,s])=>({url:t,readyState:s?.readyState??WebSocket.CLOSED}))}class Te{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 B(t.strategy);const n=t.strategy==="torrent"?[...ve]:[...ke],i=this.module.joinRoom({appId:t.appId,...t.password?{password:t.password}:{},rtcConfig:we,trickleIce:!0,relayConfig:{urls:n}},t.roomId,{onJoinError:l=>{s.onJoinError(l.error||"无法与对等节点完成握手")}}),o=i.makeAction("zen");o.onMessage=(l,{peerId:h})=>{s.onPayload(h,l)},i.onPeerJoin=l=>s.onPeerJoin(l),i.onPeerLeave=l=>s.onPeerLeave(l),this.room=i,this.action=o}async leave(){const t=this.room;if(this.module=null,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?Ee(this.module.getRelaySockets()):[]}}const rt=new Map;async function B(e){const t=rt.get(e);if(t)return t;const s=e==="torrent"?it(()=>import("./index-BgLl3BAq.js"),__vite__mapDeps([0,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId})):it(()=>import("./index-DSJFV8_4.js"),__vite__mapDeps([2,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId}));return rt.set(e,s),s}function Se(){B("torrent"),B("nostr")}function Le(e){return new Te(e)}class Ce{transport;identity;listeners;createTransport;runtime;transcript=new me;presence;outbound;heartbeat;life=D();strategy="torrent";joinErrors=0;epoch=0;sendTypingThrottled;status={phase:"idle",detail:p.idle,relays:[],peerCount:0};emitMembers=oe(()=>{this.listeners.onMembers?.(this.presence.list())});constructor(t,s,n={}){this.identity=t,this.listeners=s,this.createTransport=n.createTransport??Le,this.runtime=n.runtime??$,this.transport=this.createTransport("torrent"),this.presence=new ie(this.runtime,()=>this.emitMembers()),this.outbound=new Z(this.runtime,z,i=>this.expireAck(i)),this.heartbeat=new Yt(this.runtime,ut,Ot,{onHello:()=>this.announce(),onPoll:()=>this.poll()}),this.sendTypingThrottled=re(()=>{this.isJoined()&&this.transport.send(te(this.identity.nick))},jt,()=>this.runtime.now())}get selfId(){return this.transport.selfId()||this.identity.id}isJoined(){return S(this.life)}getLines(){return this.transcript.snapshot()}hydrate(t){this.listeners.onReset?.(this.transcript.hydrate(t))}async join(t){await this.teardown(!1),this.strategy=t.strategy,this.joinErrors=0,this.transport=this.createTransport(t.strategy),this.life=g(D(),{type:"join"}),this.setStatus({phase:"connecting",detail:Et(t.strategy),relays:[],peerCount:0});const s=this.epoch;try{await this.transport.join({appId:At,roomId:Ht({name:f(t.name),password:t.password}),password:t.password,strategy:t.strategy},{onPeerJoin:n=>this.onPeerJoin(n),onPeerLeave:n=>this.onPeerLeave(n),onPayload:(n,i)=>this.handlePayload(n,i),onJoinError:n=>this.onJoinError(n)})}catch(n){if(s!==this.epoch)return;this.life=g(this.life,{type:"join_err"}),await this.teardown(!1);const i=n instanceof Error?n.message:"连接失败";throw this.refreshStatus("error",le(i)),n}if(s!==this.epoch){await this.transport.leave();return}this.life=g(this.life,{type:"join_ok"}),this.heartbeat.start(),this.announce(),this.emitStatus()}async sendChat(t){if(!this.isJoined())return"closed";const s=tt(this.identity.nick,t,_(8),this.runtime.now());return s.text?this.dispatch(s,this.presence.supports(C)):"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)??tt(this.identity.nick,s.text,s.id,s.ts);return this.dispatch(i,this.presence.supports(C),!0)}sendTyping(){this.sendTypingThrottled()}setNick(t){this.identity={...this.identity,nick:t},this.isJoined()&&this.announce()}async leave(t={}){this.life=g(this.life,{type:"leave"}),await this.teardown(!0),this.life=g(this.life,{type:"left"}),t.silent||(this.setStatus({phase:"idle",detail:p.left,relays:[],peerCount:0}),this.emitMembers(),this.listeners.onReset?.([]))}async teardown(t){this.epoch+=1,this.heartbeat.stop(),this.outbound.dispose(),this.outbound=new Z(this.runtime,z,s=>this.expireAck(s)),this.presence.clear(),await this.transport.leave(),t&&this.transcript.clear()}live(t){return t===this.epoch&&this.isJoined()}async dispatch(t,s,n=!1){const i=this.epoch;n?this.patchDelivery(t.id,s?"pending":void 0):this.pushLine(J({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.live(i)?(this.patchDelivery(t.id,"failed"),this.pushSystem(p.sendFailed),"failed"):"closed"}return this.live(i)?(s&&this.outbound.expect(t.id,t),"sent"):"closed"}handlePayload(t,s){this.isJoined()&&ne(t,s,n=>this.transcript.has(n),{hello:(n,i,o)=>{this.presence.upsert(n,i,o),this.emitMembers()},typing:(n,i)=>{this.presence.upsert(n,i),this.presence.markTyping(n),this.emitMembers()},ack:(n,i,o)=>{this.presence.upsert(n,o),this.outbound.ack(i)&&this.patchDelivery(i,"acked")},chat:(n,i)=>{if(this.presence.upsert(n,i.nick),i.duplicate){this.transport.send(st(this.identity.nick,i.id),n);return}this.pushLine(J({id:i.id,fromId:n,nick:i.nick,text:i.text,ts:i.ts,self:!1})),this.transport.send(st(this.identity.nick,i.id),n),this.emitMembers()}})}onPeerJoin(t){if(!this.isJoined())return;const s=this.epoch;this.presence.upsert(t,"访客"),this.pushSystem(`${t.slice(0,6)} 加入了房间`),this.transport.send(et(this.identity.nick),t),this.emitMembers(),this.notePeers(),this.runtime.setTimeout(()=>{this.live(s)&&this.measure(t)},350)}onPeerLeave(t){if(!this.isJoined())return;const s=this.presence.remove(t);this.pushSystem(`${s?.nick??t.slice(0,6)} 离开了房间`),this.emitMembers(),this.notePeers()}onJoinError(t){this.joinErrors+=1,!(this.transport.peerIds().length>0)&&(this.joinErrors>=3?(this.life=g(this.life,{type:"force_down"}),this.emitStatus()):this.refreshStatus("connecting",t?`${p.handshake}(${t})`: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(et(this.identity.nick))}poll(){if(!this.isJoined())return;const t=this.presence.prune($t,this.transport.peerIds());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(fe(_(6),t,this.runtime.now()))}pushLine(t){const s=this.transcript.append(t);s&&this.listeners.onLine?.(s)}notePeers(){this.life=g(this.life,{type:"peers",count:this.presence.list().length}),this.emitStatus()}emitStatus(){this.life=g(this.life,{type:"relays",down:Tt(this.transport.relays())});const t=ue({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){pe(this.status,t)||(this.status=t,this.listeners.onStatus?.(t))}}class Ie{identity;emit;options;session=null;spec=null;generation=0;saveTimer=null;constructor(t,s,n={}){this.identity=t,this.emit=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){const n=v(t);if(!n)return!1;if(!s&&this.spec&&this.session?.isJoined()&&Vt(this.spec,n))return!0;const i=++this.generation;if(await this.snapshotAndClose(),i!==this.generation)return!1;Gt(n),this.emit.onMembers?.([]);const o=new Ce(this.identity,this.boundListeners(),this.options),l=Ft(n);l.length>0?o.hydrate(l):this.emit.onReset?.([]),this.session=o,this.spec=n;try{await o.join(n)}catch(h){if(i!==this.generation)return!1;throw h}return i!==this.generation?(await o.leave({silent:!0}),!1):o.isJoined()}async close(){this.generation+=1,await this.snapshotAndClose()}snapshot(){this.session&&this.spec&&Y(this.spec,this.session.getLines())}boundListeners(){return{onStatus:t=>this.emit.onStatus?.(t),onMembers:t=>this.emit.onMembers?.(t),onLine:t=>{this.emit.onLine?.(t),this.scheduleSnapshot()},onReset:t=>this.emit.onReset?.(t)}}scheduleSnapshot(){if(this.saveTimer!==null||!this.spec)return;const t=this.options.runtime??$;this.saveTimer=t.setTimeout(()=>{this.saveTimer=null,this.snapshot()},Dt)}clearSaveTimer(){if(this.saveTimer===null)return;(this.options.runtime??$).clearTimeout(this.saveTimer),this.saveTimer=null}async snapshotAndClose(){this.clearSaveTimer(),this.session&&this.spec&&(Y(this.spec,this.session.getLines()),await this.session.leave({silent:!0})),this.session=null,this.spec=null}}function ot(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=f(decodeURIComponent(n[1]));if(!i)return{name:"lobby"};const o=ot(s.searchParams,"s"),l=Bt(o)?o:"torrent";return{name:"room",spec:{name:i,password:ot(s.searchParams,"k"),strategy:l}}}return{name:"lobby"}}function I(e,t=!1){if(e.name==="lobby")return"#/";const s=v(e.spec)??e.spec,n=new URLSearchParams;n.set("s",s.strategy),t&&s.password&&n.set("k",s.password);const i=n.toString();return`#/r/${encodeURIComponent(s.name)}${i?`?${i}`:""}`}function Re(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:"口令",needPassword:"这个房间设了口令",duplicateTab:"另一个标签页也开着禅聊,会显示成两个人。",theme:"主题",delivered:"送达",sending:"发送中",undelivered:"未送达",resend:"点此重试",typing:"输入中"};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 F(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function Me(e){return new Intl.DateTimeFormat("zh-CN",{hour:"2-digit",minute:"2-digit",hour12:!1}).format(e)}function Ae(e){switch(e){case WebSocket.CONNECTING:return"连接中";case WebSocket.OPEN:return"已连接";case WebSocket.CLOSING:return"断开中";default:return"未连接"}}function Ne(e){try{return new URL(e).host}catch{return e}}function St(){return typeof RTCPeerConnection=="function"}function P(){return window.isSecureContext&&St()}function _e(){return window.isSecureContext?St()?r("div"):r("div",{class:"banner"},[a.webrtcMissing]):r("div",{class:"banner"},[a.insecure])}const Lt=["(´・ω・`)","(。•́︿•̀。)","(´▽`ʃ♡ƪ)","(╯°□°)╯︵ ┻━┻","¯\\_(ツ)_/¯","(づ。◕‿‿◕。)づ","(ง'̀-'́)ง","( ̄▽ ̄)","(•̀ᴗ•́)و","ฅ^•ﻌ•^ฅ","(´;ω;`)","(≧∇≦)ノ","(´∀`)♡","┐( ̄ヘ ̄)┌"],Ct=["😀","😂","🥰","😎","🤔","😭","🙏","✨","🔥","🎉","👍","👋","❤️","🌸","🍵","🌙"],xe=new Set(Lt),je=new Set(Ct);function Oe(e){const t=e.trim();return t?xe.has(t)||je.has(t)?!0:t.length<=8&&/^[\p{Emoji_Presentation}\s]+$/u.test(t):!1}function at(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 $e{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 De(t,this.onResend)}stick(){const t=this.el;requestAnimationFrame(()=>{t.scrollHeight-t.scrollTop-t.clientHeight<96&&(t.scrollTop=t.scrollHeight)})}}function De(e,t){if(e.kind==="system")return r("div",{class:"system","data-id":e.id},[e.text]);const s=Oe(e.text),n=Je(e,t),i=r("header",{},[r("b",{style:`color:${pt(e.fromId)}`},[e.nick]),r("time",{},[Me(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 Je(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 Be{el;open=!1;constructor(t){const s=r("div",{class:"picker-grid kaomoji"},Lt.map(l=>ct(l,()=>at(t,l)))),n=r("div",{class:"picker-grid emoji hidden"},Ct.map(l=>ct(l,()=>at(t,l)))),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 ct(e,t){const s=r("button",{class:"stamp-btn",type:"button"},[e]);return s.addEventListener("click",t),s}class He{constructor(t){this.handlers=t,this.log=new $e(c=>{this.handlers.resend(c)}),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",c=>{c.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:K,enterkeyhint:"send","aria-label":a.placeholder});s.setAttribute("enterkeyhint","send"),s.setAttribute("autocapitalize","sentences");const n=new Be(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",c=>{c.stopPropagation(),n.toggle()}),this.peopleBtn.addEventListener("pointerdown",c=>c.stopPropagation()),document.addEventListener("pointerdown",c=>{this.root.contains(c.target)&&(c.target.closest(".picker, .people, .side")||n.hide())}),n.el.addEventListener("click",c=>c.stopPropagation());const l=()=>{this.handlers.onNick(this.nickInput.value);const c=s.value;c.trim()&&(s.value="",s.style.height="auto",n.hide(),this.handlers.send(c).then(d=>{(d==="failed"||d==="closed")&&(s.value=c)}))};o.addEventListener("click",l),s.addEventListener("keydown",c=>{c.key==="Enter"&&!c.shiftKey&&(c.preventDefault(),l())}),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:H,value:t.nick,"aria-label":a.nick});const h=()=>this.handlers.onNick(this.nickInput.value);this.nickInput.addEventListener("change",h),this.nickInput.addEventListener("blur",h),this.nickInput.addEventListener("keydown",c=>{c.key==="Enter"&&(c.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=>`${Ne(o.url)} ${Ae(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:${pt(n.id)}`},[n.nick]),r("span",{class:"muted"},[n.typing?a.typing:n.rttMs!==null?`${Math.round(n.rttMs)}ms`:""])]))}renderActions(t){if(F(this.actionsEl),t.phase==="error"){this.actionsEl.append(M(a.retry,this.handlers.onRetry),M(a.switchSignal,this.handlers.onSwitchSignal));return}t.phase==="connecting"&&t.peerCount===0&&this.actionsEl.append(M(a.share,this.handlers.onShare))}}function M(e,t){const s=r("button",{class:"btn ghost compact",type:"button"},[e]);return s.addEventListener("click",n=>{n.stopPropagation(),t()}),s}function Ve(e){const t=r("input",{placeholder:a.room,maxlength:V,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("p",{class:"hint hidden"},[a.needPassword]),o=r("button",{class:"btn primary",type:"submit"},[a.join]);P()||(o.disabled=!0);const l=r("form",{class:"jump-form",method:"dialog"},[r("h2",{},[a.switch]),i,t,s,n,r("div",{class:"actions"},[o,r("button",{class:"btn ghost",type:"button",value:"cancel"},[a.cancel])])]),h=r("dialog",{class:"jump"},[l]);return l.addEventListener("submit",c=>{if(c.preventDefault(),!P())return;const d=v({name:t.value,password:s.value,strategy:n.value});if(!d){t.focus();return}h.close(),e(d)}),l.querySelector('button[value="cancel"]')?.addEventListener("click",()=>h.close()),{el:h,open(c){t.value=c?.name??"",s.value=c?.password??"",n.value=c?.strategy??"torrent",i.classList.toggle("hidden",!c?.needPassword),h.showModal(),c?.needPassword?s.focus():t.focus()},close:()=>h.close()}}function Ke(e){const t=r("input",{maxlength:H,value:e.nick,autocomplete:"nickname"}),s=r("input",{maxlength:V,value:_t,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]);P()||(o.disabled=!0);const l=r("form",{class:"panel form lobby-form"},[r("div",{class:"join-row"},[T(a.nick,t),T(a.room,s),o]),r("details",{class:"more"},[r("summary",{},[a.more]),r("div",{class:"row"},[T(a.password,n),T(a.strategy,i)]),r("span",{class:"hint"},[a.passwordHint])])]);return l.addEventListener("submit",h=>{if(h.preventDefault(),!P())return;const c=v({name:s.value,password:n.value,strategy:i.value});if(!c){s.focus();return}e.onJoin({nick:t.value,spec:c})}),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])]),l])}function T(e,t){return r("label",{class:"field"},[r("span",{},[e]),t])}function Fe(e,t,s,n=!1){return`${e}${t}${I({name:"room",spec:s},n)}`}async function ze(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 Ue(e,t,s){F(e);for(const i of kt()){const o=!!(t&&E(i)===E(t)),l=i.hasPassword?[i.name,r("span",{class:"lock",title:a.locked},["锁"])]:[i.name],h=r("a",{class:`tab${o?" on":""}`,href:I({name:"room",spec:{name:i.name,password:"",strategy:i.strategy}})},l);h.addEventListener("click",c=>{c.preventDefault();const d=qt(i,t);if(d==="need-password"){s.onNeedPassword(i);return}s.onGo(d)}),e.append(h)}const n=r("button",{class:"tab add",type:"button",title:a.addRoom},["+"]);n.addEventListener("click",()=>s.onAdd()),e.append(n)}function We(e,t,s,n){if(F(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 lt(e,t,s){const n=Fe(location.origin,location.pathname,e,t),i=await ze(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 It="zenchat.theme";function Ge(){const e=localStorage.getItem(It);return e==="light"||e==="dark"||e==="system"?e:"system"}function qe(e){localStorage.setItem(It,e)}function Ye(e){return e==="system"?"light":e==="light"?"dark":"system"}function Xe(){return window.matchMedia("(prefers-color-scheme: dark)").matches}function Ze(e,t=Xe()){return e==="system"?t?"dark":"light":e}function A(e){const t=Ze(e);return document.documentElement.dataset.theme=t,document.documentElement.dataset.themePref=e,document.documentElement.style.colorScheme=t,t}function ht(e){return e==="light"?"白天":e==="dark"?"黑夜":"跟随系统"}class Qe{root;theme;identity=ft();manager;themeBtn;tabsEl;toolsEl;main;jump;footerEl;dupBanner;chat=null;lobbyEl=null;stopTabWatch=null;constructor(t){this.root=t,this.theme=Ge(),A(this.theme),this.themeBtn=r("button",{class:"btn ghost",type:"button",title:a.theme},[ht(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=Ve(s=>this.go(s)),this.dupBanner=r("div",{class:"banner warn hidden"},[a.duplicateTab]),this.footerEl=r("p",{class:"footer"},[r("a",{href:Jt,target:"_blank",rel:"noreferrer"},[a.source])," · AGPL-3.0"]),this.manager=new Ie(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=Re(()=>this.dupBanner.classList.remove("hidden")),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>A(this.theme)),window.addEventListener("hashchange",()=>{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=Ye(this.theme),qe(this.theme),A(this.theme),this.themeBtn.textContent=ht(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]),_e(),this.dupBanner,this.main,this.footerEl]),this.jump.el)}async route(){const t=Pe(location.hash);if(this.refreshChrome(t.name==="room"?X(t.spec):void 0),t.name==="lobby"){await this.manager.close(),this.showLobby();return}await this.showChat(this.stripPasswordFromHash(X(t.spec)))}stripPasswordFromHash(t){O(t);const s=I({name:"room",spec:t},!1);return t.password&&location.hash!==s&&history.replaceState(null,"",`${location.pathname}${location.search}${s}`),t}go(t){const s=v(t);if(!s)return;O(s);const n=I({name:"room",spec:s},!1);if(location.hash===n){this.showChat(s);return}location.hash=n}showLobby(){this.chat?.hide(),this.footerEl.hidden=!1,this.root.classList.remove("mode-chat");const t=Ke({nick:this.identity.nick,onJoin:({nick:s,spec:n})=>{this.identity={...this.identity,nick:q(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 He({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;lt(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:q(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{}}refreshChrome(t){Ue(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()}),We(this.toolsEl,this.themeBtn,t??null,(s,n,i)=>{lt(s,n,i)})}}function ts(){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 Pt=document.querySelector("#app");if(!Pt)throw new Error("缺少 #app 挂载点");ts();Se();new Qe(Pt).start(); diff --git a/index.html b/index.html index 61d7008..0062527 100644 --- a/index.html +++ b/index.html @@ -19,7 +19,7 @@ 禅聊 ZenChat - + diff --git a/src/config/app.ts b/src/config/app.ts index 520decc..13fac52 100644 --- a/src/config/app.ts +++ b/src/config/app.ts @@ -14,5 +14,6 @@ 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 CACHE_DEBOUNCE_MS = 2000 export const SOURCE_URL = 'https://github.com/Andyccr/ZenChat' export const PAGES_URL = 'https://andyccr.github.io/ZenChat/' diff --git a/src/core/cache.ts b/src/core/cache.ts index c3a6fc8..4f85f28 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -20,20 +20,20 @@ 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(durableLines(lines))) - } catch { - // Quota errors are non-fatal; the live session still works. +export function durableLine(line: ChatLine): ChatLine { + if (line.kind !== 'chat') return line + if (line.delivery !== 'pending' && line.delivery !== 'acked' && line.delivery !== 'failed') { + return { + kind: 'chat', + id: line.id, + fromId: line.fromId, + nick: line.nick, + text: line.text, + ts: line.ts, + self: line.self, + } } -} - -function dropPendingDelivery(line: ChatLine): ChatLine { - if (line.kind !== 'chat' || line.delivery !== 'pending') return line + if (line.delivery !== 'pending') return line return { kind: 'chat', id: line.id, @@ -45,6 +45,18 @@ function dropPendingDelivery(line: ChatLine): ChatLine { } } +export function durableLines(lines: ChatLine[]): ChatLine[] { + return trimLines(lines.map(durableLine)) +} + +export function saveRoomLog(spec: RoomSpec, lines: ChatLine[]): void { + try { + sessionStorage.setItem(cacheKey(spec), JSON.stringify(durableLines(lines))) + } catch { + // Quota errors are non-fatal; the live session still works. + } +} + export function trimLines(lines: ChatLine[]): ChatLine[] { return lines.length > MAX_LOG_LINES ? lines.slice(-MAX_LOG_LINES) : lines } @@ -62,7 +74,8 @@ function isChatLine(value: unknown): value is ChatLine { typeof row.nick === 'string' && typeof row.text === 'string' && typeof row.ts === 'number' && - typeof row.self === 'boolean' + typeof row.self === 'boolean' && + (row.delivery === undefined || row.delivery === 'pending' || row.delivery === 'acked' || row.delivery === 'failed') ) } return false diff --git a/src/core/outbound.test.ts b/src/core/outbound.test.ts index 0e3ca15..6863d97 100644 --- a/src/core/outbound.test.ts +++ b/src/core/outbound.test.ts @@ -15,6 +15,7 @@ describe('Outbound', () => { expect(outbound.payload('msg-1')).toEqual({ text: 'hi' }) expect(outbound.ack('msg-1')).toBe(true) expect(outbound.payload('msg-1')).toBeUndefined() + outbound.dispose() }) it('clears a timer when the ack arrives first', () => { @@ -26,5 +27,19 @@ describe('Outbound', () => { clock.advance(100) expect(expired).toEqual([]) expect(outbound.payload('msg-2')).toBeUndefined() + outbound.dispose() + }) + + it('does not expire while the tab is hidden, then settles remaining time on resume', () => { + const clock = createMemoryRuntime(0) + const expired: string[] = [] + const outbound = new Outbound(clock.runtime, 100, (id) => expired.push(id)) + outbound.expect('msg-3', { text: 'bg' }) + clock.setHidden(true) + clock.advance(250) + expect(expired).toEqual([]) + clock.setHidden(false) + expect(expired).toEqual(['msg-3']) + outbound.dispose() }) }) diff --git a/src/core/outbound.ts b/src/core/outbound.ts index f00e24d..70be2ed 100644 --- a/src/core/outbound.ts +++ b/src/core/outbound.ts @@ -1,29 +1,30 @@ import type { Runtime } from './runtime' +type Clock = Pick + export class Outbound { private timers = new Map() private payloads = new Map() + private deadlines = new Map() + private unbindVisibility: (() => void) | null = null constructor( - private runtime: Pick, + private runtime: Clock, private timeoutMs: number, private onExpire: (id: string) => void, - ) {} + ) { + this.unbindVisibility = this.runtime.onVisibilityChange(this.onVisibility) + } expect(id: string, payload?: T): void { this.cancelTimer(id) if (payload !== undefined) this.payloads.set(id, payload) - this.timers.set( - id, - this.runtime.setTimeout(() => { - this.timers.delete(id) - this.onExpire(id) - }, this.timeoutMs), - ) + this.deadlines.set(id, this.runtime.now() + this.timeoutMs) + this.arm(id) } ack(id: string): boolean { - if (!this.timers.has(id) && !this.payloads.has(id)) return false + if (!this.deadlines.has(id) && !this.payloads.has(id)) return false this.cancel(id) return true } @@ -34,11 +35,46 @@ export class Outbound { cancel(id: string): void { this.cancelTimer(id) + this.deadlines.delete(id) this.payloads.delete(id) } clear(): void { - for (const id of [...this.timers.keys(), ...this.payloads.keys()]) this.cancel(id) + for (const id of new Set([...this.deadlines.keys(), ...this.payloads.keys()])) this.cancel(id) + } + + dispose(): void { + this.clear() + this.unbindVisibility?.() + this.unbindVisibility = null + } + + private onVisibility = (): void => { + if (this.runtime.hidden()) { + for (const id of [...this.timers.keys()]) this.cancelTimer(id) + return + } + for (const id of [...this.deadlines.keys()]) this.arm(id) + } + + private arm(id: string): void { + const deadline = this.deadlines.get(id) + if (deadline === undefined) return + this.cancelTimer(id) + if (this.runtime.hidden()) return + const wait = deadline - this.runtime.now() + if (wait <= 0) { + this.deadlines.delete(id) + this.onExpire(id) + return + } + this.timers.set( + id, + this.runtime.setTimeout(() => { + this.timers.delete(id) + this.arm(id) + }, wait), + ) } private cancelTimer(id: string): void { diff --git a/src/core/payload-router.test.ts b/src/core/payload-router.test.ts index adf2abc..b68fd05 100644 --- a/src/core/payload-router.test.ts +++ b/src/core/payload-router.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { decodeIncoming } from './payload-router' +import { applyIncoming, decodeIncoming } from './payload-router' import { createAckPayload, createChatPayload, createHelloPayload, createTypingPayload } from './protocol' describe('decodeIncoming', () => { @@ -21,4 +21,15 @@ describe('decodeIncoming', () => { expect(decodeIncoming(chat, () => false)).toMatchObject({ type: 'chat', duplicate: false, text: '在吗' }) expect(decodeIncoming(chat, (id) => id === chat.id)).toMatchObject({ type: 'chat', duplicate: true }) }) + + it('dispatches decoded payloads to the matching handler', () => { + const seen: string[] = [] + applyIncoming('peer-a', createHelloPayload('晚风'), () => false, { + hello: (peerId, nick, features) => seen.push(`${peerId}:${nick}:${features.join(',')}`), + typing: () => seen.push('typing'), + ack: () => seen.push('ack'), + chat: () => seen.push('chat'), + }) + expect(seen).toEqual(['peer-a:晚风:ack']) + }) }) diff --git a/src/core/payload-router.ts b/src/core/payload-router.ts index e82595f..80fb31c 100644 --- a/src/core/payload-router.ts +++ b/src/core/payload-router.ts @@ -6,6 +6,13 @@ export type Incoming = | { type: 'ack'; nick: string; id: string } | { type: 'chat'; nick: string; id: string; text: string; ts: number; duplicate: boolean } +export type IncomingHandler = { + hello(peerId: string, nick: string, features: string[]): void + typing(peerId: string, nick: string): void + ack(peerId: string, id: string, nick: string): void + chat(peerId: string, incoming: Extract): void +} + export function decodeIncoming(raw: unknown, seen: (id: string) => boolean): Incoming | null { const payload = parsePayload(raw) if (!payload) return null @@ -21,3 +28,18 @@ export function decodeIncoming(raw: unknown, seen: (id: string) => boolean): Inc duplicate: seen(payload.id), } } + +export function applyIncoming( + peerId: string, + raw: unknown, + seen: (id: string) => boolean, + handler: IncomingHandler, +): boolean { + const incoming = decodeIncoming(raw, seen) + if (!incoming) return false + if (incoming.type === 'hello') handler.hello(peerId, incoming.nick, incoming.features) + else if (incoming.type === 'typing') handler.typing(peerId, incoming.nick) + else if (incoming.type === 'ack') handler.ack(peerId, incoming.id, incoming.nick) + else handler.chat(peerId, incoming) + return true +} diff --git a/src/core/presence.test.ts b/src/core/presence.test.ts index b513344..4b1b25d 100644 --- a/src/core/presence.test.ts +++ b/src/core/presence.test.ts @@ -37,4 +37,14 @@ describe('Presence', () => { expect(presence.prune(100).map((member) => member.id)).toEqual(['fresh']) expect(presence.list().map((member) => member.id)).toEqual(['stale']) }) + + it('keeps connected peer ids even when lastSeenAt is stale', () => { + const clock = createMemoryRuntime(1_000) + const presence = new Presence(clock.runtime) + presence.upsert('live', '晚风') + presence.upsert('ghost', '青石') + clock.advance(200) + expect(presence.prune(100, ['live']).map((member) => member.id)).toEqual(['ghost']) + expect(presence.list().map((member) => member.id)).toEqual(['live']) + }) }) diff --git a/src/core/presence.ts b/src/core/presence.ts index ff5a6e2..c7ec4bd 100644 --- a/src/core/presence.ts +++ b/src/core/presence.ts @@ -68,9 +68,10 @@ export class Presence { return [...this.members.values()].sort((a, b) => a.joinedAt - b.joinedAt) } - prune(maxAgeMs: number): Member[] { + prune(maxAgeMs: number, keep: Iterable = []): Member[] { + const held = new Set(keep) const cutoff = this.runtime.now() - maxAgeMs - const gone = this.list().filter((member) => member.lastSeenAt < cutoff) + const gone = this.list().filter((member) => member.lastSeenAt < cutoff && !held.has(member.id)) for (const member of gone) this.remove(member.id) return gone } diff --git a/src/core/recent.test.ts b/src/core/recent.test.ts index e9cd767..e031ce1 100644 --- a/src/core/recent.test.ts +++ b/src/core/recent.test.ts @@ -37,6 +37,11 @@ describe('recent rooms', () => { expect(list).toHaveLength(2) }) + it('stores a canonical room name', () => { + rememberRoom({ name: ' 茶 室 ', password: '', strategy: 'torrent' }) + expect(loadRecentRooms()[0]).toMatchObject({ name: '茶-室', strategy: 'torrent' }) + }) + it('asks for a password when a locked recent room has no stored secret', () => { rememberRoom({ name: 'vault', password: 'x', strategy: 'torrent' }) expect(specFromRecent(loadRecentRooms()[0]!)).toBe('need-password') diff --git a/src/core/recent.ts b/src/core/recent.ts index 3c50be1..b78fd26 100644 --- a/src/core/recent.ts +++ b/src/core/recent.ts @@ -1,5 +1,5 @@ import { MAX_RECENT_ROOMS } from '../config/app' -import { normalizeRoomName, recentRoomKey } from './room' +import { canonicalizeSpec, normalizeRoomName, recentRoomKey } from './room' import { recallSecret } from './secrets' import type { RoomSpec } from './types' @@ -36,13 +36,14 @@ export function loadRecentRooms(): RecentRoom[] { } export function rememberRoom(spec: RoomSpec): RecentRoom[] { + const clean = canonicalizeSpec(spec) ?? spec const next: RecentRoom = { - name: spec.name, - strategy: spec.strategy, - hasPassword: Boolean(spec.password), + name: normalizeRoomName(clean.name) || clean.name, + strategy: clean.strategy, + hasPassword: Boolean(clean.password), visitedAt: Date.now(), } - const others = loadRecentRooms().filter((item) => recentRoomKey(item) !== recentRoomKey(spec)) + const others = loadRecentRooms().filter((item) => recentRoomKey(item) !== recentRoomKey(clean)) const list = [next, ...others].slice(0, MAX_RECENT_ROOMS) localStorage.setItem(KEY, JSON.stringify(list)) return list diff --git a/src/core/room-manager.test.ts b/src/core/room-manager.test.ts index 89b5dc8..fe73955 100644 --- a/src/core/room-manager.test.ts +++ b/src/core/room-manager.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' +import { CACHE_DEBOUNCE_MS } from '../config/app' +import { loadRoomLog } from './cache' import { RoomManager } from './room-manager' import { createMemoryRuntime } from './runtime' import { FakeTransport } from './transports/fake' @@ -37,7 +39,7 @@ function manager() { }, }, ) - return { rm, created, resets } + return { rm, created, resets, clock } } describe('RoomManager', () => { @@ -102,4 +104,15 @@ describe('RoomManager', () => { expect(rm.isJoined()).toBe(true) expect(rm.current()?.name).toBe('two') }) + + it('rejects an empty room name and snapshots chat after the debounce window', async () => { + const { rm, clock } = manager() + expect(await rm.open({ name: ' ', password: '', strategy: 'torrent' })).toBe(false) + const spec = { name: 'lobby', password: '', strategy: 'torrent' as const } + await rm.open(spec) + await rm.sendChat('hi') + expect(loadRoomLog(spec)).toEqual([]) + clock.advance(CACHE_DEBOUNCE_MS) + expect(loadRoomLog(spec).some((line) => line.kind === 'chat' && line.text === 'hi')).toBe(true) + }) }) diff --git a/src/core/room-manager.ts b/src/core/room-manager.ts index 66acc71..43119f5 100644 --- a/src/core/room-manager.ts +++ b/src/core/room-manager.ts @@ -1,20 +1,23 @@ +import { CACHE_DEBOUNCE_MS } from '../config/app' import { loadRoomLog, saveRoomLog } from './cache' import { rememberRoom } from './recent' -import { sameRoom } from './room' +import { canonicalizeSpec, sameRoom } from './room' +import { browserRuntime } from './runtime' import { ChatSession, type SessionListener, type SessionOptions } from './session' import type { Identity, RoomSpec, SendResult } from './types' export class RoomManager { private identity: Identity - private listeners: SessionListener + private emit: SessionListener private options: SessionOptions private session: ChatSession | null = null private spec: RoomSpec | null = null private generation = 0 + private saveTimer: number | null = null constructor(identity: Identity, listeners: SessionListener, options: SessionOptions = {}) { this.identity = identity - this.listeners = listeners + this.emit = listeners this.options = options } @@ -54,7 +57,9 @@ export class RoomManager { } async open(spec: RoomSpec, force = false): Promise { - if (!force && this.spec && this.session?.isJoined() && sameRoom(this.spec, spec)) { + const clean = canonicalizeSpec(spec) + if (!clean) return false + if (!force && this.spec && this.session?.isJoined() && sameRoom(this.spec, clean)) { return true } @@ -62,29 +67,26 @@ export class RoomManager { await this.snapshotAndClose() if (token !== this.generation) return false - rememberRoom(spec) - this.listeners.onMembers?.([]) - const session = new ChatSession(this.identity, this.listeners, this.options) - const cached = loadRoomLog(spec) + rememberRoom(clean) + this.emit.onMembers?.([]) + const session = new ChatSession(this.identity, this.boundListeners(), this.options) + const cached = loadRoomLog(clean) if (cached.length > 0) session.hydrate(cached) - else this.listeners.onReset?.([]) + else this.emit.onReset?.([]) this.session = session - this.spec = spec + this.spec = clean try { - await session.join(spec) + await session.join(clean) } catch (error) { - if (token !== this.generation) { - await session.leave({ silent: true }) - return false - } + if (token !== this.generation) return false throw error } if (token !== this.generation) { await session.leave({ silent: true }) return false } - return true + return session.isJoined() } async close(): Promise { @@ -96,7 +98,36 @@ export class RoomManager { if (this.session && this.spec) saveRoomLog(this.spec, this.session.getLines()) } + private boundListeners(): SessionListener { + return { + onStatus: (status) => this.emit.onStatus?.(status), + onMembers: (members) => this.emit.onMembers?.(members), + onLine: (line) => { + this.emit.onLine?.(line) + this.scheduleSnapshot() + }, + onReset: (lines) => this.emit.onReset?.(lines), + } + } + + private scheduleSnapshot(): void { + if (this.saveTimer !== null || !this.spec) return + const runtime = this.options.runtime ?? browserRuntime + this.saveTimer = runtime.setTimeout(() => { + this.saveTimer = null + this.snapshot() + }, CACHE_DEBOUNCE_MS) + } + + private clearSaveTimer(): void { + if (this.saveTimer === null) return + const runtime = this.options.runtime ?? browserRuntime + runtime.clearTimeout(this.saveTimer) + this.saveTimer = null + } + private async snapshotAndClose(): Promise { + this.clearSaveTimer() if (this.session && this.spec) { saveRoomLog(this.spec, this.session.getLines()) await this.session.leave({ silent: true }) diff --git a/src/core/room.test.ts b/src/core/room.test.ts index 291df7c..3adf8be 100644 --- a/src/core/room.test.ts +++ b/src/core/room.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { sameRoom, sessionKey } from './room' +import { canonicalizeSpec, sameRoom, sessionKey } from './room' describe('room identity', () => { it('treats the same name, strategy and password as one room', () => { @@ -8,4 +8,13 @@ describe('room identity', () => { expect(sameRoom(a, b)).toBe(true) expect(sessionKey(a)).not.toBe(sessionKey({ ...a, password: '' })) }) + + it('canonicalizes names and rejects empty rooms', () => { + expect(canonicalizeSpec({ name: ' 茶 室 ', password: '', strategy: 'nostr' })).toEqual({ + name: '茶-室', + password: '', + strategy: 'nostr', + }) + expect(canonicalizeSpec({ name: ' ', password: '', strategy: 'torrent' })).toBeNull() + }) }) diff --git a/src/core/room.ts b/src/core/room.ts index 9c5d06a..0c88544 100644 --- a/src/core/room.ts +++ b/src/core/room.ts @@ -31,3 +31,9 @@ export function sessionKey(spec: RoomSpec): string { export function sameRoom(a: RoomSpec, b: RoomSpec): boolean { return sessionKey(a) === sessionKey(b) } + +export function canonicalizeSpec(spec: RoomSpec): RoomSpec | null { + const name = normalizeRoomName(spec.name) + if (!name) return null + return { name, password: spec.password, strategy: spec.strategy } +} diff --git a/src/core/router.test.ts b/src/core/router.test.ts index ec12a0a..c669ab3 100644 --- a/src/core/router.test.ts +++ b/src/core/router.test.ts @@ -27,4 +27,10 @@ describe('room routing', () => { expect(parseHash('')).toEqual({ name: 'lobby' }) expect(parseHash('#/nope')).toEqual({ name: 'lobby' }) }) + + it('writes a canonical name into the hash', () => { + expect(toHash({ name: 'room', spec: { name: ' 茶 室 ', password: '', strategy: 'torrent' } })).toBe( + `#/r/${encodeURIComponent('茶-室')}?s=torrent`, + ) + }) }) diff --git a/src/core/router.ts b/src/core/router.ts index ca24ec5..cd73172 100644 --- a/src/core/router.ts +++ b/src/core/router.ts @@ -1,5 +1,5 @@ import { DEFAULT_ROOM } from '../config/app' -import { isSignalStrategy, normalizeRoomName } from './room' +import { isSignalStrategy, normalizeRoomName, canonicalizeSpec } from './room' import type { RoomSpec, SignalStrategy } from './types' export type Route = @@ -35,13 +35,14 @@ export function parseHash(hash: string): Route { export function toHash(route: Route, includePassword = false): string { if (route.name === 'lobby') return '#/' + const spec = canonicalizeSpec(route.spec) ?? route.spec const params = new URLSearchParams() - params.set('s', route.spec.strategy) - if (includePassword && route.spec.password) { - params.set('k', route.spec.password) + params.set('s', spec.strategy) + if (includePassword && spec.password) { + params.set('k', spec.password) } const query = params.toString() - return `#/r/${encodeURIComponent(route.spec.name)}${query ? `?${query}` : ''}` + return `#/r/${encodeURIComponent(spec.name)}${query ? `?${query}` : ''}` } export function defaultRoomSpec(overrides: Partial = {}): RoomSpec { diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts index c253852..83364e4 100644 --- a/src/core/scheduler.ts +++ b/src/core/scheduler.ts @@ -1,9 +1,13 @@ -export function throttle(fn: (...args: T) => void, ms: number): (...args: T) => void { +export function throttle( + fn: (...args: T) => void, + ms: number, + now: () => number = Date.now, +): (...args: T) => void { let last = Number.NEGATIVE_INFINITY return (...args: T) => { - const now = Date.now() - if (now - last < ms) return - last = now + const at = now() + if (at - last < ms) return + last = at fn(...args) } } diff --git a/src/core/session.test.ts b/src/core/session.test.ts index f27c151..569b351 100644 --- a/src/core/session.test.ts +++ b/src/core/session.test.ts @@ -156,15 +156,43 @@ describe('ChatSession', () => { expect([...lines].reverse().find((line) => line.kind === 'chat' && line.self)).toMatchObject({ delivery: 'acked' }) }) - it('prunes a peer that stopped announcing', async () => { + it('prunes a ghost hello that is not in the transport peer set', 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('keeps a connected peer that went quiet', async () => { + const { session, clock, lines, transport } = harness() + await session.join(spec) + transport().peerJoin('peer-live') + transport().payload('peer-live', createHelloPayload('青石')) + clock.advance(80_000) + expect(lines.filter((line) => line.kind === 'system' && line.text.includes('离开'))).toHaveLength(0) + }) + + it('leaves the failed transport before a second join', async () => { + const clock = createMemoryRuntime() + const created: FakeTransport[] = [] + let fail = true + const session = new ChatSession(identity, {}, { + runtime: clock.runtime, + createTransport: () => { + const fake = new FakeTransport() + if (fail) fake.failJoin = new Error('tracker down') + created.push(fake) + return fake + }, + }) + await expect(session.join(spec)).rejects.toThrow('tracker down') + expect(created.at(-1)?.handlers).toBeNull() + fail = false + await session.join(spec) + expect(session.isJoined()).toBe(true) + }) + it('resends a failed line restored from cache', async () => { const { session, lines, transport } = harness() session.hydrate([ diff --git a/src/core/session.ts b/src/core/session.ts index 84c822b..333b448 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -2,7 +2,7 @@ import { ACK_TIMEOUT_MS, APP_ID, HELLO_INTERVAL_MS, PRESENCE_STALE_MS, RELAY_POL import { Heartbeat } from './heartbeat' import { randomHex } from './identity' import { Outbound } from './outbound' -import { decodeIncoming } from './payload-router' +import { applyIncoming } from './payload-router' import { Presence } from './presence' import { createAckPayload, @@ -47,6 +47,8 @@ export class ChatSession { private life: Life = idleLife() private strategy: SignalStrategy = 'torrent' private joinErrors = 0 + private epoch = 0 + private sendTypingThrottled: () => void private status: SessionStatus = { phase: 'idle', detail: STATUS_COPY.idle, @@ -58,11 +60,6 @@ export class ChatSession { this.listeners.onMembers?.(this.presence.list()) }) - private readonly sendTypingThrottled = throttle(() => { - if (!this.isJoined()) return - void this.transport.send(createTypingPayload(this.identity.nick)) - }, TYPING_THROTTLE_MS) - constructor(identity: Identity, listeners: SessionListener, options: SessionOptions = {}) { this.identity = identity this.listeners = listeners @@ -75,6 +72,10 @@ export class ChatSession { onHello: () => this.announce(), onPoll: () => this.poll(), }) + this.sendTypingThrottled = throttle(() => { + if (!this.isJoined()) return + void this.transport.send(createTypingPayload(this.identity.nick)) + }, TYPING_THROTTLE_MS, () => this.runtime.now()) } get selfId(): string { @@ -94,17 +95,18 @@ export class ChatSession { } async join(spec: RoomSpec): Promise { - if (this.isJoined()) await this.leave({ silent: true }) + await this.teardown(false) this.strategy = spec.strategy this.joinErrors = 0 this.transport = this.createTransport(spec.strategy) - this.life = reduceLife(this.life, { type: 'join' }) + this.life = reduceLife(idleLife(), { type: 'join' }) this.setStatus({ phase: 'connecting', detail: connectingDetail(spec.strategy), relays: [], peerCount: 0, }) + const token = this.epoch try { await this.transport.join( @@ -118,16 +120,23 @@ export class ChatSession { onPeerJoin: (peerId) => this.onPeerJoin(peerId), onPeerLeave: (peerId) => this.onPeerLeave(peerId), onPayload: (peerId, payload) => this.handlePayload(peerId, payload), - onJoinError: () => this.onJoinError(), + onJoinError: (detail) => this.onJoinError(detail), }, ) } catch (error) { + if (token !== this.epoch) return this.life = reduceLife(this.life, { type: 'join_err' }) + await this.teardown(false) const detail = error instanceof Error ? error.message : '连接失败' this.refreshStatus('error', startFailedDetail(detail)) throw error } + if (token !== this.epoch) { + await this.transport.leave() + return + } + this.life = reduceLife(this.life, { type: 'join_ok' }) this.heartbeat.start() this.announce() @@ -162,11 +171,7 @@ export class ChatSession { async leave(options: { silent?: boolean } = {}): Promise { this.life = reduceLife(this.life, { type: 'leave' }) - this.heartbeat.stop() - this.outbound.clear() - this.presence.clear() - await this.transport.leave() - this.transcript.clear() + await this.teardown(true) this.life = reduceLife(this.life, { type: 'left' }) if (!options.silent) { this.setStatus({ phase: 'idle', detail: STATUS_COPY.left, relays: [], peerCount: 0 }) @@ -175,7 +180,22 @@ export class ChatSession { } } + private async teardown(wipeLog: boolean): Promise { + this.epoch += 1 + this.heartbeat.stop() + this.outbound.dispose() + this.outbound = new Outbound(this.runtime, ACK_TIMEOUT_MS, (id) => this.expireAck(id)) + this.presence.clear() + await this.transport.leave() + if (wipeLog) this.transcript.clear() + } + + private live(token: number): boolean { + return token === this.epoch && this.isJoined() + } + private async dispatch(payload: ChatPayload, expectAck: boolean, replace = false): Promise { + const token = this.epoch if (replace) this.patchDelivery(payload.id, expectAck ? 'pending' : undefined) else { this.pushLine( @@ -193,74 +213,82 @@ export class ChatSession { try { await this.transport.send(payload) } catch { + if (!this.live(token)) return 'closed' this.patchDelivery(payload.id, 'failed') this.pushSystem(STATUS_COPY.sendFailed) return 'failed' } + if (!this.live(token)) return 'closed' if (expectAck) this.outbound.expect(payload.id, payload) return 'sent' } private handlePayload(peerId: string, raw: unknown): void { - 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 (incoming.type === 'hello') { - this.emitMembers() - return - } - if (incoming.type === 'typing') { - this.presence.markTyping(peerId) - this.emitMembers() - return - } - if (incoming.type === 'ack') { - if (this.outbound.ack(incoming.id)) this.patchDelivery(incoming.id, 'acked') - return - } - if (incoming.duplicate) { - void this.transport.send(createAckPayload(this.identity.nick, incoming.id), peerId) - return - } - this.pushLine( - chatLine({ - id: incoming.id, - fromId: peerId, - nick: incoming.nick, - text: incoming.text, - ts: incoming.ts, - self: false, - }), - ) - void this.transport.send(createAckPayload(this.identity.nick, incoming.id), peerId) - this.emitMembers() + if (!this.isJoined()) return + applyIncoming(peerId, raw, (id) => this.transcript.has(id), { + hello: (id, nick, features) => { + this.presence.upsert(id, nick, features) + this.emitMembers() + }, + typing: (id, nick) => { + this.presence.upsert(id, nick) + this.presence.markTyping(id) + this.emitMembers() + }, + ack: (id, messageId, nick) => { + this.presence.upsert(id, nick) + if (this.outbound.ack(messageId)) this.patchDelivery(messageId, 'acked') + }, + chat: (id, incoming) => { + this.presence.upsert(id, incoming.nick) + if (incoming.duplicate) { + void this.transport.send(createAckPayload(this.identity.nick, incoming.id), id) + return + } + this.pushLine( + chatLine({ + id: incoming.id, + fromId: id, + nick: incoming.nick, + text: incoming.text, + ts: incoming.ts, + self: false, + }), + ) + void this.transport.send(createAckPayload(this.identity.nick, incoming.id), id) + this.emitMembers() + }, + }) } private onPeerJoin(peerId: string): void { + if (!this.isJoined()) return + const token = this.epoch 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) + this.runtime.setTimeout(() => { + if (this.live(token)) void this.measure(peerId) + }, 350) } private onPeerLeave(peerId: string): void { + if (!this.isJoined()) return const member = this.presence.remove(peerId) this.pushSystem(`${member?.nick ?? peerId.slice(0, 6)} 离开了房间`) this.emitMembers() this.notePeers() } - private onJoinError(): void { + private onJoinError(detail: string): 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) + } else this.refreshStatus('connecting', detail ? `${STATUS_COPY.handshake}(${detail})` : STATUS_COPY.handshake) } private expireAck(id: string): void { @@ -290,7 +318,7 @@ export class ChatSession { private poll(): void { if (!this.isJoined()) return - const gone = this.presence.prune(PRESENCE_STALE_MS) + const gone = this.presence.prune(PRESENCE_STALE_MS, this.transport.peerIds()) for (const member of gone) this.pushSystem(`${member.nick} 离开了房间`) if (gone.length > 0) this.emitMembers() this.notePeers() diff --git a/src/core/transcript.ts b/src/core/transcript.ts index 7661cfa..3bada6f 100644 --- a/src/core/transcript.ts +++ b/src/core/transcript.ts @@ -1,19 +1,7 @@ import { MAX_SEEN_IDS } from '../config/app' -import { trimLines } from './cache' +import { durableLine, trimLines } from './cache' import type { ChatLine, Delivery } from './types' -function dropPending(line: ChatLine): ChatLine { - if (line.kind !== 'chat' || line.delivery !== 'pending') return line - return chatLine({ - id: line.id, - fromId: line.fromId, - nick: line.nick, - text: line.text, - ts: line.ts, - self: line.self, - }) -} - export class Transcript { private lines: ChatLine[] = [] private seen = new Set() @@ -21,7 +9,7 @@ export class Transcript { hydrate(lines: ChatLine[]): ChatLine[] { this.clear() - this.lines = trimLines(lines.map(dropPending)) + this.lines = trimLines(lines.map(durableLine)) for (const line of this.lines) { if (line.kind === 'chat') this.remember(line.id) } diff --git a/src/core/transports/trystero.ts b/src/core/transports/trystero.ts index 8e9867f..0fd1832 100644 --- a/src/core/transports/trystero.ts +++ b/src/core/transports/trystero.ts @@ -77,6 +77,7 @@ export class TrysteroTransport implements SignallingTransport { async leave(): Promise { const room = this.room + this.module = null this.room = null this.action = null if (room) { diff --git a/src/ui/app.ts b/src/ui/app.ts index 8657248..34543e4 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -2,6 +2,7 @@ import { SOURCE_URL } from '../config/app' import { loadIdentity, persistNick } from '../core/identity' import { RoomManager } from '../core/room-manager' import { parseHash, toHash } from '../core/router' +import { canonicalizeSpec } from '../core/room' import { rememberSecret, withSecret } from '../core/secrets' import { watchDuplicateTab } from '../core/tab-guard' import type { RoomSpec, ThemePreference } from '../core/types' @@ -28,7 +29,6 @@ export class App { private dupBanner: HTMLElement private chat: ChatPane | null = null private lobbyEl: HTMLElement | null = null - private routing = false private stopTabWatch: (() => void) | null = null constructor(root: HTMLElement) { @@ -58,7 +58,7 @@ export class App { this.stopTabWatch = watchDuplicateTab(() => this.dupBanner.classList.remove('hidden')) window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => applyTheme(this.theme)) window.addEventListener('hashchange', () => { - if (!this.routing) void this.route() + void this.route() }) window.addEventListener('pagehide', () => { this.stopTabWatch?.() @@ -120,24 +120,21 @@ export class App { rememberSecret(spec) const clean = toHash({ name: 'room', spec }, false) if (spec.password && location.hash !== clean) { - this.routing = true history.replaceState(null, '', `${location.pathname}${location.search}${clean}`) - this.routing = false } return spec } private go(spec: RoomSpec): void { - rememberSecret(spec) - const next = toHash({ name: 'room', spec }, false) + const clean = canonicalizeSpec(spec) + if (!clean) return + rememberSecret(clean) + const next = toHash({ name: 'room', spec: clean }, false) if (location.hash === next) { - void this.showChat(spec) + void this.showChat(clean) return } - this.routing = true location.hash = next - this.routing = false - void this.showChat(spec) } private showLobby(): void { @@ -192,14 +189,8 @@ export class App { this.refreshChrome(spec) try { await this.manager.open(spec) - } catch (error) { - const detail = error instanceof Error ? error.message : '连接失败' - this.chat.status({ - phase: 'error', - detail: `无法启动 P2P:${detail}`, - relays: [], - peerCount: 0, - }) + } catch { + // ChatSession already emitted the error status, including live relays. } } diff --git a/src/ui/chat-pane.ts b/src/ui/chat-pane.ts index 92b5c02..c1c8406 100644 --- a/src/ui/chat-pane.ts +++ b/src/ui/chat-pane.ts @@ -1,3 +1,4 @@ +import { MAX_MESSAGE_LENGTH, MAX_NICK_LENGTH } from '../config/app' import { colorFromId } from '../core/identity' import type { ChatLine, Member, SendResult, SessionStatus } from '../core/types' import { copy } from './copy' @@ -48,7 +49,7 @@ export class ChatPane { const composer = el('textarea', { rows: 1, placeholder: copy.placeholder, - maxlength: 4000, + maxlength: MAX_MESSAGE_LENGTH, enterkeyhint: 'send', 'aria-label': copy.placeholder, }) as HTMLTextAreaElement @@ -69,6 +70,7 @@ export class ChatPane { picker.el.addEventListener('click', (event) => event.stopPropagation()) const send = () => { + this.handlers.onNick(this.nickInput.value) const text = composer.value if (!text.trim()) return composer.value = '' @@ -95,12 +97,13 @@ export class ChatPane { this.nickInput = el('input', { class: 'nick-edit', - maxlength: 24, + maxlength: MAX_NICK_LENGTH, value: handlers.nick, 'aria-label': copy.nick, }) as HTMLInputElement const commitNick = () => this.handlers.onNick(this.nickInput.value) this.nickInput.addEventListener('change', commitNick) + this.nickInput.addEventListener('blur', commitNick) this.nickInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault() @@ -179,7 +182,7 @@ export class ChatPane { this.membersEl.append( el('div', { class: 'member' }, [ el('b', { style: `color:${colorFromId(member.id)}` }, [member.nick]), - el('span', { class: 'muted' }, [member.typing ? '输入中' : member.rttMs !== null ? `${Math.round(member.rttMs)}ms` : '']), + el('span', { class: 'muted' }, [member.typing ? copy.typing : member.rttMs !== null ? `${Math.round(member.rttMs)}ms` : '']), ]), ) } diff --git a/src/ui/copy.ts b/src/ui/copy.ts index 66eae9e..04fb5c4 100644 --- a/src/ui/copy.ts +++ b/src/ui/copy.ts @@ -24,7 +24,7 @@ export const copy = { placeholder: '发消息', members: '在线', you: '你', - waiting: '还没有同伴,把链接发给对方。', + waiting: '还没有同伴,把链接发给对方', copied: '已复制', shared: '已分享', copyFailed: '无法复制,请手动复制地址栏。', @@ -47,4 +47,5 @@ export const copy = { sending: '发送中', undelivered: '未送达', resend: '点此重试', + typing: '输入中', } diff --git a/src/ui/jump-dialog.ts b/src/ui/jump-dialog.ts index 1b54c7a..df1df23 100644 --- a/src/ui/jump-dialog.ts +++ b/src/ui/jump-dialog.ts @@ -1,4 +1,7 @@ +import { MAX_ROOM_LENGTH } from '../config/app' +import { canonicalizeSpec } from '../core/room' import type { RoomSpec, SignalStrategy } from '../core/types' +import { canJoin } from './capability' import { copy } from './copy' import { el } from './dom' @@ -9,31 +12,41 @@ export type JumpDialog = { } export function createJumpDialog(onJoin: (spec: RoomSpec) => void): JumpDialog { - const room = el('input', { placeholder: copy.room, maxlength: 64, autocomplete: 'off' }) as HTMLInputElement + const room = el('input', { placeholder: copy.room, maxlength: MAX_ROOM_LENGTH, autocomplete: 'off' }) as HTMLInputElement const password = el('input', { placeholder: copy.password, type: 'password', autocomplete: 'off' }) as HTMLInputElement const strategy = el('select', {}, [ el('option', { value: 'torrent' }, [copy.torrent]), el('option', { value: 'nostr' }, [copy.nostr]), ]) as HTMLSelectElement + const hint = el('p', { class: 'hint hidden' }, [copy.needPassword]) + const joinBtn = el('button', { class: 'btn primary', type: 'submit' }, [copy.join]) as HTMLButtonElement + if (!canJoin()) joinBtn.disabled = true const form = el('form', { class: 'jump-form', method: 'dialog' }, [ el('h2', {}, [copy.switch]), + hint, room, password, strategy, el('div', { class: 'actions' }, [ - el('button', { class: 'btn primary', type: 'submit' }, [copy.join]), + joinBtn, el('button', { class: 'btn ghost', type: 'button', value: 'cancel' }, [copy.cancel]), ]), ]) const dialog = el('dialog', { class: 'jump' }, [form]) as HTMLDialogElement form.addEventListener('submit', (event) => { event.preventDefault() - dialog.close() - onJoin({ + if (!canJoin()) return + const spec = canonicalizeSpec({ name: room.value, password: password.value, strategy: strategy.value as SignalStrategy, }) + if (!spec) { + room.focus() + return + } + dialog.close() + onJoin(spec) }) form.querySelector('button[value="cancel"]')?.addEventListener('click', () => dialog.close()) return { @@ -42,6 +55,7 @@ export function createJumpDialog(onJoin: (spec: RoomSpec) => void): JumpDialog { room.value = prefill?.name ?? '' password.value = prefill?.password ?? '' strategy.value = prefill?.strategy ?? 'torrent' + hint.classList.toggle('hidden', !prefill?.needPassword) dialog.showModal() if (prefill?.needPassword) password.focus() else room.focus() diff --git a/src/ui/lobby.ts b/src/ui/lobby.ts index 71b7cf2..5442271 100644 --- a/src/ui/lobby.ts +++ b/src/ui/lobby.ts @@ -1,4 +1,5 @@ -import { DEFAULT_ROOM } from '../config/app' +import { DEFAULT_ROOM, MAX_NICK_LENGTH, MAX_ROOM_LENGTH } from '../config/app' +import { canonicalizeSpec } from '../core/room' import type { RoomSpec, SignalStrategy } from '../core/types' import { canJoin } from './capability' import { copy } from './copy' @@ -8,8 +9,8 @@ export function buildLobby(options: { nick: string onJoin: (input: { nick: string; spec: RoomSpec }) => void }): HTMLElement { - const nick = el('input', { maxlength: 24, value: options.nick, autocomplete: 'nickname' }) as HTMLInputElement - const room = el('input', { maxlength: 64, value: DEFAULT_ROOM, autocomplete: 'off' }) as HTMLInputElement + const nick = el('input', { maxlength: MAX_NICK_LENGTH, value: options.nick, autocomplete: 'nickname' }) as HTMLInputElement + const room = el('input', { maxlength: MAX_ROOM_LENGTH, value: DEFAULT_ROOM, autocomplete: 'off' }) as HTMLInputElement const password = el('input', { type: 'password', autocomplete: 'off' }) as HTMLInputElement const strategy = el('select', {}, [ el('option', { value: 'torrent' }, [copy.torrent]), @@ -28,14 +29,16 @@ export function buildLobby(options: { form.addEventListener('submit', (event) => { event.preventDefault() if (!canJoin()) return - options.onJoin({ - nick: nick.value, - spec: { - name: room.value, - password: password.value, - strategy: strategy.value as SignalStrategy, - }, + const spec = canonicalizeSpec({ + name: room.value, + password: password.value, + strategy: strategy.value as SignalStrategy, }) + if (!spec) { + room.focus() + return + } + options.onJoin({ nick: nick.value, spec }) }) return el('div', { class: 'lobby' }, [ el('p', { class: 'lede' }, [copy.lobbyHint]), diff --git a/src/ui/room-chrome.ts b/src/ui/room-chrome.ts index 7e9877b..fb948b3 100644 --- a/src/ui/room-chrome.ts +++ b/src/ui/room-chrome.ts @@ -1,5 +1,5 @@ import { loadRecentRooms, specFromRecent, type RecentRoom } from '../core/recent' -import { normalizeRoomName } from '../core/room' +import { recentRoomKey } from '../core/room' import { toHash } from '../core/router' import { roomUrl, shareOrCopy } from '../core/share' import type { RoomSpec } from '../core/types' @@ -17,7 +17,7 @@ export function renderTabs( ): void { empty(nav) for (const item of loadRecentRooms()) { - const current = Boolean(active && item.name === normalizeRoomName(active.name) && item.strategy === active.strategy) + const current = Boolean(active && recentRoomKey(item) === recentRoomKey(active)) const label = item.hasPassword ? [item.name, el('span', { class: 'lock', title: copy.locked }, ['锁'])] : [item.name]