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]