diff --git a/404.html b/404.html
index 74d916a..50af4e6 100644
--- a/404.html
+++ b/404.html
@@ -19,8 +19,8 @@
禅聊 ZenChat
-
-
+
+
diff --git a/README.md b/README.md
index 530e3d7..84e0547 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@ flowchart LR
UI[UI Shell]
RM[RoomManager]
CS[ChatSession]
+ SM[SessionMachine]
+ OB[Outbound]
P[Presence]
L[Transcript]
TR[SignallingTransport]
@@ -32,6 +34,8 @@ flowchart LR
end
UI --> RM --> CS
+ CS --> SM[SessionMachine]
+ CS --> OB[Outbound]
CS --> P[Presence]
CS --> L[Transcript]
CS --> TR
@@ -46,7 +50,9 @@ flowchart LR
|---|---|
| UI Shell | Lobby, tabs, theme. Chat pane is a separate view; shell does not remount on switch. |
| RoomManager | Room lifecycle, log cache, skip reconnect if the same room is already joined. |
-| ChatSession | Orchestrates hello / chat / typing over an injected transport and runtime. |
+| ChatSession | Thin orchestrator: transport in, machine + outbound + presence + transcript out. |
+| SessionMachine | Pure join/wait/live/error transitions. |
+| Outbound | Delivery timers. Peers that advertise `ack` in hello get a receipt. |
| Presence / Transcript | Members + typing TTL; capped message log and dedupe ids. |
| 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-BhwSy4pj.js b/assets/index-BhwSy4pj.js
deleted file mode 100644
index 920ba16..0000000
--- a/assets/index-BhwSy4pj.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 mt="zenchat.andyccr.v1",y=1,pt=24,ft=64,Y=4e3,X=10,E=200,yt=E*2,gt="lobby",bt=2500,wt=1800,vt=25e3,Et=5e3,kt="https://github.com/Andyccr/ZenChat",C="zenchat.nick",M="zenchat.peerId",O=["闲云","青石","疏影","晚风","折柳","听雨","归舟","松风"];function I(e){const t=new Uint8Array(e);return crypto.getRandomValues(t),[...t].map(s=>s.toString(16).padStart(2,"0")).join("")}const _=["#c15f3c","#8b5e3c","#5c6b4a","#6b4f3a","#9a6b3f","#4a6b62","#7a4e3a","#5a5568"];function Z(e){let t=2166136261;for(let s=0;s{const i=n.codePointAt(0)??0;return i>=32&&i!==127}).join("").slice(0,pt)}function Q(e){const t=Number.parseInt(e.slice(0,4),16)%O.length;return O[t]??"闲云"}function tt(){const e=sessionStorage.getItem(M),t=e&&/^[0-9a-f]{16,}$/i.test(e)?e:I(12);sessionStorage.setItem(M,t);const s=localStorage.getItem(C),n=b(s??"")||Q(t);return localStorage.setItem(C,n),{id:t,nick:n}}function $(e){const t=b(e)||Q(tt().id);return localStorage.setItem(C,t),t}function St(e){return e==="torrent"||e==="nostr"}function f(e){return e.normalize("NFKC").trim().replace(/\s+/g,"-").replace(/[/#?&]+/g,"").slice(0,ft)}function Tt(e){const t=f(e.name);return e.password?`${t}::${e.password}`:t}function P(e){return`${e.strategy}:${f(e.name)}`}function R(e){return`${e.strategy}:${f(e.name)}:${e.password}`}function Lt(e,t){return R(e)===R(t)}const Ct="zenchat.pass.v1.";function et(e){return Ct+P(e)}function j(e){try{const t=et(e);e.password?sessionStorage.setItem(t,e.password):sessionStorage.removeItem(t)}catch{}}function st(e){try{return sessionStorage.getItem(et(e))??""}catch{return""}}function x(e){if(e.password)return j(e),e;const t=st(e);return t?{...e,password:t}:e}const nt="zenchat.recentRooms";function it(){try{const e=localStorage.getItem(nt);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,X):[]}catch{return[]}}function It(e){const t={name:e.name,strategy:e.strategy,hasPassword:!!e.password,visitedAt:Date.now()},s=it().filter(i=>P(i)!==P(e)),n=[t,...s].slice(0,X);return localStorage.setItem(nt,JSON.stringify(n)),n}function Pt(e,t){const s=st(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 Rt="zenchat.cache.v1.";function rt(e){return Rt+R(e)}function jt(e){try{const t=sessionStorage.getItem(rt(e));if(!t)return[];const s=JSON.parse(t);return Array.isArray(s)?s.filter(Nt).slice(-E):[]}catch{return[]}}function B(e,t){try{sessionStorage.setItem(rt(e),JSON.stringify(t.slice(-E)))}catch{}}function D(e){return e.length>E?e.slice(-E):e}function Nt(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}class At{constructor(t,s=()=>{},n=bt){this.runtime=t,this.onTypingEnd=s,this.typingTtl=n}runtime;onTypingEnd;typingTtl;members=new Map;typingTimers=new Map;upsert(t,s){const n=this.members.get(t),i=this.runtime.now(),o={id:t,nick:s,joinedAt:n?.joinedAt??i,lastSeenAt:i,rttMs:n?.rttMs??null,typing:n?.typing??!1};return this.members.set(t,o),o}remove(t){this.clearTypingTimer(t);const s=this.members.get(t);return this.members.delete(t),s}markTyping(t){const s=this.members.get(t);return s?(s.typing=!0,this.clearTypingTimer(t),this.typingTimers.set(t,this.runtime.setTimeout(()=>{const n=this.members.get(t);n&&(n.typing=!1,this.onTypingEnd(t))},this.typingTtl)),!0):!1}setRtt(t,s){const n=this.members.get(t);return n?(n.rttMs=s,!0):!1}list(){return[...this.members.values()].sort((t,s)=>t.joinedAt-s.joinedAt)}clear(){for(const t of this.typingTimers.keys())this.clearTypingTimer(t);this.members.clear()}clearTypingTimer(t){const s=this.typingTimers.get(t);s!==void 0&&this.runtime.clearTimeout(s),this.typingTimers.delete(t)}}function Mt(e){return typeof e=="object"&&e!==null}function Ot(e){return e===y}function _t(e,t,s,n){return{v:y,type:"chat",id:s,ts:n,nick:b(e),text:t.trim().slice(0,Y)}}function v(e){return{v:y,type:"hello",nick:b(e)}}function $t(e){return{v:y,type:"typing",nick:b(e)}}function xt(e){if(!Mt(e)||!Ot(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:y,type:"hello",nick:t};if(e.type==="typing")return{v:y,type:"typing",nick:t};if(e.type==="chat"){if(typeof e.id!="string"||e.id.length<8||e.id.length>80||typeof e.ts!="number"||!Number.isFinite(e.ts)||typeof e.text!="string")return null;const s=e.text.trim().slice(0,Y);return s?{v:y,type:"chat",id:e.id,ts:e.ts,nick:t,text:s}:null}return null}const Bt={now:()=>Date.now(),hidden:()=>document.hidden,setTimeout:(e,t)=>window.setTimeout(e,t),clearTimeout:e=>window.clearTimeout(e),setInterval:(e,t)=>window.setInterval(e,t),clearInterval:e=>window.clearInterval(e),onVisibilityChange(e){return document.addEventListener("visibilitychange",e),()=>document.removeEventListener("visibilitychange",e)}};function Dt(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)}))}}const d={idle:"尚未连接",left:"已离开房间",connectingTorrent:"正在连接 Tracker…",connectingNostr:"正在连接 Nostr…",waiting:"还没有同伴,把链接发给对方",handshake:"握手受阻,仍在重试",relaysDownTorrent:"Tracker 连不上,可改用 Nostr",relaysDownNostr:"Nostr 中继连不上,可改用 Tracker",sendFailed:"刚才那条没发出去,请再试一次"};function Jt(e){return e==="nostr"?d.connectingNostr:d.connectingTorrent}function Kt(e){return`已直连 ${e} 人`}function Vt(e){return`无法启动 P2P:${e}`}function Ft(e){return e.filter(t=>t.readyState===1).length}function zt(e){return e.length>0&&Ft(e)===0}function H(e,t){return zt(t)?{phase:"error",detail:e==="nostr"?d.relaysDownNostr:d.relaysDownTorrent}:{phase:"connecting",detail:d.waiting}}class Ut{lines=[];seen=new Set;seenOrder=[];hydrate(t){this.clear(),this.lines=D(t);for(const s of this.lines)s.kind==="chat"&&this.remember(s.id);return this.lines}snapshot(){return this.lines}has(t){return this.seen.has(t)}remember(t){if(!this.seen.has(t))for(this.seen.add(t),this.seenOrder.push(t);this.seenOrder.length>yt;){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=D(this.lines),t}clear(){this.lines=[],this.seen.clear(),this.seenOrder=[]}}function J(e){return{kind:"chat",...e}}function Wt(e,t,s){return{kind:"system",id:e,text:t,ts:s}}const Gt="modulepreload",qt=function(e){return"/ZenChat/"+e},K={},V=function(t,s,n){let i=Promise.resolve();if(s&&s.length>0){let m=function(u){return Promise.all(u.map(g=>Promise.resolve(g).then(w=>({status:"fulfilled",value:w}),w=>({status:"rejected",reason:w}))))};var c=m;document.getElementsByTagName("link");const h=document.querySelector("meta[property=csp-nonce]"),l=h?.nonce||h?.getAttribute("nonce");i=m(s.map(u=>{if(u=qt(u),u in K)return;K[u]=!0;const g=u.endsWith(".css"),w=g?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${u}"]${w}`))return;const p=document.createElement("link");if(p.rel=g?"stylesheet":Gt,g||(p.as="script"),p.crossOrigin="",p.href=u,l&&p.setAttribute("nonce",l),document.head.appendChild(p),g)return new Promise((ut,dt)=>{p.addEventListener("load",ut),p.addEventListener("error",()=>dt(new Error(`Unable to preload CSS for ${u}`)))})}))}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)})},Yt=[{urls:"stun:stun.cloudflare.com:3478"},{urls:"stun:stun.l.google.com:19302"}],Xt={iceServers:Yt,iceCandidatePoolSize:8,bundlePolicy:"max-bundle",rtcpMuxPolicy:"require"},Zt=["wss://tracker.webtorrent.dev","wss://tracker.openwebtorrent.com","wss://open.ftorrent.com:443"],Qt=["wss://relay.damus.io","wss://nos.lol","wss://relay.snort.social","wss://nostr.wine"];function te(e){return Object.entries(e).map(([t,s])=>({url:t,readyState:s?.readyState??WebSocket.CLOSED}))}class ee{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 N(t.strategy);const n=t.strategy==="torrent"?[...Zt]:[...Qt],i=this.module.joinRoom({appId:t.appId,...t.password?{password:t.password}:{},rtcConfig:Xt,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?te(this.module.getRelaySockets()):[]}}const F=new Map;async function N(e){const t=F.get(e);if(t)return t;const s=e==="torrent"?V(()=>import("./index-BgLl3BAq.js"),__vite__mapDeps([0,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId})):V(()=>import("./index-DSJFV8_4.js"),__vite__mapDeps([2,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId}));return F.set(e,s),s}function se(){N("torrent"),N("nostr")}function ne(e){return new ee(e)}class ie{transport;identity;listeners;createTransport;runtime;transcript=new Ut;presence;helloTimer=null;relayTimer=null;unbindVisibility=null;joined=!1;strategy="torrent";joinErrors=0;status={phase:"idle",detail:d.idle,relays:[],peerCount:0};emitMembers=Ht(()=>{this.listeners.onMembers?.(this.presence.list())});sendTypingThrottled=Dt(()=>{this.joined&&this.transport.send($t(this.identity.nick))},wt);constructor(t,s,n={}){this.identity=t,this.listeners=s,this.createTransport=n.createTransport??ne,this.runtime=n.runtime??Bt,this.transport=this.createTransport("torrent"),this.presence=new At(this.runtime,()=>this.emitMembers())}get selfId(){return this.transport.selfId()||this.identity.id}isJoined(){return this.joined}getLines(){return this.transcript.snapshot()}hydrate(t){this.listeners.onReset?.(this.transcript.hydrate(t))}async join(t){this.joined&&await this.leave({silent:!0}),this.strategy=t.strategy,this.joinErrors=0,this.transport=this.createTransport(t.strategy),this.joined=!0,this.setStatus({phase:"connecting",detail:Jt(t.strategy),relays:[],peerCount:0});try{await this.transport.join({appId:mt,roomId:Tt({name:f(t.name),password:t.password}),password:t.password,strategy:t.strategy},{onPeerJoin:s=>{this.presence.upsert(s,"访客"),this.pushSystem(`${s.slice(0,6)} 加入了房间`),this.transport.send(v(this.identity.nick),s),this.emitMembers(),this.syncStatus(),this.runtime.setTimeout(()=>{this.measure(s)},350)},onPeerLeave:s=>{const n=this.presence.remove(s);this.pushSystem(`${n?.nick??s.slice(0,6)} 离开了房间`),this.emitMembers(),this.syncStatus()},onPayload:(s,n)=>this.handlePayload(s,n),onJoinError:()=>{this.joinErrors+=1,!(this.transport.peerIds().length>0)&&(this.joinErrors>=3?this.syncStatus(!0):this.refreshStatus("connecting",d.handshake))}})}catch(s){this.joined=!1;const n=s instanceof Error?s.message:"连接失败";throw this.refreshStatus("error",Vt(n)),s}this.bindVisibility(),this.startTimers(),this.transport.send(v(this.identity.nick)),this.syncStatus()}async sendChat(t){if(!this.joined)return"closed";const s=_t(this.identity.nick,t,I(8),this.runtime.now());if(!s.text)return"empty";this.pushLine(J({id:s.id,fromId:this.selfId,nick:this.identity.nick,text:s.text,ts:s.ts,self:!0}));try{return await this.transport.send(s),"sent"}catch{return this.pushSystem(d.sendFailed),"failed"}}sendTyping(){this.sendTypingThrottled()}setNick(t){this.identity={...this.identity,nick:t},this.joined&&this.transport.send(v(t))}async leave(t={}){this.joined=!1,this.unbindVisibility?.(),this.unbindVisibility=null,this.stopTimers(),this.presence.clear(),await this.transport.leave(),this.transcript.clear(),t.silent||(this.setStatus({phase:"idle",detail:d.left,relays:[],peerCount:0}),this.emitMembers(),this.listeners.onReset?.([]))}handlePayload(t,s){const n=xt(s);if(n){if(this.presence.upsert(t,n.nick),n.type==="hello"){this.emitMembers();return}if(n.type==="typing"){this.presence.markTyping(t),this.emitMembers();return}this.transcript.has(n.id)||(this.pushLine(J({id:n.id,fromId:t,nick:n.nick,text:n.text,ts:n.ts,self:!1})),this.emitMembers())}}async measure(t){if(this.joined)try{const s=await this.transport.ping(t);this.joined&&this.presence.setRtt(t,s)&&this.emitMembers()}catch{}}pushSystem(t){this.pushLine(Wt(I(6),t,this.runtime.now()))}pushLine(t){const s=this.transcript.append(t);s&&this.listeners.onLine?.(s)}syncStatus(t=!1){const s=this.transport.peerIds().length,n=this.transport.relays();if(s>0){this.setStatus({phase:"connected",detail:Kt(s),relays:n,peerCount:s});return}const i=t?H(this.strategy,n.length?n:[{url:"local",readyState:3}]):H(this.strategy,n);this.setStatus({...i,relays:n,peerCount:0})}refreshStatus(t,s){this.setStatus({phase:t,detail:s,relays:this.transport.relays(),peerCount:this.transport.peerIds().length})}setStatus(t){const s=this.status;s.phase===t.phase&&s.detail===t.detail&&s.peerCount===t.peerCount&&s.relays.length===t.relays.length&&s.relays.every((n,i)=>n.readyState===t.relays[i]?.readyState)||(this.status=t,this.listeners.onStatus?.(t))}onVisibility=()=>{if(this.runtime.hidden()){this.stopTimers();return}this.joined&&(this.startTimers(),this.transport.send(v(this.identity.nick)),this.syncStatus())};bindVisibility(){this.unbindVisibility?.(),this.unbindVisibility=this.runtime.onVisibilityChange(this.onVisibility)}startTimers(){this.stopTimers(),this.helloTimer=this.runtime.setInterval(()=>{this.transport.send(v(this.identity.nick))},vt),this.relayTimer=this.runtime.setInterval(()=>{this.syncStatus();for(const t of this.transport.peerIds())this.measure(t)},Et)}stopTimers(){this.helloTimer!==null&&(this.runtime.clearInterval(this.helloTimer),this.helloTimer=null),this.relayTimer!==null&&(this.runtime.clearInterval(this.relayTimer),this.relayTimer=null)}}class re{identity;listeners;options;session=null;spec=null;generation=0;constructor(t,s,n={}){this.identity=t,this.listeners=s,this.options=n}current(){return this.spec}getSession(){return this.session}setIdentity(t){this.identity=t,this.session?.setNick(t.nick)}async open(t,s=!1){if(!s&&this.spec&&this.session?.isJoined()&&Lt(this.spec,t))return this.session;const n=++this.generation;if(await this.snapshotAndClose(),n!==this.generation)return null;It(t),this.listeners.onMembers?.([]);const i=new ie(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}),null;throw c}return n!==this.generation?(await i.leave({silent:!0}),null):i}async close(){this.generation+=1,await this.snapshotAndClose()}snapshot(){this.session&&this.spec&&B(this.spec,this.session.getLines())}async snapshotAndClose(){this.session&&this.spec&&(B(this.spec,this.session.getLines()),await this.session.leave({silent:!0})),this.session=null,this.spec=null}}function z(e,t){return e.get(t)??""}function oe(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=z(s.searchParams,"s"),c=St(o)?o:"torrent";return{name:"room",spec:{name:i,password:z(s.searchParams,"k"),strategy:c}}}return{name:"lobby"}}function S(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 ae(e,t,s,n=!1){return`${e}${t}${S({name:"room",spec:s},n)}`}async function ce(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 le(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:"主题"};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 A(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function he(e){return new Intl.DateTimeFormat("zh-CN",{hour:"2-digit",minute:"2-digit",hour12:!1}).format(e)}function ue(e){switch(e){case WebSocket.CONNECTING:return"连接中";case WebSocket.OPEN:return"已连接";case WebSocket.CLOSING:return"断开中";default:return"未连接"}}function de(e){try{return new URL(e).host}catch{return e}}const ot=["(´・ω・`)","(。•́︿•̀。)","(´▽`ʃ♡ƪ)","(╯°□°)╯︵ ┻━┻","¯\\_(ツ)_/¯","(づ。◕‿‿◕。)づ","(ง'̀-'́)ง","( ̄▽ ̄)","(•̀ᴗ•́)و","ฅ^•ﻌ•^ฅ","(´;ω;`)","(≧∇≦)ノ","(´∀`)♡","┐( ̄ヘ ̄)┌"],at=["😀","😂","🥰","😎","🤔","😭","🙏","✨","🔥","🎉","👍","👋","❤️","🌸","🍵","🌙"],me=new Set(ot),pe=new Set(at);function fe(e){const t=e.trim();return t?me.has(t)||pe.has(t)?!0:t.length<=8&&/^[\p{Emoji_Presentation}\s]+$/u.test(t):!1}function U(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 ye{el;ids=new Set;constructor(){this.el=r("div",{class:"log",role:"log","aria-live":"polite"})}get size(){return this.ids.size}reset(t){this.el.replaceChildren(),this.ids.clear();for(const s of t)this.append(s,!1);this.stick()}append(t,s=!0){this.ids.has(t.id)||(this.ids.add(t.id),this.el.append(ge(t)),s&&this.stick())}stick(){const t=this.el;requestAnimationFrame(()=>{t.scrollHeight-t.scrollTop-t.clientHeight<96&&(t.scrollTop=t.scrollHeight)})}}function ge(e){if(e.kind==="system")return r("div",{class:"system"},[e.text]);const t=fe(e.text);return r("article",{class:`bubble${e.self?" self":""}${t?" stamp":""}`},[r("header",{},[r("b",{style:`color:${Z(e.fromId)}`},[e.nick]),r("time",{},[he(e.ts)])]),r("p",{},[e.text])])}class be{el;open=!1;constructor(t){const s=r("div",{class:"picker-grid kaomoji"},ot.map(c=>W(c,()=>U(t,c)))),n=r("div",{class:"picker-grid emoji hidden"},at.map(c=>W(c,()=>U(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 W(e,t){const s=r("button",{class:"stamp-btn",type:"button"},[e]);return s.addEventListener("click",t),s}class we{constructor(t){this.handlers=t,this.statusEl=r("div",{class:"status"}),this.actionsEl=r("div",{class:"status-actions"}),this.membersEl=r("div",{class:"members"}),this.vacantEl=r("div",{class:"vacant"},[a.waiting]),this.peopleBtn=r("button",{class:"btn ghost people",type:"button","aria-expanded":"false"},[a.people]),this.peopleBtn.addEventListener("click",l=>{l.stopPropagation(),this.root.classList.toggle("show-side"),this.peopleBtn.setAttribute("aria-expanded",this.root.classList.contains("show-side")?"true":"false")});const s=r("textarea",{rows:1,placeholder:a.placeholder,maxlength:4e3,enterkeyhint:"send","aria-label":a.placeholder});s.setAttribute("enterkeyhint","send"),s.setAttribute("autocapitalize","sentences");const n=new 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",l=>{l.stopPropagation(),n.toggle()}),this.peopleBtn.addEventListener("pointerdown",l=>l.stopPropagation()),document.addEventListener("pointerdown",l=>{this.root.contains(l.target)&&(l.target.closest(".picker, .people, .side")||n.hide())}),n.el.addEventListener("click",l=>l.stopPropagation());const c=()=>{const l=s.value;l.trim()&&(s.value="",s.style.height="auto",n.hide(),this.handlers.send(l).then(m=>{(m==="failed"||m==="closed")&&(s.value=l)}))};o.addEventListener("click",c),s.addEventListener("keydown",l=>{l.key==="Enter"&&!l.shiftKey&&(l.preventDefault(),c())}),s.addEventListener("input",()=>{s.style.height="auto",s.style.height=`${Math.min(s.scrollHeight,96)}px`,this.handlers.typing()}),this.nickInput=r("input",{class:"nick-edit",maxlength:24,value:t.nick,"aria-label":a.nick});const h=()=>this.handlers.onNick(this.nickInput.value);this.nickInput.addEventListener("change",h),this.nickInput.addEventListener("keydown",l=>{l.key==="Enter"&&(l.preventDefault(),this.nickInput.blur())}),this.selfIdEl=r("span",{class:"muted"},[t.selfId.slice(0,8)]),this.selfRow=r("div",{class:"member self"},[r("label",{class:"nick-label"},[r("span",{},[a.you]),this.nickInput]),this.selfIdEl]),this.membersEl.append(this.selfRow),this.root=r("div",{class:"chat"},[r("section",{class:"transcript"},[r("div",{class:"status-row"},[this.statusEl,this.actionsEl,this.peopleBtn]),this.vacantEl,this.log.el,n.el,r("div",{class:"composer"},[i,s,o])]),r("aside",{class:"side"},[r("h2",{},[a.members]),this.membersEl])])}handlers;root;log=new ye;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=>`${de(o.url)} ${ue(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:${Z(n.id)}`},[n.nick]),r("span",{class:"muted"},[n.typing?"输入中":n.rttMs!==null?`${Math.round(n.rttMs)}ms`:""])]))}renderActions(t){if(A(this.actionsEl),t.phase==="error"){this.actionsEl.append(T(a.retry,this.handlers.onRetry),T(a.switchSignal,this.handlers.onSwitchSignal));return}t.phase==="connecting"&&t.peerCount===0&&this.actionsEl.append(T(a.share,this.handlers.onShare))}}function T(e,t){const s=r("button",{class:"btn ghost compact",type:"button"},[e]);return s.addEventListener("click",n=>{n.stopPropagation(),t()}),s}const ct="zenchat.theme";function ve(){const e=localStorage.getItem(ct);return e==="light"||e==="dark"||e==="system"?e:"system"}function Ee(e){localStorage.setItem(ct,e)}function ke(e){return e==="system"?"light":e==="light"?"dark":"system"}function Se(){return window.matchMedia("(prefers-color-scheme: dark)").matches}function Te(e,t=Se()){return e==="system"?t?"dark":"light":e}function L(e){const t=Te(e);return document.documentElement.dataset.theme=t,document.documentElement.dataset.themePref=e,document.documentElement.style.colorScheme=t,t}function G(e){return e==="light"?"白天":e==="dark"?"黑夜":"跟随系统"}function lt(){return typeof RTCPeerConnection=="function"}function q(){return window.isSecureContext&<()}class Le{root;theme;identity=tt();manager;themeBtn;tabsEl;toolsEl;main;jump;jumpRoom;jumpPass;jumpStrategy;footerEl;dupBanner;chat=null;lobbyEl=null;routing=!1;stopTabWatch=null;constructor(t){this.root=t,this.theme=ve(),L(this.theme),this.themeBtn=r("button",{class:"btn ghost",type:"button",title:a.theme},[G(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.jumpRoom=r("input",{placeholder:a.room,maxlength:64,autocomplete:"off"}),this.jumpPass=r("input",{placeholder:a.password,type:"password",autocomplete:"off"}),this.jumpStrategy=r("select",{},[r("option",{value:"torrent"},[a.torrent]),r("option",{value:"nostr"},[a.nostr])]),this.jump=this.buildJump(),this.dupBanner=r("div",{class:"banner warn hidden"},[a.duplicateTab]),this.footerEl=r("p",{class:"footer"},[r("a",{href:kt,target:"_blank",rel:"noreferrer"},[a.source])," · AGPL-3.0"]),this.manager=new re(this.identity,{onStatus:s=>this.chat?.status(s),onMembers:s=>this.chat?.members(s,this.manager.getSession()?.selfId??this.identity.id),onLine:s=>this.chat?.push(s),onReset:s=>this.chat?.reset(s)})}start(){this.mount(),this.stopTabWatch=le(()=>this.dupBanner.classList.remove("hidden")),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>L(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.openJump()),t.key==="Escape"&&this.jump.close()}),this.themeBtn.addEventListener("click",()=>{this.theme=ke(this.theme),Ee(this.theme),L(this.theme),this.themeBtn.textContent=G(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]),this.capabilityBanner(),this.dupBanner,this.main,this.footerEl]),this.jump)}capabilityBanner(){return window.isSecureContext?lt()?r("div"):r("div",{class:"banner"},[a.webrtcMissing]):r("div",{class:"banner"},[a.insecure])}buildJump(){const t=r("form",{class:"jump-form",method:"dialog"},[r("h2",{},[a.switch]),this.jumpRoom,this.jumpPass,this.jumpStrategy,r("div",{class:"actions"},[r("button",{class:"btn primary",type:"submit"},[a.join]),r("button",{class:"btn ghost",type:"button",value:"cancel"},[a.cancel])])]),s=r("dialog",{class:"jump"},[t]);return t.addEventListener("submit",n=>{n.preventDefault();const i={name:this.jumpRoom.value,password:this.jumpPass.value,strategy:this.jumpStrategy.value};s.close(),this.go(i)}),t.querySelector('button[value="cancel"]')?.addEventListener("click",()=>s.close()),s}openJump(t){this.jumpRoom.value=t?.name??"",this.jumpPass.value=t?.password??"",this.jumpStrategy.value=t?.strategy??"torrent",this.jump.showModal(),t?.needPassword?this.jumpPass.focus():this.jumpRoom.focus()}async route(){const t=oe(location.hash);if(this.refreshTabs(),this.refreshTools(t.name==="room"?x(t.spec):null),t.name==="lobby"){await this.manager.close(),this.showLobby();return}const s=this.stripPasswordFromHash(x(t.spec));await this.showChat(s)}stripPasswordFromHash(t){j(t);const s=S({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=S({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"),this.lobbyEl?this.lobbyEl.replaceWith(this.lobbyEl=this.buildLobby()):this.lobbyEl=this.buildLobby(),this.main.replaceChildren(this.lobbyEl),this.refreshTabs()}async showChat(t){this.chat?this.chat.setNick(this.identity.nick):this.chat=new we({selfId:this.identity.id,nick:this.identity.nick,send:s=>this.manager.getSession()?.sendChat(s)??Promise.resolve("closed"),typing:()=>this.manager.getSession()?.sendTyping(),onRetry:()=>{const s=this.manager.current()??t;this.manager.open(s,!0)},onShare:()=>{const s=this.manager.current()??t;this.shareRoom(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:$(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.refreshTabs(t),this.refreshTools(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})}}buildLobby(){const t=r("input",{maxlength:24,value:this.identity.nick,autocomplete:"nickname"}),s=r("input",{maxlength:64,value:gt,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]);q()||(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(),q()&&(this.identity={...this.identity,nick:$(t.value)},this.manager.setIdentity(this.identity),this.go({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])}refreshTabs(t){const s=it();A(this.tabsEl);for(const i of s){const o=!!(t&&i.name===f(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:S({name:"room",spec:{name:i.name,password:"",strategy:i.strategy}})},c);h.addEventListener("click",l=>{l.preventDefault();const m=Pt(i,t);if(m==="need-password"){this.openJump({name:i.name,strategy:i.strategy,needPassword:!0});return}this.go(m)}),this.tabsEl.append(h)}const n=r("button",{class:"tab add",type:"button",title:a.addRoom},["+"]);n.addEventListener("click",()=>this.openJump()),this.tabsEl.append(n)}refreshTools(t){if(A(this.toolsEl),this.toolsEl.append(this.themeBtn),!t)return;const s=r("button",{class:"btn ghost",type:"button"},[a.share]);if(s.addEventListener("click",()=>{this.shareRoom(t,!1,s)}),this.toolsEl.append(s),t.password){const i=r("button",{class:"btn ghost",type:"button"},[a.shareWithKey]);i.addEventListener("click",()=>{this.shareRoom(t,!0,i)}),this.toolsEl.append(i)}const n=r("a",{class:"btn ghost",href:"#/"},[a.leave]);this.toolsEl.append(n)}async shareRoom(t,s,n){const i=ae(location.origin,location.pathname,t,s),o=await ce(i);if(!n)return;const c=n.textContent;n.textContent=o==="shared"?a.shared:o==="copied"?a.copied:a.copyFailed,window.setTimeout(()=>{c&&(n.textContent=c)},1400)}}function k(e,t){return r("label",{class:"field"},[r("span",{},[e]),t])}function Ce(){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 ht=document.querySelector("#app");if(!ht)throw new Error("缺少 #app 挂载点");Ce();se();new Le(ht).start();
diff --git a/assets/index-CbGVd7dV.css b/assets/index-Bou1xdtq.css
similarity index 70%
rename from assets/index-CbGVd7dV.css
rename to assets/index-Bou1xdtq.css
index e7e4961..ca82f56 100644
--- a/assets/index-CbGVd7dV.css
+++ b/assets/index-Bou1xdtq.css
@@ -1 +1 @@
-:root,[data-theme=light]{--bg: #faf9f5;--grouped: #f3eee4;--fg: #1a1612;--muted: #6f665c;--hair: #e4d9c8;--accent: #d97757;--accent-deep: #c15f3c;--on-accent: #faf6f0;--danger: #9f1239;--ok: #4f8a6a;--fill: rgba(44, 28, 16, .06);--shadow: 0 18px 48px rgba(48, 28, 12, .1);--glow-1: color-mix(in srgb, var(--accent) 22%, transparent);--glow-2: color-mix(in srgb, #e8b86a 18%, transparent)}[data-theme=dark]{--bg: #161412;--grouped: #211e1a;--fg: #f4f0e8;--muted: #a89f94;--hair: #3a332b;--accent: #e39472;--accent-deep: #d97757;--on-accent: #1a1410;--danger: #fb7185;--ok: #7dba8e;--fill: rgba(255, 236, 214, .07);--shadow: 0 18px 48px rgba(0, 0, 0, .4);--glow-1: color-mix(in srgb, var(--accent) 18%, transparent);--glow-2: color-mix(in srgb, #c96442 12%, transparent)}*{box-sizing:border-box}html,body,#app{height:var(--app-h, 100dvh);margin:0;overflow:hidden}body{font:16px/1.5 "Source Serif 4",Iowan Old Style,Palatino Linotype,Songti SC,"Noto Serif SC",PingFang SC,serif;background:radial-gradient(1100px 460px at 8% -12%,var(--glow-1),transparent 54%),radial-gradient(820px 380px at 108% -4%,var(--glow-2),transparent 50%),var(--bg);color:var(--fg);letter-spacing:.01em;-webkit-font-smoothing:antialiased;overscroll-behavior:none}::selection{background:color-mix(in srgb,var(--accent) 34%,transparent)}input,select,textarea,.tab,.btn,.status,.member,.footer,.hint,.field span{font-family:IBM Plex Sans,"Source Sans 3",-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Noto Sans SC,sans-serif}#app{display:flex}.shell{flex:1;min-height:0;width:min(920px,100%);margin:0 auto;padding:0 16px calc(10px + env(safe-area-inset-bottom,0px));padding-top:env(safe-area-inset-top,0px);display:flex;flex-direction:column}.topbar{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:8px;height:52px;flex:0 0 auto}.brand{display:flex;align-items:center;gap:9px;text-decoration:none;color:inherit}.enso{width:18px;height:18px;border:2px solid var(--accent);border-radius:50%;border-right-color:transparent;transform:rotate(-18deg);flex:0 0 auto}.brand strong{font-size:1.22rem;font-weight:600;letter-spacing:.04em}.tagline{color:var(--muted);font-size:12px;font-family:IBM Plex Sans,"Source Sans 3",-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,sans-serif;white-space:nowrap}.mode-chat .tagline{display:none}.tabs{display:flex;gap:2px;overflow-x:auto;min-width:0;scrollbar-width:none}.tabs::-webkit-scrollbar{display:none}.tab,.btn{font:inherit;color:inherit;background:transparent;border:0;cursor:pointer;touch-action:manipulation}.tab{flex:0 0 auto;padding:6px 10px;border-radius:999px;color:var(--muted);text-decoration:none;font-size:13px}.tab.on{color:var(--accent-deep);background:color-mix(in srgb,var(--accent) 14%,transparent)}.tab.add{width:32px;padding:6px 0}.toolbar{display:flex;gap:2px}.btn{padding:6px 11px;border-radius:999px;color:var(--muted);font-size:13px}.btn.ghost:hover,.tab:hover{background:var(--fill);color:var(--fg)}.btn.primary,.btn.send{background:var(--accent);color:var(--on-accent);border:1px solid transparent}.btn.primary:hover,.btn.send:hover{background:var(--accent-deep);color:var(--on-accent)}.btn.send{width:32px;height:32px;padding:0;border-radius:50%;font-size:16px;line-height:32px}.btn:focus-visible,.tab:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid color-mix(in srgb,var(--accent) 70%,transparent);outline-offset:2px}.main{flex:1;min-height:0;display:flex;flex-direction:column}.lobby{display:grid;gap:12px;align-content:start;min-height:0;overflow:auto}.lede{margin:4px 0 2px;max-width:36em;color:var(--muted);font-size:15px;line-height:1.65}.panel{background:color-mix(in srgb,var(--grouped) 78%,var(--bg));border:1px solid var(--hair);border-radius:20px;padding:16px;box-shadow:var(--shadow)}.lobby-form{display:grid;gap:10px}.join-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;align-items:end}.join-btn{min-height:40px;padding:0 18px}.row{display:grid;grid-template-columns:1fr 1fr;gap:8px}.field{display:grid;gap:4px}.field span,.hint,.muted,.footer,.status,.system{color:var(--muted);font-size:13px}.field input,.field select,.composer textarea,.jump input,.jump select{width:100%;font:inherit;font-size:16px;color:inherit;background:color-mix(in srgb,var(--bg) 80%,white);border:1px solid var(--hair);border-radius:12px;padding:8px 10px}[data-theme=dark] .field input,[data-theme=dark] .field select,[data-theme=dark] .composer textarea,[data-theme=dark] .jump input,[data-theme=dark] .jump select{background:color-mix(in srgb,var(--grouped) 70%,#000)}.more{color:var(--muted);font-size:13px}.more summary{cursor:pointer;padding:4px 0}.actions{display:flex;gap:8px}.chat{position:relative;flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 200px;gap:12px}.transcript{display:flex;flex-direction:column;min-height:0;min-width:0}.status-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:8px;min-height:28px}.status-actions{display:flex;gap:4px}.btn.compact{padding:4px 8px;font-size:12px}.btn:disabled{opacity:.45;cursor:not-allowed}.vacant{margin:24px auto;max-width:22em;text-align:center;color:var(--muted);font-size:14px;line-height:1.6}.vacant[hidden]{display:none!important}.lock{margin-left:4px;font-size:10px;color:var(--accent-deep)}.tips{margin:0 0 4px;padding-left:1.2em;color:var(--muted);font-size:13px;line-height:1.65}.banner.warn{color:var(--accent-deep)}.dot.err{background:var(--danger)}.nick-label{display:grid;gap:2px;min-width:0}.nick-edit{width:100%;font:inherit;font-size:14px;color:inherit;background:transparent;border:0;border-bottom:1px solid var(--hair);padding:2px 0}.status{display:flex;align-items:center;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.log{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:8px;padding:4px 0 8px;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}.bubble{max-width:min(78%,36em);padding:8px 12px;border-radius:16px 16px 16px 4px;background:var(--grouped);border:1px solid color-mix(in srgb,var(--hair) 70%,transparent)}.bubble.self{align-self:flex-end;border-radius:16px 16px 4px;background:var(--accent);color:var(--on-accent);border-color:transparent}.bubble.self header,.bubble.self .muted{color:color-mix(in srgb,var(--on-accent) 74%,transparent)}.bubble header{display:flex;gap:8px;font-size:11px;color:var(--muted)}.bubble p{margin:2px 0 0;white-space:pre-wrap;word-break:break-word}.bubble.stamp p{font-size:22px;line-height:1.3}.system{align-self:center}.composer{display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:end;padding:8px 0 4px;background:transparent}.composer textarea{resize:none;min-height:36px;max-height:96px;line-height:1.3;border-radius:18px;padding:8px 14px}.dot{width:6px;height:6px;border-radius:50%;background:var(--muted);display:inline-block;margin-right:6px;flex:0 0 auto}.dot.ok{background:var(--ok)}.side{min-height:0;overflow:auto;padding:8px 4px}.side h2{margin:0 0 8px;font-size:12px;font-weight:600;color:var(--muted);letter-spacing:.06em}.member{display:flex;justify-content:space-between;gap:8px;padding:8px 0;border-bottom:1px solid var(--hair);font-size:14px}.banner{color:var(--danger);font-size:13px;padding:8px 0}.footer{margin:0;padding:8px 0 0}.footer a{color:inherit}.picker{position:absolute;left:12px;right:12px;bottom:56px;background:var(--grouped);border:1px solid var(--hair);border-radius:16px;padding:10px;z-index:3;box-shadow:var(--shadow)}.picker-tabs{display:flex;gap:4px;margin-bottom:8px}.picker-grid{display:flex;flex-wrap:wrap;gap:6px;max-height:140px;overflow:auto}.stamp-btn{font:inherit;color:inherit;background:var(--bg);border:1px solid var(--hair);border-radius:10px;padding:6px 8px;cursor:pointer}.stamp-btn:hover{border-color:var(--accent)}.emoji .stamp-btn{font-size:18px}.hidden{display:none!important}.jump{border:1px solid var(--hair);border-radius:20px;background:var(--grouped);color:inherit;padding:0;width:min(400px,calc(100% - 32px));box-shadow:var(--shadow)}.jump::backdrop{background:#1c120a6b}.jump-form{display:grid;gap:10px;padding:18px}.jump h2{margin:0;font-size:1.15rem;font-family:"Source Serif 4",Iowan Old Style,Palatino Linotype,Songti SC,serif}@media(max-width:720px){.tagline{display:none}.join-row,.row{grid-template-columns:1fr}.join-btn{width:100%;min-height:44px}.chat{grid-template-columns:1fr}.side{display:none;position:absolute;top:36px;right:0;width:min(240px,80vw);max-height:50%;background:var(--grouped);border:1px solid var(--hair);border-radius:16px;padding:10px 12px;z-index:2;box-shadow:var(--shadow)}.chat.show-side .side{display:block}.topbar{grid-template-columns:auto 1fr auto}}
+:root,[data-theme=light]{--bg: #faf9f5;--grouped: #f3eee4;--fg: #1a1612;--muted: #6f665c;--hair: #e4d9c8;--accent: #d97757;--accent-deep: #c15f3c;--on-accent: #faf6f0;--danger: #9f1239;--ok: #4f8a6a;--fill: rgba(44, 28, 16, .06);--shadow: 0 18px 48px rgba(48, 28, 12, .1);--glow-1: color-mix(in srgb, var(--accent) 22%, transparent);--glow-2: color-mix(in srgb, #e8b86a 18%, transparent)}[data-theme=dark]{--bg: #161412;--grouped: #211e1a;--fg: #f4f0e8;--muted: #a89f94;--hair: #3a332b;--accent: #e39472;--accent-deep: #d97757;--on-accent: #1a1410;--danger: #fb7185;--ok: #7dba8e;--fill: rgba(255, 236, 214, .07);--shadow: 0 18px 48px rgba(0, 0, 0, .4);--glow-1: color-mix(in srgb, var(--accent) 18%, transparent);--glow-2: color-mix(in srgb, #c96442 12%, transparent)}*{box-sizing:border-box}html,body,#app{height:var(--app-h, 100dvh);margin:0;overflow:hidden}body{font:16px/1.5 "Source Serif 4",Iowan Old Style,Palatino Linotype,Songti SC,"Noto Serif SC",PingFang SC,serif;background:radial-gradient(1100px 460px at 8% -12%,var(--glow-1),transparent 54%),radial-gradient(820px 380px at 108% -4%,var(--glow-2),transparent 50%),var(--bg);color:var(--fg);letter-spacing:.01em;-webkit-font-smoothing:antialiased;overscroll-behavior:none}::selection{background:color-mix(in srgb,var(--accent) 34%,transparent)}input,select,textarea,.tab,.btn,.status,.member,.footer,.hint,.field span{font-family:IBM Plex Sans,"Source Sans 3",-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Noto Sans SC,sans-serif}#app{display:flex}.shell{flex:1;min-height:0;width:min(920px,100%);margin:0 auto;padding:0 16px calc(10px + env(safe-area-inset-bottom,0px));padding-top:env(safe-area-inset-top,0px);display:flex;flex-direction:column}.topbar{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:8px;height:52px;flex:0 0 auto}.brand{display:flex;align-items:center;gap:9px;text-decoration:none;color:inherit}.enso{width:18px;height:18px;border:2px solid var(--accent);border-radius:50%;border-right-color:transparent;transform:rotate(-18deg);flex:0 0 auto}.brand strong{font-size:1.22rem;font-weight:600;letter-spacing:.04em}.tagline{color:var(--muted);font-size:12px;font-family:IBM Plex Sans,"Source Sans 3",-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,sans-serif;white-space:nowrap}.mode-chat .tagline{display:none}.tabs{display:flex;gap:2px;overflow-x:auto;min-width:0;scrollbar-width:none}.tabs::-webkit-scrollbar{display:none}.tab,.btn{font:inherit;color:inherit;background:transparent;border:0;cursor:pointer;touch-action:manipulation}.tab{flex:0 0 auto;padding:6px 10px;border-radius:999px;color:var(--muted);text-decoration:none;font-size:13px}.tab.on{color:var(--accent-deep);background:color-mix(in srgb,var(--accent) 14%,transparent)}.tab.add{width:32px;padding:6px 0}.toolbar{display:flex;gap:2px}.btn{padding:6px 11px;border-radius:999px;color:var(--muted);font-size:13px}.btn.ghost:hover,.tab:hover{background:var(--fill);color:var(--fg)}.btn.primary,.btn.send{background:var(--accent);color:var(--on-accent);border:1px solid transparent}.btn.primary:hover,.btn.send:hover{background:var(--accent-deep);color:var(--on-accent)}.btn.send{width:32px;height:32px;padding:0;border-radius:50%;font-size:16px;line-height:32px}.btn:focus-visible,.tab:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid color-mix(in srgb,var(--accent) 70%,transparent);outline-offset:2px}.main{flex:1;min-height:0;display:flex;flex-direction:column}.lobby{display:grid;gap:12px;align-content:start;min-height:0;overflow:auto}.lede{margin:4px 0 2px;max-width:36em;color:var(--muted);font-size:15px;line-height:1.65}.panel{background:color-mix(in srgb,var(--grouped) 78%,var(--bg));border:1px solid var(--hair);border-radius:20px;padding:16px;box-shadow:var(--shadow)}.lobby-form{display:grid;gap:10px}.join-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;align-items:end}.join-btn{min-height:40px;padding:0 18px}.row{display:grid;grid-template-columns:1fr 1fr;gap:8px}.field{display:grid;gap:4px}.field span,.hint,.muted,.footer,.status,.system{color:var(--muted);font-size:13px}.field input,.field select,.composer textarea,.jump input,.jump select{width:100%;font:inherit;font-size:16px;color:inherit;background:color-mix(in srgb,var(--bg) 80%,white);border:1px solid var(--hair);border-radius:12px;padding:8px 10px}[data-theme=dark] .field input,[data-theme=dark] .field select,[data-theme=dark] .composer textarea,[data-theme=dark] .jump input,[data-theme=dark] .jump select{background:color-mix(in srgb,var(--grouped) 70%,#000)}.more{color:var(--muted);font-size:13px}.more summary{cursor:pointer;padding:4px 0}.actions{display:flex;gap:8px}.chat{position:relative;flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 200px;gap:12px}.transcript{display:flex;flex-direction:column;min-height:0;min-width:0}.status-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:8px;min-height:28px}.status-actions{display:flex;gap:4px}.btn.compact{padding:4px 8px;font-size:12px}.btn:disabled{opacity:.45;cursor:not-allowed}.vacant{margin:24px auto;max-width:22em;text-align:center;color:var(--muted);font-size:14px;line-height:1.6}.vacant[hidden]{display:none!important}.lock{margin-left:4px;font-size:10px;color:var(--accent-deep)}.tips{margin:0 0 4px;padding-left:1.2em;color:var(--muted);font-size:13px;line-height:1.65}.banner.warn{color:var(--accent-deep)}.dot.err{background:var(--danger)}.nick-label{display:grid;gap:2px;min-width:0}.nick-edit{width:100%;font:inherit;font-size:14px;color:inherit;background:transparent;border:0;border-bottom:1px solid var(--hair);padding:2px 0}.status{display:flex;align-items:center;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.log{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:8px;padding:4px 0 8px;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}.bubble{max-width:min(78%,36em);padding:8px 12px;border-radius:16px 16px 16px 4px;background:var(--grouped);border:1px solid color-mix(in srgb,var(--hair) 70%,transparent)}.bubble.self{align-self:flex-end;border-radius:16px 16px 4px;background:var(--accent);color:var(--on-accent);border-color:transparent}.bubble.self .delivery{color:color-mix(in srgb,var(--on-accent) 74%,transparent);font-size:11px}.bubble.self header,.bubble.self .muted{color:color-mix(in srgb,var(--on-accent) 74%,transparent)}.bubble.self .delivery.fail,.bubble.failed{opacity:.86}.bubble header{display:flex;gap:8px;font-size:11px;color:var(--muted)}.bubble p{margin:2px 0 0;white-space:pre-wrap;word-break:break-word}.bubble.stamp p{font-size:22px;line-height:1.3}.system{align-self:center}.composer{display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:end;padding:8px 0 4px;background:transparent}.composer textarea{resize:none;min-height:36px;max-height:96px;line-height:1.3;border-radius:18px;padding:8px 14px}.dot{width:6px;height:6px;border-radius:50%;background:var(--muted);display:inline-block;margin-right:6px;flex:0 0 auto}.dot.ok{background:var(--ok)}.side{min-height:0;overflow:auto;padding:8px 4px}.side h2{margin:0 0 8px;font-size:12px;font-weight:600;color:var(--muted);letter-spacing:.06em}.member{display:flex;justify-content:space-between;gap:8px;padding:8px 0;border-bottom:1px solid var(--hair);font-size:14px}.banner{color:var(--danger);font-size:13px;padding:8px 0}.footer{margin:0;padding:8px 0 0}.footer a{color:inherit}.picker{position:absolute;left:12px;right:12px;bottom:56px;background:var(--grouped);border:1px solid var(--hair);border-radius:16px;padding:10px;z-index:3;box-shadow:var(--shadow)}.picker-tabs{display:flex;gap:4px;margin-bottom:8px}.picker-grid{display:flex;flex-wrap:wrap;gap:6px;max-height:140px;overflow:auto}.stamp-btn{font:inherit;color:inherit;background:var(--bg);border:1px solid var(--hair);border-radius:10px;padding:6px 8px;cursor:pointer}.stamp-btn:hover{border-color:var(--accent)}.emoji .stamp-btn{font-size:18px}.hidden{display:none!important}.jump{border:1px solid var(--hair);border-radius:20px;background:var(--grouped);color:inherit;padding:0;width:min(400px,calc(100% - 32px));box-shadow:var(--shadow)}.jump::backdrop{background:#1c120a6b}.jump-form{display:grid;gap:10px;padding:18px}.jump h2{margin:0;font-size:1.15rem;font-family:"Source Serif 4",Iowan Old Style,Palatino Linotype,Songti SC,serif}@media(max-width:720px){.tagline{display:none}.join-row,.row{grid-template-columns:1fr}.join-btn{width:100%;min-height:44px}.chat{grid-template-columns:1fr}.side{display:none;position:absolute;top:36px;right:0;width:min(240px,80vw);max-height:50%;background:var(--grouped);border:1px solid var(--hair);border-radius:16px;padding:10px 12px;z-index:2;box-shadow:var(--shadow)}.chat.show-side .side{display:block}.topbar{grid-template-columns:auto 1fr auto}}
diff --git a/assets/index-Ckc1AzqL.js b/assets/index-Ckc1AzqL.js
new file mode 100644
index 0000000..5aa9e26
--- /dev/null
+++ b/assets/index-Ckc1AzqL.js
@@ -0,0 +1,3 @@
+const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-BgLl3BAq.js","assets/strategy-C52a_06u.js","assets/index-DSJFV8_4.js"])))=>i.map(i=>d[i]);
+(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const c of o.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&n(c)}).observe(document,{childList:!0,subtree:!0});function s(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function n(i){if(i.ep)return;i.ep=!0;const o=s(i);fetch(i.href,o)}})();const Et="zenchat.andyccr.v1",u=1,St=24,Tt=64,it=4e3,rt=10,E=200,Lt=E*2,Ct="lobby",It=2500,Pt=1800,Rt=25e3,At=5e3,Nt=8e3,_t="https://github.com/Andyccr/ZenChat",R="zenchat.nick",D="zenchat.peerId",B=["闲云","青石","疏影","晚风","折柳","听雨","归舟","松风"];function A(e){const t=new Uint8Array(e);return crypto.getRandomValues(t),[...t].map(s=>s.toString(16).padStart(2,"0")).join("")}const J=["#c15f3c","#8b5e3c","#5c6b4a","#6b4f3a","#9a6b3f","#4a6b62","#7a4e3a","#5a5568"];function ot(e){let t=2166136261;for(let s=0;s{const i=n.codePointAt(0)??0;return i>=32&&i!==127}).join("").slice(0,St)}function at(e){const t=Number.parseInt(e.slice(0,4),16)%B.length;return B[t]??"闲云"}function ct(){const e=sessionStorage.getItem(D),t=e&&/^[0-9a-f]{16,}$/i.test(e)?e:A(12);sessionStorage.setItem(D,t);const s=localStorage.getItem(R),n=b(s??"")||at(t);return localStorage.setItem(R,n),{id:t,nick:n}}function H(e){const t=b(e)||at(ct().id);return localStorage.setItem(R,t),t}function jt(e){return e==="torrent"||e==="nostr"}function g(e){return e.normalize("NFKC").trim().replace(/\s+/g,"-").replace(/[/#?&]+/g,"").slice(0,Tt)}function xt(e){const t=g(e.name);return e.password?`${t}::${e.password}`:t}function N(e){return`${e.strategy}:${g(e.name)}`}function _(e){return`${e.strategy}:${g(e.name)}:${e.password}`}function Mt(e,t){return _(e)===_(t)}const Ot="zenchat.cache.v1.";function lt(e){return Ot+_(e)}function $t(e){try{const t=sessionStorage.getItem(lt(e));if(!t)return[];const s=JSON.parse(t);return Array.isArray(s)?s.filter(Dt).slice(-E):[]}catch{return[]}}function K(e,t){try{sessionStorage.setItem(lt(e),JSON.stringify(t.slice(-E)))}catch{}}function F(e){return e.length>E?e.slice(-E):e}function Dt(e){if(typeof e!="object"||e===null)return!1;const t=e;return t.kind==="system"?typeof t.id=="string"&&typeof t.text=="string"&&typeof t.ts=="number":t.kind==="chat"?typeof t.id=="string"&&typeof t.fromId=="string"&&typeof t.nick=="string"&&typeof t.text=="string"&&typeof t.ts=="number"&&typeof t.self=="boolean":!1}const Bt="zenchat.pass.v1.";function ht(e){return Bt+N(e)}function j(e){try{const t=ht(e);e.password?sessionStorage.setItem(t,e.password):sessionStorage.removeItem(t)}catch{}}function dt(e){try{return sessionStorage.getItem(ht(e))??""}catch{return""}}function V(e){if(e.password)return j(e),e;const t=dt(e);return t?{...e,password:t}:e}const ut="zenchat.recentRooms";function pt(){try{const e=localStorage.getItem(ut);if(!e)return[];const t=JSON.parse(e);return Array.isArray(t)?t.filter(s=>typeof s=="object"&&s!==null&&typeof s.name=="string"&&(s.strategy==="torrent"||s.strategy==="nostr")&&typeof s.hasPassword=="boolean"&&typeof s.visitedAt=="number").slice(0,rt):[]}catch{return[]}}function Jt(e){const t={name:e.name,strategy:e.strategy,hasPassword:!!e.password,visitedAt:Date.now()},s=pt().filter(i=>N(i)!==N(e)),n=[t,...s].slice(0,rt);return localStorage.setItem(ut,JSON.stringify(n)),n}function Ht(e,t){const s=dt(e),i=(!!(t&&g(e.name)===g(t.name)&&e.strategy===t.strategy)?t?.password??"":"")||s;return e.hasPassword&&!i?"need-password":{name:e.name,password:i,strategy:e.strategy}}class Kt{constructor(t,s,n){this.runtime=t,this.timeoutMs=s,this.onExpire=n}runtime;timeoutMs;onExpire;timers=new Map;expect(t){this.cancel(t),this.timers.set(t,this.runtime.setTimeout(()=>{this.timers.delete(t),this.onExpire(t)},this.timeoutMs))}ack(t){return this.timers.has(t)?(this.cancel(t),!0):!1}cancel(t){const s=this.timers.get(t);s!==void 0&&this.runtime.clearTimeout(s),this.timers.delete(t)}clear(){for(const t of[...this.timers.keys()])this.cancel(t)}}class Ft{constructor(t,s=()=>{},n=It){this.runtime=t,this.onTypingEnd=s,this.typingTtl=n}runtime;onTypingEnd;typingTtl;members=new Map;typingTimers=new Map;upsert(t,s,n){const i=this.members.get(t),o=this.runtime.now(),c={id:t,nick:s,joinedAt:i?.joinedAt??o,lastSeenAt:o,rttMs:i?.rttMs??null,typing:i?.typing??!1,features:n??i?.features??[]};return this.members.set(t,c),c}supports(t){return this.list().some(s=>s.features.includes(t))}remove(t){this.clearTypingTimer(t);const s=this.members.get(t);return this.members.delete(t),s}markTyping(t){const s=this.members.get(t);return s?(s.typing=!0,this.clearTypingTimer(t),this.typingTimers.set(t,this.runtime.setTimeout(()=>{const n=this.members.get(t);n&&(n.typing=!1,this.onTypingEnd(t))},this.typingTtl)),!0):!1}setRtt(t,s){const n=this.members.get(t);return n?(n.rttMs=s,!0):!1}list(){return[...this.members.values()].sort((t,s)=>t.joinedAt-s.joinedAt)}clear(){for(const t of this.typingTimers.keys())this.clearTypingTimer(t);this.members.clear()}clearTypingTimer(t){const s=this.typingTimers.get(t);s!==void 0&&this.runtime.clearTimeout(s),this.typingTimers.delete(t)}}const O="ack";function Vt(e){return typeof e=="object"&&e!==null}function zt(e){return e===u}function Ut(e){return Array.isArray(e)?[...new Set(e.filter(t=>t===O))]:[]}function z(e){return typeof e!="string"||e.length<8||e.length>80?null:e}function Wt(e,t,s,n){return{v:u,type:"chat",id:s,ts:n,nick:b(e),text:t.trim().slice(0,it)}}function k(e){return{v:u,type:"hello",nick:b(e),features:[O]}}function Gt(e){return{v:u,type:"typing",nick:b(e)}}function U(e,t){return{v:u,type:"ack",id:t,nick:b(e)}}function qt(e){if(!Vt(e)||!zt(e.v)||typeof e.type!="string")return null;const t=typeof e.nick=="string"?b(e.nick):"";if(!t)return null;if(e.type==="hello")return{v:u,type:"hello",nick:t,features:Ut(e.features)};if(e.type==="typing")return{v:u,type:"typing",nick:t};if(e.type==="ack"){const s=z(e.id);return s?{v:u,type:"ack",id:s,nick:t}:null}if(e.type==="chat"){const s=z(e.id);if(!s||typeof e.ts!="number"||!Number.isFinite(e.ts)||typeof e.text!="string")return null;const n=e.text.trim().slice(0,it);return n?{v:u,type:"chat",id:s,ts:e.ts,nick:t,text:n}:null}return null}const Yt={now:()=>Date.now(),hidden:()=>document.hidden,setTimeout:(e,t)=>window.setTimeout(e,t),clearTimeout:e=>window.clearTimeout(e),setInterval:(e,t)=>window.setInterval(e,t),clearInterval:e=>window.clearInterval(e),onVisibilityChange(e){return document.addEventListener("visibilitychange",e),()=>document.removeEventListener("visibilitychange",e)}};function Xt(e,t){let s=Number.NEGATIVE_INFINITY;return(...n)=>{const i=Date.now();i-se(...n);let t=0,s=null;return(...n)=>{s=n,!t&&(t=requestAnimationFrame(()=>{t=0;const i=s;s=null,i&&e(...i)}))}}function mt(){return{state:"idle",peers:0,relaysDown:!1}}function T(e){return e.state==="joining"||e.state==="waiting"||e.state==="live"||e.state==="relay_down"}function C(e){return e.state==="idle"||e.state==="failed"||e.state==="joining"||e.state==="leaving"?e:e.peers>0?{...e,state:"live"}:e.relaysDown?{...e,state:"relay_down"}:{...e,state:"waiting"}}function y(e,t){switch(t.type){case"join":return e.state==="leaving"?e:{state:"joining",peers:0,relaysDown:!1};case"join_ok":return e.state!=="joining"?e:C({...e,state:"waiting"});case"join_err":return e.state!=="joining"?e:{state:"failed",peers:0,relaysDown:!1};case"peers":return T(e)?C({...e,peers:t.count}):e;case"relays":return T(e)?C({...e,relaysDown:t.down}):e;case"force_down":return!T(e)||e.peers>0?e:{...e,relaysDown:!0,state:"relay_down"};case"leave":return e.state==="idle"?e:{...e,state:"leaving"};case"left":return mt()}}function Qt(e){return e.state==="live"?"connected":e.state==="failed"||e.state==="relay_down"?"error":e.state==="idle"?"idle":"connecting"}const p={idle:"尚未连接",left:"已离开房间",connectingTorrent:"正在连接 Tracker…",connectingNostr:"正在连接 Nostr…",waiting:"还没有同伴,把链接发给对方",handshake:"握手受阻,仍在重试",relaysDownTorrent:"Tracker 连不上,可改用 Nostr",relaysDownNostr:"Nostr 中继连不上,可改用 Tracker",sendFailed:"刚才那条没发出去,请再试一次"};function W(e){return e==="nostr"?p.connectingNostr:p.connectingTorrent}function te(e){return`已直连 ${e} 人`}function ee(e){return`无法启动 P2P:${e}`}function se(e){return e.filter(t=>t.readyState===1).length}function ne(e){return e.length>0&&se(e)===0}function ie(e,t){return ne(t)?{phase:"error",detail:e==="nostr"?p.relaysDownNostr:p.relaysDownTorrent}:{phase:"connecting",detail:p.waiting}}function re(e){return e.kind!=="chat"||e.delivery!=="pending"?e:x({id:e.id,fromId:e.fromId,nick:e.nick,text:e.text,ts:e.ts,self:e.self})}class oe{lines=[];seen=new Set;seenOrder=[];hydrate(t){this.clear(),this.lines=F(t.map(re));for(const s of this.lines)s.kind==="chat"&&this.remember(s.id);return this.lines}snapshot(){return this.lines}has(t){return this.seen.has(t)}remember(t){if(!this.seen.has(t))for(this.seen.add(t),this.seenOrder.push(t);this.seenOrder.length>Lt;){const s=this.seenOrder.shift();s&&this.seen.delete(s)}}append(t){if(t.kind==="chat"){if(this.seen.has(t.id))return null;this.remember(t.id)}return this.lines.push(t),this.lines=F(this.lines),t}patchDelivery(t,s){const n=this.lines.findIndex(c=>c.id===t),i=n>=0?this.lines[n]:void 0;if(n<0||!i||i.kind!=="chat")return null;const o={...i,delivery:s};return this.lines[n]=o,o}clear(){this.lines=[],this.seen.clear(),this.seenOrder=[]}}function x(e){return e.delivery?{kind:"chat",...e,delivery:e.delivery}:{kind:"chat",id:e.id,fromId:e.fromId,nick:e.nick,text:e.text,ts:e.ts,self:e.self}}function ae(e,t,s){return{kind:"system",id:e,text:t,ts:s}}const ce="modulepreload",le=function(e){return"/ZenChat/"+e},G={},q=function(t,s,n){let i=Promise.resolve();if(s&&s.length>0){let m=function(d){return Promise.all(d.map(w=>Promise.resolve(w).then(v=>({status:"fulfilled",value:v}),v=>({status:"rejected",reason:v}))))};var c=m;document.getElementsByTagName("link");const h=document.querySelector("meta[property=csp-nonce]"),l=h?.nonce||h?.getAttribute("nonce");i=m(s.map(d=>{if(d=le(d),d in G)return;G[d]=!0;const w=d.endsWith(".css"),v=w?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${d}"]${v}`))return;const f=document.createElement("link");if(f.rel=w?"stylesheet":ce,w||(f.as="script"),f.crossOrigin="",f.href=d,l&&f.setAttribute("nonce",l),document.head.appendChild(f),w)return new Promise((vt,kt)=>{f.addEventListener("load",vt),f.addEventListener("error",()=>kt(new Error(`Unable to preload CSS for ${d}`)))})}))}function o(h){const l=new Event("vite:preloadError",{cancelable:!0});if(l.payload=h,window.dispatchEvent(l),!l.defaultPrevented)throw h}return i.then(h=>{for(const l of h||[])l.status==="rejected"&&o(l.reason);return t().catch(o)})},he=[{urls:"stun:stun.cloudflare.com:3478"},{urls:"stun:stun.l.google.com:19302"}],de={iceServers:he,iceCandidatePoolSize:8,bundlePolicy:"max-bundle",rtcpMuxPolicy:"require"},ue=["wss://tracker.webtorrent.dev","wss://tracker.openwebtorrent.com","wss://open.ftorrent.com:443"],pe=["wss://relay.damus.io","wss://nos.lol","wss://relay.snort.social","wss://nostr.wine"];function me(e){return Object.entries(e).map(([t,s])=>({url:t,readyState:s?.readyState??WebSocket.CLOSED}))}class fe{strategy;room=null;action=null;module=null;constructor(t){this.strategy=t}selfId(){return this.module?.selfId??""}async join(t,s){await this.leave(),this.module=await M(t.strategy);const n=t.strategy==="torrent"?[...ue]:[...pe],i=this.module.joinRoom({appId:t.appId,...t.password?{password:t.password}:{},rtcConfig:de,trickleIce:!0,relayConfig:{urls:n}},t.roomId,{onJoinError:c=>{s.onJoinError(c.error||"无法与对等节点完成握手")}}),o=i.makeAction("zen");o.onMessage=(c,{peerId:h})=>{s.onPayload(h,c)},i.onPeerJoin=c=>s.onPeerJoin(c),i.onPeerLeave=c=>s.onPeerLeave(c),this.room=i,this.action=o}async leave(){const t=this.room;if(this.room=null,this.action=null,t)try{await t.leave()}catch{}}async send(t,s){if(!this.action)throw new Error("尚未加入房间");await this.action.send(t,s?{target:s}:void 0)}ping(t){return this.room?this.room.ping(t):Promise.reject(new Error("未连接"))}peerIds(){return this.room?Object.keys(this.room.getPeers()):[]}relays(){return this.module?me(this.module.getRelaySockets()):[]}}const Y=new Map;async function M(e){const t=Y.get(e);if(t)return t;const s=e==="torrent"?q(()=>import("./index-BgLl3BAq.js"),__vite__mapDeps([0,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId})):q(()=>import("./index-DSJFV8_4.js"),__vite__mapDeps([2,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId}));return Y.set(e,s),s}function ye(){M("torrent"),M("nostr")}function ge(e){return new fe(e)}class be{transport;identity;listeners;createTransport;runtime;transcript=new oe;presence;outbound;life=mt();helloTimer=null;relayTimer=null;unbindVisibility=null;strategy="torrent";joinErrors=0;status={phase:"idle",detail:p.idle,relays:[],peerCount:0};emitMembers=Zt(()=>{this.listeners.onMembers?.(this.presence.list())});sendTypingThrottled=Xt(()=>{this.isJoined()&&this.transport.send(Gt(this.identity.nick))},Pt);constructor(t,s,n={}){this.identity=t,this.listeners=s,this.createTransport=n.createTransport??ge,this.runtime=n.runtime??Yt,this.transport=this.createTransport("torrent"),this.presence=new Ft(this.runtime,()=>this.emitMembers()),this.outbound=new Kt(this.runtime,Nt,i=>this.expireAck(i))}get selfId(){return this.transport.selfId()||this.identity.id}isJoined(){return T(this.life)}getLines(){return this.transcript.snapshot()}hydrate(t){this.listeners.onReset?.(this.transcript.hydrate(t))}async join(t){this.isJoined()&&await this.leave({silent:!0}),this.strategy=t.strategy,this.joinErrors=0,this.transport=this.createTransport(t.strategy),this.life=y(this.life,{type:"join"}),this.setStatus({phase:"connecting",detail:W(t.strategy),relays:[],peerCount:0});try{await this.transport.join({appId:Et,roomId:xt({name:g(t.name),password:t.password}),password:t.password,strategy:t.strategy},{onPeerJoin:s=>{this.presence.upsert(s,"访客"),this.pushSystem(`${s.slice(0,6)} 加入了房间`),this.transport.send(k(this.identity.nick),s),this.emitMembers(),this.notePeers(),this.runtime.setTimeout(()=>{this.measure(s)},350)},onPeerLeave:s=>{const n=this.presence.remove(s);this.pushSystem(`${n?.nick??s.slice(0,6)} 离开了房间`),this.emitMembers(),this.notePeers()},onPayload:(s,n)=>this.handlePayload(s,n),onJoinError:()=>{this.joinErrors+=1,!(this.transport.peerIds().length>0)&&(this.joinErrors>=3?(this.life=y(this.life,{type:"force_down"}),this.emitStatus()):this.refreshStatus("connecting",p.handshake))}})}catch(s){this.life=y(this.life,{type:"join_err"});const n=s instanceof Error?s.message:"连接失败";throw this.refreshStatus("error",ee(n)),s}this.life=y(this.life,{type:"join_ok"}),this.bindVisibility(),this.startTimers(),this.transport.send(k(this.identity.nick)),this.emitStatus()}async sendChat(t){if(!this.isJoined())return"closed";const s=Wt(this.identity.nick,t,A(8),this.runtime.now());if(!s.text)return"empty";const n=this.presence.supports(O);this.pushLine(x({id:s.id,fromId:this.selfId,nick:this.identity.nick,text:s.text,ts:s.ts,self:!0,...n?{delivery:"pending"}:{}}));try{await this.transport.send(s)}catch{return this.patchDelivery(s.id,"failed"),this.pushSystem(p.sendFailed),"failed"}return n&&this.outbound.expect(s.id),"sent"}sendTyping(){this.sendTypingThrottled()}setNick(t){this.identity={...this.identity,nick:t},this.isJoined()&&this.transport.send(k(t))}async leave(t={}){this.life=y(this.life,{type:"leave"}),this.unbindVisibility?.(),this.unbindVisibility=null,this.stopTimers(),this.outbound.clear(),this.presence.clear(),await this.transport.leave(),this.transcript.clear(),this.life=y(this.life,{type:"left"}),t.silent||(this.setStatus({phase:"idle",detail:p.left,relays:[],peerCount:0}),this.emitMembers(),this.listeners.onReset?.([]))}handlePayload(t,s){const n=qt(s);if(n){if(n.type==="hello"?this.presence.upsert(t,n.nick,n.features):this.presence.upsert(t,n.nick),n.type==="hello"){this.emitMembers();return}if(n.type==="typing"){this.presence.markTyping(t),this.emitMembers();return}if(n.type==="ack"){this.outbound.ack(n.id)&&this.patchDelivery(n.id,"acked");return}if(this.transcript.has(n.id)){this.transport.send(U(this.identity.nick,n.id),t);return}this.pushLine(x({id:n.id,fromId:t,nick:n.nick,text:n.text,ts:n.ts,self:!1})),this.transport.send(U(this.identity.nick,n.id),t),this.emitMembers()}}expireAck(t){this.isJoined()&&this.patchDelivery(t,"failed")}patchDelivery(t,s){const n=this.transcript.patchDelivery(t,s);n&&this.listeners.onLine?.(n)}async measure(t){if(this.isJoined())try{const s=await this.transport.ping(t);this.isJoined()&&this.presence.setRtt(t,s)&&this.emitMembers()}catch{}}pushSystem(t){this.pushLine(ae(A(6),t,this.runtime.now()))}pushLine(t){const s=this.transcript.append(t);s&&this.listeners.onLine?.(s)}notePeers(){this.life=y(this.life,{type:"peers",count:this.transport.peerIds().length}),this.emitStatus()}emitStatus(){const t=this.transport.peerIds().length,s=this.transport.relays();this.life=y(this.life,{type:"relays",down:s.length>0&&s.every(o=>o.readyState!==1)});const n=Qt(this.life);if(this.life.state==="live"){this.setStatus({phase:n,detail:te(t),relays:s,peerCount:t});return}if(this.life.state==="failed")return;const i=ie(this.strategy,this.life.state==="relay_down"&&s.length===0?[{url:"local",readyState:3}]:s);this.setStatus({phase:i.phase,detail:this.life.state==="joining"?W(this.strategy):i.detail,relays:s,peerCount:t})}refreshStatus(t,s){this.setStatus({phase:t,detail:s,relays:this.transport.relays(),peerCount:this.transport.peerIds().length})}setStatus(t){const s=this.status;s.phase===t.phase&&s.detail===t.detail&&s.peerCount===t.peerCount&&s.relays.length===t.relays.length&&s.relays.every((n,i)=>n.readyState===t.relays[i]?.readyState)||(this.status=t,this.listeners.onStatus?.(t))}onVisibility=()=>{if(this.runtime.hidden()){this.stopTimers();return}this.isJoined()&&(this.startTimers(),this.transport.send(k(this.identity.nick)),this.emitStatus())};bindVisibility(){this.unbindVisibility?.(),this.unbindVisibility=this.runtime.onVisibilityChange(this.onVisibility)}startTimers(){this.stopTimers(),this.helloTimer=this.runtime.setInterval(()=>{this.transport.send(k(this.identity.nick))},Rt),this.relayTimer=this.runtime.setInterval(()=>{this.emitStatus();for(const t of this.transport.peerIds())this.measure(t)},At)}stopTimers(){this.helloTimer!==null&&(this.runtime.clearInterval(this.helloTimer),this.helloTimer=null),this.relayTimer!==null&&(this.runtime.clearInterval(this.relayTimer),this.relayTimer=null)}}class we{identity;listeners;options;session=null;spec=null;generation=0;constructor(t,s,n={}){this.identity=t,this.listeners=s,this.options=n}current(){return this.spec}getSession(){return this.session}selfId(){return this.session?.selfId??this.identity.id}sendChat(t){return this.session?.sendChat(t)??Promise.resolve("closed")}sendTyping(){this.session?.sendTyping()}retry(){const t=this.spec;return t?this.open(t,!0):Promise.resolve(null)}setIdentity(t){this.identity=t,this.session?.setNick(t.nick)}async open(t,s=!1){if(!s&&this.spec&&this.session?.isJoined()&&Mt(this.spec,t))return this.session;const n=++this.generation;if(await this.snapshotAndClose(),n!==this.generation)return null;Jt(t),this.listeners.onMembers?.([]);const i=new be(this.identity,this.listeners,this.options),o=$t(t);o.length>0?i.hydrate(o):this.listeners.onReset?.([]),this.session=i,this.spec=t;try{await i.join(t)}catch(c){if(n!==this.generation)return await i.leave({silent:!0}),null;throw c}return n!==this.generation?(await i.leave({silent:!0}),null):i}async close(){this.generation+=1,await this.snapshotAndClose()}snapshot(){this.session&&this.spec&&K(this.spec,this.session.getLines())}async snapshotAndClose(){this.session&&this.spec&&(K(this.spec,this.session.getLines()),await this.session.leave({silent:!0})),this.session=null,this.spec=null}}function X(e,t){return e.get(t)??""}function ve(e){const t=e.startsWith("#")?e.slice(1):e,s=new URL(t||"/","https://zenchat.local"),n=s.pathname.split("/").filter(Boolean);if(n[0]==="r"&&n[1]){const i=g(decodeURIComponent(n[1]));if(!i)return{name:"lobby"};const o=X(s.searchParams,"s"),c=jt(o)?o:"torrent";return{name:"room",spec:{name:i,password:X(s.searchParams,"k"),strategy:c}}}return{name:"lobby"}}function L(e,t=!1){if(e.name==="lobby")return"#/";const s=new URLSearchParams;s.set("s",e.spec.strategy),t&&e.spec.password&&s.set("k",e.spec.password);const n=s.toString();return`#/r/${encodeURIComponent(e.spec.name)}${n?`?${n}`:""}`}function ke(e){if(typeof BroadcastChannel>"u")return()=>{};const t=new BroadcastChannel("zenchat.tab"),s=n=>{n.data==="ping"&&t.postMessage("pong"),n.data==="pong"&&e()};return t.addEventListener("message",s),t.postMessage("ping"),()=>{t.removeEventListener("message",s),t.close()}}const a={title:"禅聊",tagline:"无服务器 · 浏览器直连",source:"源码",nick:"昵称",room:"房间",password:"口令",passwordHint:"口令只隔离房间,存在这个标签页,不会上传。",strategy:"信令",torrent:"Tracker",nostr:"Nostr",join:"加入",more:"更多选项",lobbyHint:"同一房间即可直连,无需账号。",tipSame:"两个窗口进同一个房间就能互发。",tipHttps:"请用 HTTPS 或 localhost。",tipNat:"对称 NAT 可能连不上(静态站没有 TURN)。",recent:"最近",share:"分享",shareWithKey:"含口令",leave:"大厅",send:"发送",placeholder:"发消息",members:"在线",you:"你",waiting:"还没有同伴,把链接发给对方。",copied:"已复制",shared:"已分享",copyFailed:"无法复制,请手动复制地址栏。",webrtcMissing:"需要支持 WebRTC 的浏览器,并用 HTTPS 打开。",insecure:"需要 HTTPS 或 localhost。",switch:"切换房间",addRoom:"加入房间",stamps:"表情",kaomoji:"颜文字",emoji:"表情",people:"成员",cancel:"取消",retry:"重试",switchSignal:"换信令",locked:"口令",duplicateTab:"另一个标签页也开着禅聊,会显示成两个人。",theme:"主题",delivered:"送达",undelivered:"未送达"};function r(e,t={},s=[]){const n=document.createElement(e);for(const[i,o]of Object.entries(t))if(!(o===void 0||o===!1)){if(i==="class"){n.className=String(o);continue}if(typeof o=="boolean"){n.toggleAttribute(i,o);continue}if(i==="style"){n.setAttribute("style",String(o));continue}if(i in n&&i!=="list"){n[i]=o;continue}n.setAttribute(i,String(o))}for(const i of s)n.append(typeof i=="string"?document.createTextNode(i):i);return n}function $(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function Ee(e){return new Intl.DateTimeFormat("zh-CN",{hour:"2-digit",minute:"2-digit",hour12:!1}).format(e)}function Se(e){switch(e){case WebSocket.CONNECTING:return"连接中";case WebSocket.OPEN:return"已连接";case WebSocket.CLOSING:return"断开中";default:return"未连接"}}function Te(e){try{return new URL(e).host}catch{return e}}function ft(){return typeof RTCPeerConnection=="function"}function Z(){return window.isSecureContext&&ft()}function Le(){return window.isSecureContext?ft()?r("div"):r("div",{class:"banner"},[a.webrtcMissing]):r("div",{class:"banner"},[a.insecure])}const yt=["(´・ω・`)","(。•́︿•̀。)","(´▽`ʃ♡ƪ)","(╯°□°)╯︵ ┻━┻","¯\\_(ツ)_/¯","(づ。◕‿‿◕。)づ","(ง'̀-'́)ง","( ̄▽ ̄)","(•̀ᴗ•́)و","ฅ^•ﻌ•^ฅ","(´;ω;`)","(≧∇≦)ノ","(´∀`)♡","┐( ̄ヘ ̄)┌"],gt=["😀","😂","🥰","😎","🤔","😭","🙏","✨","🔥","🎉","👍","👋","❤️","🌸","🍵","🌙"],Ce=new Set(yt),Ie=new Set(gt);function Pe(e){const t=e.trim();return t?Ce.has(t)||Ie.has(t)?!0:t.length<=8&&/^[\p{Emoji_Presentation}\s]+$/u.test(t):!1}function Q(e,t){const s=e.selectionStart,n=e.selectionEnd,i=`${e.value.slice(0,s)}${t}${e.value.slice(n)}`;e.value=i;const o=s+t.length;e.setSelectionRange(o,o),e.focus(),e.dispatchEvent(new Event("input",{bubbles:!0}))}class Re{el;ids=new Set;nodes=new Map;constructor(){this.el=r("div",{class:"log",role:"log","aria-live":"polite"})}get size(){return this.ids.size}reset(t){this.el.replaceChildren(),this.ids.clear(),this.nodes.clear();for(const s of t)this.append(s,!1);this.stick()}append(t,s=!0){if(this.ids.has(t.id)){this.replace(t);return}this.ids.add(t.id);const n=tt(t);this.nodes.set(t.id,n),this.el.append(n),s&&this.stick()}replace(t){const s=this.nodes.get(t.id);if(!s)return;const n=tt(t);this.nodes.set(t.id,n),s.replaceWith(n)}stick(){const t=this.el;requestAnimationFrame(()=>{t.scrollHeight-t.scrollTop-t.clientHeight<96&&(t.scrollTop=t.scrollHeight)})}}function tt(e){if(e.kind==="system")return r("div",{class:"system","data-id":e.id},[e.text]);const t=Pe(e.text),s=e.self&&e.delivery==="acked"?r("span",{class:"delivery"},[a.delivered]):e.self&&e.delivery==="failed"?r("span",{class:"delivery fail"},[a.undelivered]):null,n=r("header",{},[r("b",{style:`color:${ot(e.fromId)}`},[e.nick]),r("time",{},[Ee(e.ts)]),...s?[s]:[]]);return r("article",{class:`bubble${e.self?" self":""}${t?" stamp":""}${e.delivery==="failed"?" failed":""}`,"data-id":e.id},[n,r("p",{},[e.text])])}class Ae{el;open=!1;constructor(t){const s=r("div",{class:"picker-grid kaomoji"},yt.map(c=>et(c,()=>Q(t,c)))),n=r("div",{class:"picker-grid emoji hidden"},gt.map(c=>et(c,()=>Q(t,c)))),i=r("button",{class:"tab on",type:"button"},[a.kaomoji]),o=r("button",{class:"tab",type:"button"},[a.emoji]);i.addEventListener("click",()=>{s.classList.remove("hidden"),n.classList.add("hidden"),i.classList.add("on"),o.classList.remove("on")}),o.addEventListener("click",()=>{n.classList.remove("hidden"),s.classList.add("hidden"),o.classList.add("on"),i.classList.remove("on")}),this.el=r("div",{class:"picker hidden",role:"dialog","aria-label":a.stamps},[r("div",{class:"picker-tabs"},[i,o]),s,n])}toggle(){this.open=!this.open,this.el.classList.toggle("hidden",!this.open)}hide(){this.open=!1,this.el.classList.add("hidden")}}function et(e,t){const s=r("button",{class:"stamp-btn",type:"button"},[e]);return s.addEventListener("click",t),s}class Ne{constructor(t){this.handlers=t,this.statusEl=r("div",{class:"status"}),this.actionsEl=r("div",{class:"status-actions"}),this.membersEl=r("div",{class:"members"}),this.vacantEl=r("div",{class:"vacant"},[a.waiting]),this.peopleBtn=r("button",{class:"btn ghost people",type:"button","aria-expanded":"false"},[a.people]),this.peopleBtn.addEventListener("click",l=>{l.stopPropagation(),this.root.classList.toggle("show-side"),this.peopleBtn.setAttribute("aria-expanded",this.root.classList.contains("show-side")?"true":"false")});const s=r("textarea",{rows:1,placeholder:a.placeholder,maxlength:4e3,enterkeyhint:"send","aria-label":a.placeholder});s.setAttribute("enterkeyhint","send"),s.setAttribute("autocapitalize","sentences");const n=new Ae(s),i=r("button",{class:"btn ghost",type:"button",title:a.stamps},["顔"]),o=r("button",{class:"btn primary send",type:"button","aria-label":a.send},["↑"]);i.addEventListener("click",l=>{l.stopPropagation(),n.toggle()}),this.peopleBtn.addEventListener("pointerdown",l=>l.stopPropagation()),document.addEventListener("pointerdown",l=>{this.root.contains(l.target)&&(l.target.closest(".picker, .people, .side")||n.hide())}),n.el.addEventListener("click",l=>l.stopPropagation());const c=()=>{const l=s.value;l.trim()&&(s.value="",s.style.height="auto",n.hide(),this.handlers.send(l).then(m=>{(m==="failed"||m==="closed")&&(s.value=l)}))};o.addEventListener("click",c),s.addEventListener("keydown",l=>{l.key==="Enter"&&!l.shiftKey&&(l.preventDefault(),c())}),s.addEventListener("input",()=>{s.style.height="auto",s.style.height=`${Math.min(s.scrollHeight,96)}px`,this.handlers.typing()}),this.nickInput=r("input",{class:"nick-edit",maxlength:24,value:t.nick,"aria-label":a.nick});const h=()=>this.handlers.onNick(this.nickInput.value);this.nickInput.addEventListener("change",h),this.nickInput.addEventListener("keydown",l=>{l.key==="Enter"&&(l.preventDefault(),this.nickInput.blur())}),this.selfIdEl=r("span",{class:"muted"},[t.selfId.slice(0,8)]),this.selfRow=r("div",{class:"member self"},[r("label",{class:"nick-label"},[r("span",{},[a.you]),this.nickInput]),this.selfIdEl]),this.membersEl.append(this.selfRow),this.root=r("div",{class:"chat"},[r("section",{class:"transcript"},[r("div",{class:"status-row"},[this.statusEl,this.actionsEl,this.peopleBtn]),this.vacantEl,this.log.el,n.el,r("div",{class:"composer"},[i,s,o])]),r("aside",{class:"side"},[r("h2",{},[a.members]),this.membersEl])])}handlers;root;log=new Re;statusEl;actionsEl;membersEl;selfRow;selfIdEl;vacantEl;peopleBtn;nickInput;lastStatus="";lastPeerCount=0;hide(){this.root.hidden=!0,this.root.classList.remove("show-side"),this.peopleBtn.setAttribute("aria-expanded","false")}show(){this.root.hidden=!1}push(t){this.log.append(t),this.vacantEl.hidden=!0}reset(t){this.log.reset(t),this.vacantEl.hidden=t.length>0||this.lastPeerCount>0}setNick(t){document.activeElement!==this.nickInput&&(this.nickInput.value=t)}status(t){const s=t.relays.filter(o=>o.readyState===WebSocket.OPEN).length,n=`${t.phase}|${t.detail}|${t.peerCount}|${s}|${this.log.size}`;if(n===this.lastStatus)return;this.lastStatus=n,this.lastPeerCount=t.peerCount;const i=t.phase==="connected"?" ok":t.phase==="error"?" err":"";this.statusEl.replaceChildren(r("i",{class:`dot${i}`}),r("span",{},[` ${t.detail}`])),this.peopleBtn.textContent=`${a.people} ${t.peerCount}`,this.statusEl.title=t.relays.map(o=>`${Te(o.url)} ${Se(o.readyState)}`).join(`
+`),this.renderActions(t),this.vacantEl.hidden=t.peerCount>0||this.log.size>0}members(t,s){for(this.selfIdEl.textContent=s.slice(0,8);this.membersEl.lastChild&&this.membersEl.lastChild!==this.selfRow;)this.membersEl.removeChild(this.membersEl.lastChild);for(const n of t)this.membersEl.append(r("div",{class:"member"},[r("b",{style:`color:${ot(n.id)}`},[n.nick]),r("span",{class:"muted"},[n.typing?"输入中":n.rttMs!==null?`${Math.round(n.rttMs)}ms`:""])]))}renderActions(t){if($(this.actionsEl),t.phase==="error"){this.actionsEl.append(I(a.retry,this.handlers.onRetry),I(a.switchSignal,this.handlers.onSwitchSignal));return}t.phase==="connecting"&&t.peerCount===0&&this.actionsEl.append(I(a.share,this.handlers.onShare))}}function I(e,t){const s=r("button",{class:"btn ghost compact",type:"button"},[e]);return s.addEventListener("click",n=>{n.stopPropagation(),t()}),s}function _e(e){const t=r("input",{placeholder:a.room,maxlength:64,autocomplete:"off"}),s=r("input",{placeholder:a.password,type:"password",autocomplete:"off"}),n=r("select",{},[r("option",{value:"torrent"},[a.torrent]),r("option",{value:"nostr"},[a.nostr])]),i=r("form",{class:"jump-form",method:"dialog"},[r("h2",{},[a.switch]),t,s,n,r("div",{class:"actions"},[r("button",{class:"btn primary",type:"submit"},[a.join]),r("button",{class:"btn ghost",type:"button",value:"cancel"},[a.cancel])])]),o=r("dialog",{class:"jump"},[i]);return i.addEventListener("submit",c=>{c.preventDefault(),o.close(),e({name:t.value,password:s.value,strategy:n.value})}),i.querySelector('button[value="cancel"]')?.addEventListener("click",()=>o.close()),{el:o,open(c){t.value=c?.name??"",s.value=c?.password??"",n.value=c?.strategy??"torrent",o.showModal(),c?.needPassword?s.focus():t.focus()},close:()=>o.close()}}function je(e){const t=r("input",{maxlength:24,value:e.nick,autocomplete:"nickname"}),s=r("input",{maxlength:64,value:Ct,autocomplete:"off"}),n=r("input",{type:"password",autocomplete:"off"}),i=r("select",{},[r("option",{value:"torrent"},[a.torrent]),r("option",{value:"nostr"},[a.nostr])]),o=r("button",{class:"btn primary join-btn",type:"submit"},[a.join]);Z()||(o.disabled=!0);const c=r("form",{class:"panel form lobby-form"},[r("div",{class:"join-row"},[S(a.nick,t),S(a.room,s),o]),r("details",{class:"more"},[r("summary",{},[a.more]),r("div",{class:"row"},[S(a.password,n),S(a.strategy,i)]),r("span",{class:"hint"},[a.passwordHint])])]);return c.addEventListener("submit",h=>{h.preventDefault(),Z()&&e.onJoin({nick:t.value,spec:{name:s.value,password:n.value,strategy:i.value}})}),r("div",{class:"lobby"},[r("p",{class:"lede"},[a.lobbyHint]),r("ul",{class:"tips"},[r("li",{},[a.tipSame]),r("li",{},[a.tipHttps]),r("li",{},[a.tipNat])]),c])}function S(e,t){return r("label",{class:"field"},[r("span",{},[e]),t])}function xe(e,t,s,n=!1){return`${e}${t}${L({name:"room",spec:s},n)}`}async function Me(e,t="禅聊"){try{if(typeof navigator.share=="function")return await navigator.share({title:t,url:e}),"shared"}catch(s){if(s instanceof Error&&s.name==="AbortError")return"failed"}try{return await navigator.clipboard.writeText(e),"copied"}catch{return"failed"}}function Oe(e,t,s){$(e);for(const i of pt()){const o=!!(t&&i.name===g(t.name)&&i.strategy===t.strategy),c=i.hasPassword?[i.name,r("span",{class:"lock",title:a.locked},["锁"])]:[i.name],h=r("a",{class:`tab${o?" on":""}`,href:L({name:"room",spec:{name:i.name,password:"",strategy:i.strategy}})},c);h.addEventListener("click",l=>{l.preventDefault();const m=Ht(i,t);if(m==="need-password"){s.onNeedPassword(i);return}s.onGo(m)}),e.append(h)}const n=r("button",{class:"tab add",type:"button",title:a.addRoom},["+"]);n.addEventListener("click",()=>s.onAdd()),e.append(n)}function $e(e,t,s,n){if($(e),e.append(t),!s)return;const i=r("button",{class:"btn ghost",type:"button"},[a.share]);if(i.addEventListener("click",()=>n(s,!1,i)),e.append(i),s.password){const o=r("button",{class:"btn ghost",type:"button"},[a.shareWithKey]);o.addEventListener("click",()=>n(s,!0,o)),e.append(o)}e.append(r("a",{class:"btn ghost",href:"#/"},[a.leave]))}async function st(e,t,s){const n=xe(location.origin,location.pathname,e,t),i=await Me(n);if(!s)return;const o=s.textContent;s.textContent=i==="shared"?a.shared:i==="copied"?a.copied:a.copyFailed,window.setTimeout(()=>{o&&(s.textContent=o)},1400)}const bt="zenchat.theme";function De(){const e=localStorage.getItem(bt);return e==="light"||e==="dark"||e==="system"?e:"system"}function Be(e){localStorage.setItem(bt,e)}function Je(e){return e==="system"?"light":e==="light"?"dark":"system"}function He(){return window.matchMedia("(prefers-color-scheme: dark)").matches}function Ke(e,t=He()){return e==="system"?t?"dark":"light":e}function P(e){const t=Ke(e);return document.documentElement.dataset.theme=t,document.documentElement.dataset.themePref=e,document.documentElement.style.colorScheme=t,t}function nt(e){return e==="light"?"白天":e==="dark"?"黑夜":"跟随系统"}class Fe{root;theme;identity=ct();manager;themeBtn;tabsEl;toolsEl;main;jump;footerEl;dupBanner;chat=null;lobbyEl=null;routing=!1;stopTabWatch=null;constructor(t){this.root=t,this.theme=De(),P(this.theme),this.themeBtn=r("button",{class:"btn ghost",type:"button",title:a.theme},[nt(this.theme)]),this.tabsEl=r("nav",{class:"tabs","aria-label":a.recent}),this.toolsEl=r("div",{class:"toolbar"}),this.main=r("main",{class:"main"}),this.jump=_e(s=>this.go(s)),this.dupBanner=r("div",{class:"banner warn hidden"},[a.duplicateTab]),this.footerEl=r("p",{class:"footer"},[r("a",{href:_t,target:"_blank",rel:"noreferrer"},[a.source])," · AGPL-3.0"]),this.manager=new we(this.identity,{onStatus:s=>this.chat?.status(s),onMembers:s=>this.chat?.members(s,this.manager.selfId()),onLine:s=>this.chat?.push(s),onReset:s=>this.chat?.reset(s)})}start(){this.mount(),this.stopTabWatch=ke(()=>this.dupBanner.classList.remove("hidden")),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>P(this.theme)),window.addEventListener("hashchange",()=>{this.routing||this.route()}),window.addEventListener("pagehide",()=>{this.stopTabWatch?.(),this.manager.snapshot(),this.manager.close()}),window.addEventListener("pageshow",t=>{t.persisted&&this.route()}),window.addEventListener("keydown",t=>{(t.metaKey||t.ctrlKey)&&t.key.toLowerCase()==="k"&&(t.preventDefault(),this.jump.open()),t.key==="Escape"&&this.jump.close()}),this.themeBtn.addEventListener("click",()=>{this.theme=Je(this.theme),Be(this.theme),P(this.theme),this.themeBtn.textContent=nt(this.theme)}),this.route()}mount(){this.root.append(r("div",{class:"shell"},[r("header",{class:"topbar"},[r("a",{class:"brand",href:"#/"},[r("div",{class:"enso","aria-hidden":"true"}),r("strong",{},[a.title]),r("span",{class:"tagline"},[a.tagline])]),this.tabsEl,this.toolsEl]),Le(),this.dupBanner,this.main,this.footerEl]),this.jump.el)}async route(){const t=ve(location.hash);if(this.refreshChrome(t.name==="room"?V(t.spec):void 0),t.name==="lobby"){await this.manager.close(),this.showLobby();return}await this.showChat(this.stripPasswordFromHash(V(t.spec)))}stripPasswordFromHash(t){j(t);const s=L({name:"room",spec:t},!1);return t.password&&location.hash!==s&&(this.routing=!0,history.replaceState(null,"",`${location.pathname}${location.search}${s}`),this.routing=!1),t}go(t){j(t);const s=L({name:"room",spec:t},!1);if(location.hash===s){this.showChat(t);return}this.routing=!0,location.hash=s,this.routing=!1,this.showChat(t)}showLobby(){this.chat?.hide(),this.footerEl.hidden=!1,this.root.classList.remove("mode-chat");const t=je({nick:this.identity.nick,onJoin:({nick:s,spec:n})=>{this.identity={...this.identity,nick:H(s)},this.manager.setIdentity(this.identity),this.go(n)}});this.lobbyEl&&this.lobbyEl.replaceWith(t),this.lobbyEl=t,this.main.replaceChildren(this.lobbyEl),this.refreshChrome()}async showChat(t){this.chat?this.chat.setNick(this.identity.nick):this.chat=new Ne({selfId:this.identity.id,nick:this.identity.nick,send:s=>this.manager.sendChat(s),typing:()=>this.manager.sendTyping(),onRetry:()=>{this.manager.retry()},onShare:()=>{const s=this.manager.current()??t;st(s,!1)},onSwitchSignal:()=>{const s=this.manager.current()??t;this.go({...s,strategy:s.strategy==="torrent"?"nostr":"torrent"})},onNick:s=>{this.identity={...this.identity,nick:H(s)},this.manager.setIdentity(this.identity),this.chat?.setNick(this.identity.nick)}}),this.lobbyEl=null,this.footerEl.hidden=!0,this.root.classList.add("mode-chat"),this.main.replaceChildren(this.chat.root),this.chat.show(),this.refreshChrome(t);try{await this.manager.open(t)}catch(s){const n=s instanceof Error?s.message:"连接失败";this.chat.status({phase:"error",detail:`无法启动 P2P:${n}`,relays:[],peerCount:0})}}refreshChrome(t){Oe(this.tabsEl,t,{onGo:s=>this.go(s),onNeedPassword:s=>this.jump.open({name:s.name,strategy:s.strategy,needPassword:!0}),onAdd:()=>this.jump.open()}),$e(this.toolsEl,this.themeBtn,t??null,(s,n,i)=>{st(s,n,i)})}}function Ve(){const e=window.visualViewport,t=()=>{const s=e?.height??window.innerHeight;document.documentElement.style.setProperty("--app-h",`${Math.round(s)}px`)};t(),e?.addEventListener("resize",t,{passive:!0}),e?.addEventListener("scroll",t,{passive:!0}),window.addEventListener("orientationchange",t,{passive:!0})}const wt=document.querySelector("#app");if(!wt)throw new Error("缺少 #app 挂载点");Ve();ye();new Fe(wt).start();
diff --git a/index.html b/index.html
index 74d916a..50af4e6 100644
--- a/index.html
+++ b/index.html
@@ -19,8 +19,8 @@
禅聊 ZenChat
-
-
+
+
diff --git a/src/config/app.ts b/src/config/app.ts
index 39c3f7e..3017a8f 100644
--- a/src/config/app.ts
+++ b/src/config/app.ts
@@ -12,5 +12,6 @@ export const TYPING_TTL_MS = 2500
export const TYPING_THROTTLE_MS = 1800
export const HELLO_INTERVAL_MS = 25_000
export const RELAY_POLL_MS = 5000
+export const ACK_TIMEOUT_MS = 8000
export const SOURCE_URL = 'https://github.com/Andyccr/ZenChat'
export const PAGES_URL = 'https://andyccr.github.io/ZenChat/'
diff --git a/src/core/outbound.test.ts b/src/core/outbound.test.ts
new file mode 100644
index 0000000..a59095e
--- /dev/null
+++ b/src/core/outbound.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { Outbound } from './outbound'
+import { createMemoryRuntime } from './runtime'
+
+describe('Outbound', () => {
+ it('expires pending ids and ignores a late ack', () => {
+ const clock = createMemoryRuntime(0)
+ const expired: string[] = []
+ const outbound = new Outbound(clock.runtime, 100, (id) => expired.push(id))
+ outbound.expect('msg-1')
+ clock.advance(99)
+ expect(expired).toEqual([])
+ clock.advance(1)
+ expect(expired).toEqual(['msg-1'])
+ expect(outbound.ack('msg-1')).toBe(false)
+ })
+
+ it('clears a timer when the ack arrives first', () => {
+ const clock = createMemoryRuntime(0)
+ const expired: string[] = []
+ const outbound = new Outbound(clock.runtime, 100, (id) => expired.push(id))
+ outbound.expect('msg-2')
+ expect(outbound.ack('msg-2')).toBe(true)
+ clock.advance(100)
+ expect(expired).toEqual([])
+ })
+})
diff --git a/src/core/outbound.ts b/src/core/outbound.ts
new file mode 100644
index 0000000..95f06fb
--- /dev/null
+++ b/src/core/outbound.ts
@@ -0,0 +1,38 @@
+import type { Runtime } from './runtime'
+
+export class Outbound {
+ private timers = new Map()
+
+ constructor(
+ private runtime: Pick,
+ private timeoutMs: number,
+ private onExpire: (id: string) => void,
+ ) {}
+
+ expect(id: string): void {
+ this.cancel(id)
+ this.timers.set(
+ id,
+ this.runtime.setTimeout(() => {
+ this.timers.delete(id)
+ this.onExpire(id)
+ }, this.timeoutMs),
+ )
+ }
+
+ ack(id: string): boolean {
+ if (!this.timers.has(id)) return false
+ this.cancel(id)
+ return true
+ }
+
+ cancel(id: string): void {
+ const timer = this.timers.get(id)
+ if (timer !== undefined) this.runtime.clearTimeout(timer)
+ this.timers.delete(id)
+ }
+
+ clear(): void {
+ for (const id of [...this.timers.keys()]) this.cancel(id)
+ }
+}
diff --git a/src/core/presence.ts b/src/core/presence.ts
index aef1dc2..3fd0810 100644
--- a/src/core/presence.ts
+++ b/src/core/presence.ts
@@ -12,7 +12,7 @@ export class Presence {
private typingTtl = TYPING_TTL_MS,
) {}
- upsert(id: string, nick: string): Member {
+ upsert(id: string, nick: string, features?: string[]): Member {
const existing = this.members.get(id)
const now = this.runtime.now()
const member: Member = {
@@ -22,11 +22,16 @@ export class Presence {
lastSeenAt: now,
rttMs: existing?.rttMs ?? null,
typing: existing?.typing ?? false,
+ features: features ?? existing?.features ?? [],
}
this.members.set(id, member)
return member
}
+ supports(feature: string): boolean {
+ return this.list().some((member) => member.features.includes(feature))
+ }
+
remove(id: string): Member | undefined {
this.clearTypingTimer(id)
const member = this.members.get(id)
diff --git a/src/core/protocol.test.ts b/src/core/protocol.test.ts
index eaad8e7..b6361c3 100644
--- a/src/core/protocol.test.ts
+++ b/src/core/protocol.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { createChatPayload, parsePayload } from './protocol'
+import { createAckPayload, createChatPayload, createHelloPayload, parsePayload } from './protocol'
describe('parsePayload', () => {
it('accepts a well-formed chat message', () => {
@@ -30,4 +30,21 @@ describe('parsePayload', () => {
nick: '听雨',
})
})
+
+ it('reads hello features and ack ids, ignoring unknown types', () => {
+ expect(parsePayload(createHelloPayload('晚风'))).toEqual({
+ v: 1,
+ type: 'hello',
+ nick: '晚风',
+ features: ['ack'],
+ })
+ expect(parsePayload({ v: 1, type: 'hello', nick: '晚风' })).toMatchObject({ features: [] })
+ expect(parsePayload(createAckPayload('晚风', 'aabbccdd12345678'))).toEqual({
+ v: 1,
+ type: 'ack',
+ id: 'aabbccdd12345678',
+ nick: '晚风',
+ })
+ expect(parsePayload({ v: 1, type: 'leave', nick: '晚风' })).toBeNull()
+ })
})
diff --git a/src/core/protocol.ts b/src/core/protocol.ts
index 9e92719..3cce331 100644
--- a/src/core/protocol.ts
+++ b/src/core/protocol.ts
@@ -1,10 +1,13 @@
import { MAX_MESSAGE_LENGTH, PROTOCOL_VERSION } from '../config/app'
import { sanitizeNick } from './identity'
+export const FEATURE_ACK = 'ack'
+
export type HelloPayload = {
v: typeof PROTOCOL_VERSION
type: 'hello'
nick: string
+ features: string[]
}
export type ChatPayload = {
@@ -22,7 +25,14 @@ export type TypingPayload = {
nick: string
}
-export type WirePayload = HelloPayload | ChatPayload | TypingPayload
+export type AckPayload = {
+ v: typeof PROTOCOL_VERSION
+ type: 'ack'
+ id: string
+ nick: string
+}
+
+export type WirePayload = HelloPayload | ChatPayload | TypingPayload | AckPayload
function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null
@@ -32,6 +42,16 @@ function isVersion(value: unknown): value is typeof PROTOCOL_VERSION {
return value === PROTOCOL_VERSION
}
+function readFeatures(value: unknown): string[] {
+ if (!Array.isArray(value)) return []
+ return [...new Set(value.filter((item): item is string => item === FEATURE_ACK))]
+}
+
+function readId(value: unknown): string | null {
+ if (typeof value !== 'string' || value.length < 8 || value.length > 80) return null
+ return value
+}
+
export function createChatPayload(nick: string, text: string, id: string, ts: number): ChatPayload {
return {
v: PROTOCOL_VERSION,
@@ -44,13 +64,17 @@ export function createChatPayload(nick: string, text: string, id: string, ts: nu
}
export function createHelloPayload(nick: string): HelloPayload {
- return { v: PROTOCOL_VERSION, type: 'hello', nick: sanitizeNick(nick) }
+ return { v: PROTOCOL_VERSION, type: 'hello', nick: sanitizeNick(nick), features: [FEATURE_ACK] }
}
export function createTypingPayload(nick: string): TypingPayload {
return { v: PROTOCOL_VERSION, type: 'typing', nick: sanitizeNick(nick) }
}
+export function createAckPayload(nick: string, id: string): AckPayload {
+ return { v: PROTOCOL_VERSION, type: 'ack', id, nick: sanitizeNick(nick) }
+}
+
export function parsePayload(value: unknown): WirePayload | null {
if (!isRecord(value) || !isVersion(value.v) || typeof value.type !== 'string') {
return null
@@ -60,17 +84,22 @@ export function parsePayload(value: unknown): WirePayload | null {
if (!nick) return null
if (value.type === 'hello') {
- return { v: PROTOCOL_VERSION, type: 'hello', nick }
+ return { v: PROTOCOL_VERSION, type: 'hello', nick, features: readFeatures(value.features) }
}
if (value.type === 'typing') {
return { v: PROTOCOL_VERSION, type: 'typing', nick }
}
+ if (value.type === 'ack') {
+ const id = readId(value.id)
+ if (!id) return null
+ return { v: PROTOCOL_VERSION, type: 'ack', id, nick }
+ }
+
if (value.type === 'chat') {
- if (typeof value.id !== 'string' || value.id.length < 8 || value.id.length > 80) {
- return null
- }
+ const id = readId(value.id)
+ if (!id) return null
if (typeof value.ts !== 'number' || !Number.isFinite(value.ts)) {
return null
}
@@ -79,7 +108,7 @@ export function parsePayload(value: unknown): WirePayload | null {
}
const text = value.text.trim().slice(0, MAX_MESSAGE_LENGTH)
if (!text) return null
- return { v: PROTOCOL_VERSION, type: 'chat', id: value.id, ts: value.ts, nick, text }
+ return { v: PROTOCOL_VERSION, type: 'chat', id, ts: value.ts, nick, text }
}
return null
diff --git a/src/core/room-manager.test.ts b/src/core/room-manager.test.ts
index b90715f..1b9e2b9 100644
--- a/src/core/room-manager.test.ts
+++ b/src/core/room-manager.test.ts
@@ -50,6 +50,11 @@ describe('RoomManager', () => {
expect(rm.getSession()?.isJoined()).toBe(true)
})
+ it('returns closed when sending without a live session', async () => {
+ const { rm } = manager()
+ expect(await rm.sendChat('hi')).toBe('closed')
+ })
+
it('retries after a failed join instead of sticking on the dead session', async () => {
const clock = createMemoryRuntime()
let fail = true
diff --git a/src/core/room-manager.ts b/src/core/room-manager.ts
index 5bdb84e..42bcafd 100644
--- a/src/core/room-manager.ts
+++ b/src/core/room-manager.ts
@@ -2,7 +2,7 @@ import { loadRoomLog, saveRoomLog } from './cache'
import { rememberRoom } from './recent'
import { sameRoom } from './room'
import { ChatSession, type SessionListener, type SessionOptions } from './session'
-import type { Identity, RoomSpec } from './types'
+import type { Identity, RoomSpec, SendResult } from './types'
export class RoomManager {
private identity: Identity
@@ -26,6 +26,24 @@ export class RoomManager {
return this.session
}
+ selfId(): string {
+ return this.session?.selfId ?? this.identity.id
+ }
+
+ sendChat(text: string): Promise {
+ return this.session?.sendChat(text) ?? Promise.resolve('closed')
+ }
+
+ sendTyping(): void {
+ this.session?.sendTyping()
+ }
+
+ retry(): Promise {
+ const spec = this.spec
+ if (!spec) return Promise.resolve(null)
+ return this.open(spec, true)
+ }
+
setIdentity(identity: Identity): void {
this.identity = identity
this.session?.setNick(identity.nick)
diff --git a/src/core/session-machine.test.ts b/src/core/session-machine.test.ts
new file mode 100644
index 0000000..f802941
--- /dev/null
+++ b/src/core/session-machine.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { idleLife, isJoined, reduceLife, uiPhase } from './session-machine'
+
+describe('session machine', () => {
+ it('joins to waiting, then live, then waiting when the last peer leaves', () => {
+ let life = reduceLife(idleLife(), { type: 'join' })
+ expect(life.state).toBe('joining')
+ expect(uiPhase(life)).toBe('connecting')
+ life = reduceLife(life, { type: 'join_ok' })
+ expect(life.state).toBe('waiting')
+ life = reduceLife(life, { type: 'peers', count: 1 })
+ expect(life.state).toBe('live')
+ expect(uiPhase(life)).toBe('connected')
+ life = reduceLife(life, { type: 'peers', count: 0 })
+ expect(life.state).toBe('waiting')
+ })
+
+ it('marks failed joins as not joined and maps closed relays to error', () => {
+ let life = reduceLife(idleLife(), { type: 'join' })
+ life = reduceLife(life, { type: 'join_err' })
+ expect(life.state).toBe('failed')
+ expect(isJoined(life)).toBe(false)
+ expect(uiPhase(life)).toBe('error')
+
+ life = reduceLife(idleLife(), { type: 'join' })
+ life = reduceLife(life, { type: 'join_ok' })
+ life = reduceLife(life, { type: 'relays', down: true })
+ expect(life.state).toBe('relay_down')
+ expect(uiPhase(life)).toBe('error')
+ })
+})
diff --git a/src/core/session-machine.ts b/src/core/session-machine.ts
new file mode 100644
index 0000000..3cefa5e
--- /dev/null
+++ b/src/core/session-machine.ts
@@ -0,0 +1,71 @@
+import type { SessionStatus } from './types'
+
+export type LifeState = 'idle' | 'joining' | 'waiting' | 'live' | 'relay_down' | 'failed' | 'leaving'
+
+export type Life = {
+ state: LifeState
+ peers: number
+ relaysDown: boolean
+}
+
+export type LifeEvent =
+ | { type: 'join' }
+ | { type: 'join_ok' }
+ | { type: 'join_err' }
+ | { type: 'peers'; count: number }
+ | { type: 'relays'; down: boolean }
+ | { type: 'force_down' }
+ | { type: 'leave' }
+ | { type: 'left' }
+
+export function idleLife(): Life {
+ return { state: 'idle', peers: 0, relaysDown: false }
+}
+
+export function isJoined(life: Life): boolean {
+ return life.state === 'joining' || life.state === 'waiting' || life.state === 'live' || life.state === 'relay_down'
+}
+
+function settle(life: Life): Life {
+ if (life.state === 'idle' || life.state === 'failed' || life.state === 'joining' || life.state === 'leaving') {
+ return life
+ }
+ if (life.peers > 0) return { ...life, state: 'live' }
+ if (life.relaysDown) return { ...life, state: 'relay_down' }
+ return { ...life, state: 'waiting' }
+}
+
+export function reduceLife(life: Life, event: LifeEvent): Life {
+ switch (event.type) {
+ case 'join':
+ if (life.state === 'leaving') return life
+ return { state: 'joining', peers: 0, relaysDown: false }
+ case 'join_ok':
+ if (life.state !== 'joining') return life
+ return settle({ ...life, state: 'waiting' })
+ case 'join_err':
+ if (life.state !== 'joining') return life
+ return { state: 'failed', peers: 0, relaysDown: false }
+ case 'peers':
+ if (!isJoined(life)) return life
+ return settle({ ...life, peers: event.count })
+ case 'relays':
+ if (!isJoined(life)) return life
+ return settle({ ...life, relaysDown: event.down })
+ case 'force_down':
+ if (!isJoined(life) || life.peers > 0) return life
+ return { ...life, relaysDown: true, state: 'relay_down' }
+ case 'leave':
+ if (life.state === 'idle') return life
+ return { ...life, state: 'leaving' }
+ case 'left':
+ return idleLife()
+ }
+}
+
+export function uiPhase(life: Life): SessionStatus['phase'] {
+ if (life.state === 'live') return 'connected'
+ if (life.state === 'failed' || life.state === 'relay_down') return 'error'
+ if (life.state === 'idle') return 'idle'
+ return 'connecting'
+}
diff --git a/src/core/session.test.ts b/src/core/session.test.ts
index 8e85db5..f4880ea 100644
--- a/src/core/session.test.ts
+++ b/src/core/session.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { createTypingPayload } from './protocol'
+import { createAckPayload, createHelloPayload, createTypingPayload } from './protocol'
import { createMemoryRuntime } from './runtime'
import { ChatSession } from './session'
import { FakeTransport } from './transports/fake'
@@ -59,6 +59,27 @@ describe('ChatSession', () => {
expect(transport().sent.some((item) => (item.payload as { type: string }).type === 'chat')).toBe(true)
})
+ it('acks remote chat and waits for ack-capable peers', async () => {
+ const { session, clock, lines, transport } = harness()
+ await session.join(spec)
+ transport().payload('peer-b', createHelloPayload('青石'))
+ const payload = { v: 1, type: 'chat', id: 'aabbccdd12345678', ts: 1, nick: '青石', text: '在吗' }
+ transport().payload('peer-b', payload)
+ expect(transport().sent.some((item) => (item.payload as { type: string }).type === 'ack' && item.target === 'peer-b')).toBe(true)
+
+ await session.sendChat('回你')
+ const pending = [...lines].reverse().find((line) => line.kind === 'chat' && line.self)
+ expect(pending).toMatchObject({ delivery: 'pending' })
+ transport().payload('peer-b', createAckPayload('青石', pending && pending.kind === 'chat' ? pending.id : ''))
+ const acked = [...lines].reverse().find((line) => line.kind === 'chat' && line.self)
+ expect(acked).toMatchObject({ delivery: 'acked' })
+
+ await session.sendChat('第二句')
+ clock.advance(8000)
+ const failed = [...lines].reverse().find((line) => line.kind === 'chat' && line.self)
+ expect(failed).toMatchObject({ delivery: 'failed' })
+ })
+
it('accepts remote chat once and ignores duplicates', async () => {
const { session, lines, transport } = harness()
await session.join(spec)
diff --git a/src/core/session.ts b/src/core/session.ts
index 78d61cb..799a4a8 100644
--- a/src/core/session.ts
+++ b/src/core/session.ts
@@ -1,15 +1,19 @@
-import { APP_ID, HELLO_INTERVAL_MS, RELAY_POLL_MS, TYPING_THROTTLE_MS } from '../config/app'
+import { ACK_TIMEOUT_MS, APP_ID, HELLO_INTERVAL_MS, RELAY_POLL_MS, TYPING_THROTTLE_MS } from '../config/app'
import { randomHex } from './identity'
+import { Outbound } from './outbound'
import { Presence } from './presence'
import {
+ createAckPayload,
createChatPayload,
createHelloPayload,
createTypingPayload,
+ FEATURE_ACK,
parsePayload,
} from './protocol'
import { normalizeRoomName, roomNamespace } from './room'
import { browserRuntime, type Runtime } from './runtime'
import { rafBatch, throttle } from './scheduler'
+import { idleLife, isJoined, reduceLife, uiPhase, type Life } from './session-machine'
import {
connectedDetail,
connectingDetail,
@@ -42,10 +46,11 @@ export class ChatSession {
private runtime: Runtime
private transcript = new Transcript()
private presence: Presence
+ private outbound: Outbound
+ private life: Life = idleLife()
private helloTimer: number | null = null
private relayTimer: number | null = null
private unbindVisibility: (() => void) | null = null
- private joined = false
private strategy: SignalStrategy = 'torrent'
private joinErrors = 0
private status: SessionStatus = {
@@ -60,7 +65,7 @@ export class ChatSession {
})
private readonly sendTypingThrottled = throttle(() => {
- if (!this.joined) return
+ if (!this.isJoined()) return
void this.transport.send(createTypingPayload(this.identity.nick))
}, TYPING_THROTTLE_MS)
@@ -71,6 +76,7 @@ export class ChatSession {
this.runtime = options.runtime ?? browserRuntime
this.transport = this.createTransport('torrent')
this.presence = new Presence(this.runtime, () => this.emitMembers())
+ this.outbound = new Outbound(this.runtime, ACK_TIMEOUT_MS, (id) => this.expireAck(id))
}
get selfId(): string {
@@ -78,7 +84,7 @@ export class ChatSession {
}
isJoined(): boolean {
- return this.joined
+ return isJoined(this.life)
}
getLines(): ChatLine[] {
@@ -90,11 +96,11 @@ export class ChatSession {
}
async join(spec: RoomSpec): Promise {
- if (this.joined) await this.leave({ silent: true })
+ if (this.isJoined()) await this.leave({ silent: true })
this.strategy = spec.strategy
this.joinErrors = 0
this.transport = this.createTransport(spec.strategy)
- this.joined = true
+ this.life = reduceLife(this.life, { type: 'join' })
this.setStatus({
phase: 'connecting',
detail: connectingDetail(spec.strategy),
@@ -116,41 +122,45 @@ export class ChatSession {
this.pushSystem(`${peerId.slice(0, 6)} 加入了房间`)
void this.transport.send(createHelloPayload(this.identity.nick), peerId)
this.emitMembers()
- this.syncStatus()
+ this.notePeers()
this.runtime.setTimeout(() => void this.measure(peerId), 350)
},
onPeerLeave: (peerId) => {
const member = this.presence.remove(peerId)
this.pushSystem(`${member?.nick ?? peerId.slice(0, 6)} 离开了房间`)
this.emitMembers()
- this.syncStatus()
+ this.notePeers()
},
onPayload: (peerId, payload) => this.handlePayload(peerId, payload),
onJoinError: () => {
this.joinErrors += 1
if (this.transport.peerIds().length > 0) return
- if (this.joinErrors >= 3) this.syncStatus(true)
- else this.refreshStatus('connecting', STATUS_COPY.handshake)
+ if (this.joinErrors >= 3) {
+ this.life = reduceLife(this.life, { type: 'force_down' })
+ this.emitStatus()
+ } else this.refreshStatus('connecting', STATUS_COPY.handshake)
},
},
)
} catch (error) {
- this.joined = false
+ this.life = reduceLife(this.life, { type: 'join_err' })
const detail = error instanceof Error ? error.message : '连接失败'
this.refreshStatus('error', startFailedDetail(detail))
throw error
}
+ this.life = reduceLife(this.life, { type: 'join_ok' })
this.bindVisibility()
this.startTimers()
void this.transport.send(createHelloPayload(this.identity.nick))
- this.syncStatus()
+ this.emitStatus()
}
async sendChat(text: string): Promise {
- if (!this.joined) return 'closed'
+ if (!this.isJoined()) return 'closed'
const payload = createChatPayload(this.identity.nick, text, randomHex(8), this.runtime.now())
if (!payload.text) return 'empty'
+ const expectAck = this.presence.supports(FEATURE_ACK)
this.pushLine(
chatLine({
id: payload.id,
@@ -159,15 +169,18 @@ export class ChatSession {
text: payload.text,
ts: payload.ts,
self: true,
+ ...(expectAck ? { delivery: 'pending' as const } : {}),
}),
)
try {
await this.transport.send(payload)
- return 'sent'
} catch {
+ this.patchDelivery(payload.id, 'failed')
this.pushSystem(STATUS_COPY.sendFailed)
return 'failed'
}
+ if (expectAck) this.outbound.expect(payload.id)
+ return 'sent'
}
sendTyping(): void {
@@ -176,17 +189,19 @@ export class ChatSession {
setNick(nick: string): void {
this.identity = { ...this.identity, nick }
- if (this.joined) void this.transport.send(createHelloPayload(nick))
+ if (this.isJoined()) void this.transport.send(createHelloPayload(nick))
}
async leave(options: { silent?: boolean } = {}): Promise {
- this.joined = false
+ this.life = reduceLife(this.life, { type: 'leave' })
this.unbindVisibility?.()
this.unbindVisibility = null
this.stopTimers()
+ this.outbound.clear()
this.presence.clear()
await this.transport.leave()
this.transcript.clear()
+ this.life = reduceLife(this.life, { type: 'left' })
if (!options.silent) {
this.setStatus({ phase: 'idle', detail: STATUS_COPY.left, relays: [], peerCount: 0 })
this.emitMembers()
@@ -197,7 +212,8 @@ export class ChatSession {
private handlePayload(peerId: string, raw: unknown): void {
const payload = parsePayload(raw)
if (!payload) return
- this.presence.upsert(peerId, payload.nick)
+ if (payload.type === 'hello') this.presence.upsert(peerId, payload.nick, payload.features)
+ else this.presence.upsert(peerId, payload.nick)
if (payload.type === 'hello') {
this.emitMembers()
@@ -210,7 +226,15 @@ export class ChatSession {
return
}
- if (this.transcript.has(payload.id)) return
+ if (payload.type === 'ack') {
+ if (this.outbound.ack(payload.id)) this.patchDelivery(payload.id, 'acked')
+ return
+ }
+
+ if (this.transcript.has(payload.id)) {
+ void this.transport.send(createAckPayload(this.identity.nick, payload.id), peerId)
+ return
+ }
this.pushLine(
chatLine({
id: payload.id,
@@ -221,14 +245,25 @@ export class ChatSession {
self: false,
}),
)
+ void this.transport.send(createAckPayload(this.identity.nick, payload.id), peerId)
this.emitMembers()
}
+ private expireAck(id: string): void {
+ if (!this.isJoined()) return
+ this.patchDelivery(id, 'failed')
+ }
+
+ private patchDelivery(id: string, delivery: 'acked' | 'failed' | 'pending'): void {
+ const next = this.transcript.patchDelivery(id, delivery)
+ if (next) this.listeners.onLine?.(next)
+ }
+
private async measure(peerId: string): Promise {
- if (!this.joined) return
+ if (!this.isJoined()) return
try {
const rtt = await this.transport.ping(peerId)
- if (this.joined && this.presence.setRtt(peerId, rtt)) this.emitMembers()
+ if (this.isJoined() && this.presence.setRtt(peerId, rtt)) this.emitMembers()
} catch {
// Ping can fail during ICE restart; presence still stands.
}
@@ -243,17 +278,23 @@ export class ChatSession {
if (accepted) this.listeners.onLine?.(accepted)
}
- private syncStatus(forceError = false): void {
+ private notePeers(): void {
+ this.life = reduceLife(this.life, { type: 'peers', count: this.transport.peerIds().length })
+ this.emitStatus()
+ }
+
+ private emitStatus(): void {
const peerCount = this.transport.peerIds().length
const relays = this.transport.relays()
- if (peerCount > 0) {
- this.setStatus({ phase: 'connected', detail: connectedDetail(peerCount), relays, peerCount })
+ this.life = reduceLife(this.life, { type: 'relays', down: relays.length > 0 && relays.every((relay) => relay.readyState !== 1) })
+ const phase = uiPhase(this.life)
+ if (this.life.state === 'live') {
+ this.setStatus({ phase, detail: connectedDetail(peerCount), relays, peerCount })
return
}
- const next = forceError
- ? waitingOrDown(this.strategy, relays.length ? relays : [{ url: 'local', readyState: 3 }])
- : waitingOrDown(this.strategy, relays)
- this.setStatus({ ...next, relays, peerCount: 0 })
+ if (this.life.state === 'failed') return
+ const wait = waitingOrDown(this.strategy, this.life.state === 'relay_down' && relays.length === 0 ? [{ url: 'local', readyState: 3 }] : relays)
+ this.setStatus({ phase: wait.phase, detail: this.life.state === 'joining' ? connectingDetail(this.strategy) : wait.detail, relays, peerCount })
}
private refreshStatus(phase: SessionStatus['phase'], detail: string): void {
@@ -285,10 +326,10 @@ export class ChatSession {
this.stopTimers()
return
}
- if (!this.joined) return
+ if (!this.isJoined()) return
this.startTimers()
void this.transport.send(createHelloPayload(this.identity.nick))
- this.syncStatus()
+ this.emitStatus()
}
private bindVisibility(): void {
@@ -302,7 +343,7 @@ export class ChatSession {
void this.transport.send(createHelloPayload(this.identity.nick))
}, HELLO_INTERVAL_MS)
this.relayTimer = this.runtime.setInterval(() => {
- this.syncStatus()
+ this.emitStatus()
for (const peerId of this.transport.peerIds()) void this.measure(peerId)
}, RELAY_POLL_MS)
}
diff --git a/src/core/transcript.test.ts b/src/core/transcript.test.ts
index 717b960..f5a2d28 100644
--- a/src/core/transcript.test.ts
+++ b/src/core/transcript.test.ts
@@ -28,4 +28,12 @@ describe('Transcript', () => {
expect(ids.has('seen-0')).toBe(false)
expect(ids.has(`seen-${MAX_SEEN_IDS + 9}`)).toBe(true)
})
+
+ it('drops pending delivery when hydrating a cached log', () => {
+ const log = new Transcript()
+ log.hydrate([chatLine({ id: 'aabbccdd', fromId: 'me', nick: '晚风', text: 'hi', ts: 1, self: true, delivery: 'pending' })])
+ const row = log.snapshot()[0]
+ expect(row).toMatchObject({ kind: 'chat', id: 'aabbccdd', text: 'hi' })
+ expect(row && row.kind === 'chat' ? row.delivery : 'missing').toBeUndefined()
+ })
})
diff --git a/src/core/transcript.ts b/src/core/transcript.ts
index ef30188..a4a0bf4 100644
--- a/src/core/transcript.ts
+++ b/src/core/transcript.ts
@@ -1,6 +1,18 @@
import { MAX_SEEN_IDS } from '../config/app'
import { trimLines } from './cache'
-import type { ChatLine } from './types'
+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[] = []
@@ -9,7 +21,7 @@ export class Transcript {
hydrate(lines: ChatLine[]): ChatLine[] {
this.clear()
- this.lines = trimLines(lines)
+ this.lines = trimLines(lines.map(dropPending))
for (const line of this.lines) {
if (line.kind === 'chat') this.remember(line.id)
}
@@ -44,6 +56,15 @@ export class Transcript {
return line
}
+ patchDelivery(id: string, delivery: Delivery): ChatLine | null {
+ const index = this.lines.findIndex((line) => line.id === id)
+ const current = index >= 0 ? this.lines[index] : undefined
+ if (index < 0 || !current || current.kind !== 'chat') return null
+ const next = { ...current, delivery }
+ this.lines[index] = next
+ return next
+ }
+
clear(): void {
this.lines = []
this.seen.clear()
@@ -58,8 +79,18 @@ export function chatLine(input: {
text: string
ts: number
self: boolean
+ delivery?: Delivery
}): ChatLine {
- return { kind: 'chat', ...input }
+ if (input.delivery) return { kind: 'chat', ...input, delivery: input.delivery }
+ return {
+ kind: 'chat',
+ id: input.id,
+ fromId: input.fromId,
+ nick: input.nick,
+ text: input.text,
+ ts: input.ts,
+ self: input.self,
+ }
}
export function systemLine(id: string, text: string, ts: number): ChatLine {
diff --git a/src/core/types.ts b/src/core/types.ts
index c88f8c5..0b09fab 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -21,8 +21,11 @@ export type Member = {
lastSeenAt: number
rttMs: number | null
typing: boolean
+ features: string[]
}
+export type Delivery = 'pending' | 'acked' | 'failed'
+
export type ChatLine =
| {
kind: 'chat'
@@ -32,6 +35,7 @@ export type ChatLine =
text: string
ts: number
self: boolean
+ delivery?: Delivery
}
| {
kind: 'system'
diff --git a/src/ui/app.ts b/src/ui/app.ts
index dd6a278..18509e7 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -1,26 +1,19 @@
-import { DEFAULT_ROOM, SOURCE_URL } from '../config/app'
+import { SOURCE_URL } from '../config/app'
import { loadIdentity, persistNick } from '../core/identity'
-import { loadRecentRooms, specFromRecent } from '../core/recent'
-import { normalizeRoomName } from '../core/room'
import { RoomManager } from '../core/room-manager'
import { parseHash, toHash } from '../core/router'
-import { withSecret, rememberSecret } from '../core/secrets'
-import { roomUrl, shareOrCopy } from '../core/share'
+import { rememberSecret, withSecret } from '../core/secrets'
import { watchDuplicateTab } from '../core/tab-guard'
-import type { RoomSpec, SignalStrategy, ThemePreference } from '../core/types'
+import type { RoomSpec, ThemePreference } from '../core/types'
+import { capabilityBanner } from './capability'
import { ChatPane } from './chat-pane'
import { copy } from './copy'
-import { el, empty } from './dom'
+import { el } from './dom'
+import { createJumpDialog, type JumpDialog } from './jump-dialog'
+import { buildLobby } from './lobby'
+import { flashShare, renderTabs, renderTools } from './room-chrome'
import { applyTheme, cycleTheme, loadThemePreference, persistThemePreference, themeLabel } from './theme'
-function webrtcReady(): boolean {
- return typeof RTCPeerConnection === 'function'
-}
-
-function canJoin(): boolean {
- return window.isSecureContext && webrtcReady()
-}
-
export class App {
private root: HTMLElement
private theme: ThemePreference
@@ -30,10 +23,7 @@ export class App {
private tabsEl: HTMLElement
private toolsEl: HTMLElement
private main: HTMLElement
- private jump: HTMLDialogElement
- private jumpRoom: HTMLInputElement
- private jumpPass: HTMLInputElement
- private jumpStrategy: HTMLSelectElement
+ private jump: JumpDialog
private footerEl: HTMLElement
private dupBanner: HTMLElement
private chat: ChatPane | null = null
@@ -49,13 +39,7 @@ export class App {
this.tabsEl = el('nav', { class: 'tabs', 'aria-label': copy.recent })
this.toolsEl = el('div', { class: 'toolbar' })
this.main = el('main', { class: 'main' })
- this.jumpRoom = el('input', { placeholder: copy.room, maxlength: 64, autocomplete: 'off' }) as HTMLInputElement
- this.jumpPass = el('input', { placeholder: copy.password, type: 'password', autocomplete: 'off' }) as HTMLInputElement
- this.jumpStrategy = el('select', {}, [
- el('option', { value: 'torrent' }, [copy.torrent]),
- el('option', { value: 'nostr' }, [copy.nostr]),
- ]) as HTMLSelectElement
- this.jump = this.buildJump()
+ this.jump = createJumpDialog((spec) => this.go(spec))
this.dupBanner = el('div', { class: 'banner warn hidden' }, [copy.duplicateTab])
this.footerEl = el('p', { class: 'footer' }, [
el('a', { href: SOURCE_URL, target: '_blank', rel: 'noreferrer' }, [copy.source]),
@@ -63,7 +47,7 @@ export class App {
])
this.manager = new RoomManager(this.identity, {
onStatus: (status) => this.chat?.status(status),
- onMembers: (members) => this.chat?.members(members, this.manager.getSession()?.selfId ?? this.identity.id),
+ onMembers: (members) => this.chat?.members(members, this.manager.selfId()),
onLine: (line) => this.chat?.push(line),
onReset: (lines) => this.chat?.reset(lines),
})
@@ -87,7 +71,7 @@ export class App {
window.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
- this.openJump()
+ this.jump.open()
}
if (event.key === 'Escape') this.jump.close()
})
@@ -112,67 +96,24 @@ export class App {
this.tabsEl,
this.toolsEl,
]),
- this.capabilityBanner(),
+ capabilityBanner(),
this.dupBanner,
this.main,
this.footerEl,
]),
- this.jump,
+ this.jump.el,
)
}
- private capabilityBanner(): HTMLElement {
- if (!window.isSecureContext) return el('div', { class: 'banner' }, [copy.insecure])
- if (!webrtcReady()) return el('div', { class: 'banner' }, [copy.webrtcMissing])
- return el('div')
- }
-
- private buildJump(): HTMLDialogElement {
- const form = el('form', { class: 'jump-form', method: 'dialog' }, [
- el('h2', {}, [copy.switch]),
- this.jumpRoom,
- this.jumpPass,
- this.jumpStrategy,
- el('div', { class: 'actions' }, [
- el('button', { class: 'btn primary', type: 'submit' }, [copy.join]),
- 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()
- const spec: RoomSpec = {
- name: this.jumpRoom.value,
- password: this.jumpPass.value,
- strategy: this.jumpStrategy.value as SignalStrategy,
- }
- dialog.close()
- this.go(spec)
- })
- form.querySelector('button[value="cancel"]')?.addEventListener('click', () => dialog.close())
- return dialog
- }
-
- private openJump(prefill?: Partial & { needPassword?: boolean }): void {
- this.jumpRoom.value = prefill?.name ?? ''
- this.jumpPass.value = prefill?.password ?? ''
- this.jumpStrategy.value = prefill?.strategy ?? 'torrent'
- this.jump.showModal()
- if (prefill?.needPassword) this.jumpPass.focus()
- else this.jumpRoom.focus()
- }
-
private async route(): Promise {
const route = parseHash(location.hash)
- this.refreshTabs()
- this.refreshTools(route.name === 'room' ? withSecret(route.spec) : null)
+ this.refreshChrome(route.name === 'room' ? withSecret(route.spec) : undefined)
if (route.name === 'lobby') {
await this.manager.close()
this.showLobby()
return
}
- const spec = this.stripPasswordFromHash(withSecret(route.spec))
- await this.showChat(spec)
+ await this.showChat(this.stripPasswordFromHash(withSecret(route.spec)))
}
private stripPasswordFromHash(spec: RoomSpec): RoomSpec {
@@ -203,10 +144,18 @@ export class App {
this.chat?.hide()
this.footerEl.hidden = false
this.root.classList.remove('mode-chat')
- if (!this.lobbyEl) this.lobbyEl = this.buildLobby()
- else this.lobbyEl.replaceWith((this.lobbyEl = this.buildLobby()))
+ const next = buildLobby({
+ nick: this.identity.nick,
+ onJoin: ({ nick, spec }) => {
+ this.identity = { ...this.identity, nick: persistNick(nick) }
+ this.manager.setIdentity(this.identity)
+ this.go(spec)
+ },
+ })
+ if (this.lobbyEl) this.lobbyEl.replaceWith(next)
+ this.lobbyEl = next
this.main.replaceChildren(this.lobbyEl)
- this.refreshTabs()
+ this.refreshChrome()
}
private async showChat(spec: RoomSpec): Promise {
@@ -214,22 +163,16 @@ export class App {
this.chat = new ChatPane({
selfId: this.identity.id,
nick: this.identity.nick,
- send: (text) => this.manager.getSession()?.sendChat(text) ?? Promise.resolve('closed'),
- typing: () => this.manager.getSession()?.sendTyping(),
- onRetry: () => {
- const current = this.manager.current() ?? spec
- void this.manager.open(current, true)
- },
+ send: (text) => this.manager.sendChat(text),
+ typing: () => this.manager.sendTyping(),
+ onRetry: () => void this.manager.retry(),
onShare: () => {
const current = this.manager.current() ?? spec
- void this.shareRoom(current, false)
+ void flashShare(current, false)
},
onSwitchSignal: () => {
const current = this.manager.current() ?? spec
- this.go({
- ...current,
- strategy: current.strategy === 'torrent' ? 'nostr' : 'torrent',
- })
+ this.go({ ...current, strategy: current.strategy === 'torrent' ? 'nostr' : 'torrent' })
},
onNick: (nick) => {
this.identity = { ...this.identity, nick: persistNick(nick) }
@@ -245,8 +188,7 @@ export class App {
this.root.classList.add('mode-chat')
this.main.replaceChildren(this.chat.root)
this.chat.show()
- this.refreshTabs(spec)
- this.refreshTools(spec)
+ this.refreshChrome(spec)
try {
await this.manager.open(spec)
} catch (error) {
@@ -260,103 +202,14 @@ export class App {
}
}
- private buildLobby(): HTMLElement {
- const nick = el('input', { maxlength: 24, value: this.identity.nick, autocomplete: 'nickname' }) as HTMLInputElement
- const room = el('input', { maxlength: 64, 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]),
- el('option', { value: 'nostr' }, [copy.nostr]),
- ]) as HTMLSelectElement
- const joinBtn = el('button', { class: 'btn primary join-btn', type: 'submit' }, [copy.join]) as HTMLButtonElement
- if (!canJoin()) joinBtn.disabled = true
- const form = el('form', { class: 'panel form lobby-form' }, [
- el('div', { class: 'join-row' }, [field(copy.nick, nick), field(copy.room, room), joinBtn]),
- el('details', { class: 'more' }, [
- el('summary', {}, [copy.more]),
- el('div', { class: 'row' }, [field(copy.password, password), field(copy.strategy, strategy)]),
- el('span', { class: 'hint' }, [copy.passwordHint]),
- ]),
- ])
- form.addEventListener('submit', (event) => {
- event.preventDefault()
- if (!canJoin()) return
- this.identity = { ...this.identity, nick: persistNick(nick.value) }
- this.manager.setIdentity(this.identity)
- this.go({
- name: room.value,
- password: password.value,
- strategy: strategy.value as SignalStrategy,
- })
+ private refreshChrome(spec?: RoomSpec): void {
+ renderTabs(this.tabsEl, spec, {
+ onGo: (next) => this.go(next),
+ onNeedPassword: (item) => this.jump.open({ name: item.name, strategy: item.strategy, needPassword: true }),
+ onAdd: () => this.jump.open(),
+ })
+ renderTools(this.toolsEl, this.themeBtn, spec ?? null, (room, withKey, button) => {
+ void flashShare(room, withKey, button)
})
- return el('div', { class: 'lobby' }, [
- el('p', { class: 'lede' }, [copy.lobbyHint]),
- el('ul', { class: 'tips' }, [
- el('li', {}, [copy.tipSame]),
- el('li', {}, [copy.tipHttps]),
- el('li', {}, [copy.tipNat]),
- ]),
- form,
- ])
- }
-
- private refreshTabs(active?: RoomSpec): void {
- const rooms = loadRecentRooms()
- empty(this.tabsEl)
- for (const item of rooms) {
- const current = Boolean(active && item.name === normalizeRoomName(active.name) && item.strategy === active.strategy)
- const label = item.hasPassword
- ? [item.name, el('span', { class: 'lock', title: copy.locked }, ['锁'])]
- : [item.name]
- const tab = el(
- 'a',
- { class: `tab${current ? ' on' : ''}`, href: toHash({ name: 'room', spec: { name: item.name, password: '', strategy: item.strategy } }) },
- label,
- )
- tab.addEventListener('click', (event) => {
- event.preventDefault()
- const resolved = specFromRecent(item, active)
- if (resolved === 'need-password') {
- this.openJump({ name: item.name, strategy: item.strategy, needPassword: true })
- return
- }
- this.go(resolved)
- })
- this.tabsEl.append(tab)
- }
- const add = el('button', { class: 'tab add', type: 'button', title: copy.addRoom }, ['+'])
- add.addEventListener('click', () => this.openJump())
- this.tabsEl.append(add)
- }
-
- private refreshTools(spec: RoomSpec | null): void {
- empty(this.toolsEl)
- this.toolsEl.append(this.themeBtn)
- if (!spec) return
- const share = el('button', { class: 'btn ghost', type: 'button' }, [copy.share])
- share.addEventListener('click', () => void this.shareRoom(spec, false, share))
- this.toolsEl.append(share)
- if (spec.password) {
- const shareKey = el('button', { class: 'btn ghost', type: 'button' }, [copy.shareWithKey])
- shareKey.addEventListener('click', () => void this.shareRoom(spec, true, shareKey))
- this.toolsEl.append(shareKey)
- }
- const leave = el('a', { class: 'btn ghost', href: '#/' }, [copy.leave])
- this.toolsEl.append(leave)
- }
-
- private async shareRoom(spec: RoomSpec, includePassword: boolean, button?: HTMLButtonElement): Promise {
- const url = roomUrl(location.origin, location.pathname, spec, includePassword)
- const result = await shareOrCopy(url)
- if (!button) return
- const original = button.textContent
- button.textContent = result === 'shared' ? copy.shared : result === 'copied' ? copy.copied : copy.copyFailed
- window.setTimeout(() => {
- if (original) button.textContent = original
- }, 1400)
}
}
-
-function field(label: string, control: HTMLElement): HTMLElement {
- return el('label', { class: 'field' }, [el('span', {}, [label]), control])
-}
diff --git a/src/ui/capability.ts b/src/ui/capability.ts
new file mode 100644
index 0000000..9da3b85
--- /dev/null
+++ b/src/ui/capability.ts
@@ -0,0 +1,16 @@
+import { copy } from './copy'
+import { el } from './dom'
+
+export function webrtcReady(): boolean {
+ return typeof RTCPeerConnection === 'function'
+}
+
+export function canJoin(): boolean {
+ return window.isSecureContext && webrtcReady()
+}
+
+export function capabilityBanner(): HTMLElement {
+ if (!window.isSecureContext) return el('div', { class: 'banner' }, [copy.insecure])
+ if (!webrtcReady()) return el('div', { class: 'banner' }, [copy.webrtcMissing])
+ return el('div')
+}
diff --git a/src/ui/copy.ts b/src/ui/copy.ts
index 03ce610..39c404b 100644
--- a/src/ui/copy.ts
+++ b/src/ui/copy.ts
@@ -43,4 +43,6 @@ export const copy = {
needPassword: '这个房间设了口令',
duplicateTab: '另一个标签页也开着禅聊,会显示成两个人。',
theme: '主题',
+ delivered: '送达',
+ undelivered: '未送达',
}
diff --git a/src/ui/jump-dialog.ts b/src/ui/jump-dialog.ts
new file mode 100644
index 0000000..1b54c7a
--- /dev/null
+++ b/src/ui/jump-dialog.ts
@@ -0,0 +1,51 @@
+import type { RoomSpec, SignalStrategy } from '../core/types'
+import { copy } from './copy'
+import { el } from './dom'
+
+export type JumpDialog = {
+ el: HTMLDialogElement
+ open: (prefill?: Partial & { needPassword?: boolean }) => void
+ close: () => void
+}
+
+export function createJumpDialog(onJoin: (spec: RoomSpec) => void): JumpDialog {
+ const room = el('input', { placeholder: copy.room, maxlength: 64, 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 form = el('form', { class: 'jump-form', method: 'dialog' }, [
+ el('h2', {}, [copy.switch]),
+ room,
+ password,
+ strategy,
+ el('div', { class: 'actions' }, [
+ el('button', { class: 'btn primary', type: 'submit' }, [copy.join]),
+ 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({
+ name: room.value,
+ password: password.value,
+ strategy: strategy.value as SignalStrategy,
+ })
+ })
+ form.querySelector('button[value="cancel"]')?.addEventListener('click', () => dialog.close())
+ return {
+ el: dialog,
+ open(prefill) {
+ room.value = prefill?.name ?? ''
+ password.value = prefill?.password ?? ''
+ strategy.value = prefill?.strategy ?? 'torrent'
+ dialog.showModal()
+ if (prefill?.needPassword) password.focus()
+ else room.focus()
+ },
+ close: () => dialog.close(),
+ }
+}
diff --git a/src/ui/lobby.ts b/src/ui/lobby.ts
new file mode 100644
index 0000000..71b7cf2
--- /dev/null
+++ b/src/ui/lobby.ts
@@ -0,0 +1,53 @@
+import { DEFAULT_ROOM } from '../config/app'
+import type { RoomSpec, SignalStrategy } from '../core/types'
+import { canJoin } from './capability'
+import { copy } from './copy'
+import { el } from './dom'
+
+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 password = el('input', { 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 joinBtn = el('button', { class: 'btn primary join-btn', type: 'submit' }, [copy.join]) as HTMLButtonElement
+ if (!canJoin()) joinBtn.disabled = true
+ const form = el('form', { class: 'panel form lobby-form' }, [
+ el('div', { class: 'join-row' }, [field(copy.nick, nick), field(copy.room, room), joinBtn]),
+ el('details', { class: 'more' }, [
+ el('summary', {}, [copy.more]),
+ el('div', { class: 'row' }, [field(copy.password, password), field(copy.strategy, strategy)]),
+ el('span', { class: 'hint' }, [copy.passwordHint]),
+ ]),
+ ])
+ 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,
+ },
+ })
+ })
+ return el('div', { class: 'lobby' }, [
+ el('p', { class: 'lede' }, [copy.lobbyHint]),
+ el('ul', { class: 'tips' }, [
+ el('li', {}, [copy.tipSame]),
+ el('li', {}, [copy.tipHttps]),
+ el('li', {}, [copy.tipNat]),
+ ]),
+ form,
+ ])
+}
+
+function field(label: string, control: HTMLElement): HTMLElement {
+ return el('label', { class: 'field' }, [el('span', {}, [label]), control])
+}
diff --git a/src/ui/log-view.ts b/src/ui/log-view.ts
index cd729dd..e8d2482 100644
--- a/src/ui/log-view.ts
+++ b/src/ui/log-view.ts
@@ -1,11 +1,13 @@
import { colorFromId } from '../core/identity'
import type { ChatLine } from '../core/types'
+import { copy } from './copy'
import { el, formatTime } from './dom'
import { isStamp } from './stickers'
export class LogView {
readonly el: HTMLElement
private ids = new Set()
+ private nodes = new Map()
constructor() {
this.el = el('div', { class: 'log', role: 'log', 'aria-live': 'polite' })
@@ -18,17 +20,31 @@ export class LogView {
reset(lines: ChatLine[]): void {
this.el.replaceChildren()
this.ids.clear()
+ this.nodes.clear()
for (const line of lines) this.append(line, false)
this.stick()
}
append(line: ChatLine, stick = true): void {
- if (this.ids.has(line.id)) return
+ if (this.ids.has(line.id)) {
+ this.replace(line)
+ return
+ }
this.ids.add(line.id)
- this.el.append(renderLine(line))
+ const node = renderLine(line)
+ this.nodes.set(line.id, node)
+ this.el.append(node)
if (stick) this.stick()
}
+ private replace(line: ChatLine): void {
+ const previous = this.nodes.get(line.id)
+ if (!previous) return
+ const next = renderLine(line)
+ this.nodes.set(line.id, next)
+ previous.replaceWith(next)
+ }
+
private stick(): void {
const node = this.el
requestAnimationFrame(() => {
@@ -40,14 +56,22 @@ export class LogView {
function renderLine(line: ChatLine): HTMLElement {
if (line.kind === 'system') {
- return el('div', { class: 'system' }, [line.text])
+ return el('div', { class: 'system', 'data-id': line.id }, [line.text])
}
const stamp = isStamp(line.text)
- return el('article', { class: `bubble${line.self ? ' self' : ''}${stamp ? ' stamp' : ''}` }, [
- el('header', {}, [
- el('b', { style: `color:${colorFromId(line.fromId)}` }, [line.nick]),
- el('time', {}, [formatTime(line.ts)]),
- ]),
+ const mark =
+ line.self && line.delivery === 'acked'
+ ? el('span', { class: 'delivery' }, [copy.delivered])
+ : line.self && line.delivery === 'failed'
+ ? el('span', { class: 'delivery fail' }, [copy.undelivered])
+ : null
+ const header = el('header', {}, [
+ el('b', { style: `color:${colorFromId(line.fromId)}` }, [line.nick]),
+ el('time', {}, [formatTime(line.ts)]),
+ ...(mark ? [mark] : []),
+ ])
+ return el('article', { class: `bubble${line.self ? ' self' : ''}${stamp ? ' stamp' : ''}${line.delivery === 'failed' ? ' failed' : ''}`, 'data-id': line.id }, [
+ header,
el('p', {}, [line.text]),
])
}
diff --git a/src/ui/room-chrome.ts b/src/ui/room-chrome.ts
new file mode 100644
index 0000000..7e9877b
--- /dev/null
+++ b/src/ui/room-chrome.ts
@@ -0,0 +1,74 @@
+import { loadRecentRooms, specFromRecent, type RecentRoom } from '../core/recent'
+import { normalizeRoomName } from '../core/room'
+import { toHash } from '../core/router'
+import { roomUrl, shareOrCopy } from '../core/share'
+import type { RoomSpec } from '../core/types'
+import { copy } from './copy'
+import { el, empty } from './dom'
+
+export function renderTabs(
+ nav: HTMLElement,
+ active: RoomSpec | undefined,
+ handlers: {
+ onGo: (spec: RoomSpec) => void
+ onNeedPassword: (item: RecentRoom) => void
+ onAdd: () => void
+ },
+): void {
+ empty(nav)
+ for (const item of loadRecentRooms()) {
+ const current = Boolean(active && item.name === normalizeRoomName(active.name) && item.strategy === active.strategy)
+ const label = item.hasPassword
+ ? [item.name, el('span', { class: 'lock', title: copy.locked }, ['锁'])]
+ : [item.name]
+ const tab = el(
+ 'a',
+ { class: `tab${current ? ' on' : ''}`, href: toHash({ name: 'room', spec: { name: item.name, password: '', strategy: item.strategy } }) },
+ label,
+ )
+ tab.addEventListener('click', (event) => {
+ event.preventDefault()
+ const resolved = specFromRecent(item, active)
+ if (resolved === 'need-password') {
+ handlers.onNeedPassword(item)
+ return
+ }
+ handlers.onGo(resolved)
+ })
+ nav.append(tab)
+ }
+ const add = el('button', { class: 'tab add', type: 'button', title: copy.addRoom }, ['+'])
+ add.addEventListener('click', () => handlers.onAdd())
+ nav.append(add)
+}
+
+export function renderTools(
+ toolbar: HTMLElement,
+ themeBtn: HTMLElement,
+ spec: RoomSpec | null,
+ onShare: (spec: RoomSpec, withKey: boolean, button: HTMLButtonElement) => void,
+): void {
+ empty(toolbar)
+ toolbar.append(themeBtn)
+ if (!spec) return
+ const share = el('button', { class: 'btn ghost', type: 'button' }, [copy.share])
+ share.addEventListener('click', () => onShare(spec, false, share))
+ toolbar.append(share)
+ if (spec.password) {
+ const shareKey = el('button', { class: 'btn ghost', type: 'button' }, [copy.shareWithKey])
+ shareKey.addEventListener('click', () => onShare(spec, true, shareKey))
+ toolbar.append(shareKey)
+ }
+ toolbar.append(el('a', { class: 'btn ghost', href: '#/' }, [copy.leave]))
+}
+
+export async function flashShare(spec: RoomSpec, includePassword: boolean, button?: HTMLButtonElement): Promise {
+ const url = roomUrl(location.origin, location.pathname, spec, includePassword)
+ const result = await shareOrCopy(url)
+ if (!button) return
+ const original = button.textContent
+ button.textContent = result === 'shared' ? copy.shared : result === 'copied' ? copy.copied : copy.copyFailed
+ window.setTimeout(() => {
+ if (original) button.textContent = original
+ }, 1400)
+}
diff --git a/src/ui/styles.css b/src/ui/styles.css
index 773ce9a..e207628 100644
--- a/src/ui/styles.css
+++ b/src/ui/styles.css
@@ -458,11 +458,21 @@ textarea:focus-visible {
border-color: transparent;
}
+.bubble.self .delivery {
+ color: color-mix(in srgb, var(--on-accent) 74%, transparent);
+ font-size: 11px;
+}
+
.bubble.self header,
.bubble.self .muted {
color: color-mix(in srgb, var(--on-accent) 74%, transparent);
}
+.bubble.self .delivery.fail,
+.bubble.failed {
+ opacity: 0.86;
+}
+
.bubble header {
display: flex;
gap: 8px;