diff --git a/404.html b/404.html
index 57485d4..244b68e 100644
--- a/404.html
+++ b/404.html
@@ -10,7 +10,7 @@
/>
禅聊 ZenChat
-
+
diff --git a/README.md b/README.md
index 063fb38..2eb35fc 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@ flowchart LR
UI[UI Shell]
RM[RoomManager]
CS[ChatSession]
+ P[Presence]
+ L[Transcript]
TR[SignallingTransport]
end
@@ -29,7 +31,11 @@ flowchart LR
DC[WebRTC DataChannel]
end
- UI --> RM --> CS --> TR
+ UI --> RM --> CS
+ CS --> P[Presence]
+ CS --> L[Transcript]
+ CS --> TR
+
TR -->|"SDP / ICE (signalling only)"| WT
TR -->|"SDP / ICE (signalling only)"| NS
TR -->|"NAT discovery"| STUN
@@ -38,14 +44,13 @@ flowchart LR
| Layer / 层 | Role / 职责 |
|---|---|
-| UI Shell | Lobby, chat, room tabs, theme. Persistent; does not remount on switch. |
-| RoomManager | Fast room switch, local log cache, skip reconnect if same room. |
-| ChatSession | hello / chat / typing, incremental DOM, throttle typing, pause when hidden. |
-| Transport | `@trystero-p2p/torrent` (default) or `@trystero-p2p/nostr`. |
+| 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. |
+| 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. |
-扩展:再实现一个 `Libp2pTransport` 即可接到同一套 UI,不必改上层。
-
---
## English
diff --git a/assets/index-D53iHK7S.js b/assets/index-D53iHK7S.js
new file mode 100644
index 0000000..c2baec8
--- /dev/null
+++ b/assets/index-D53iHK7S.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 st="zenchat.andyccr.v1",m=1,nt=24,it=64,J=4e3,D=10,b=200,rt=b*2,ot="lobby",at=2500,lt=1800,ct=25e3,ht=5e3,ut="https://github.com/Andyccr/ZenChat",S="zenchat.nick",P="zenchat.peerId",j=["闲云","青石","疏影","晚风","折柳","听雨","归舟","松风"];function k(e){const t=new Uint8Array(e);return crypto.getRandomValues(t),[...t].map(s=>s.toString(16).padStart(2,"0")).join("")}const I=["#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,nt)}function F(e){const t=Number.parseInt(e.slice(0,4),16)%j.length;return j[t]??"闲云"}function U(){const e=sessionStorage.getItem(P),t=e&&/^[0-9a-f]{16,}$/i.test(e)?e:k(12);sessionStorage.setItem(P,t);const s=localStorage.getItem(S),n=y(s??"")||F(t);return localStorage.setItem(S,n),{id:t,nick:n}}function dt(e){const t=y(e)||F(U().id);return localStorage.setItem(S,t),t}function mt(e){return e==="torrent"||e==="nostr"}function p(e){return e.normalize("NFKC").trim().replace(/\s+/g,"-").replace(/[/#?&]+/g,"").slice(0,it)}function pt(e){const t=p(e.name);return e.password?`${t}::${e.password}`:t}function A(e){return`${e.strategy}:${p(e.name)}`}function L(e){return`${e.strategy}:${p(e.name)}:${e.password}`}function ft(e,t){return L(e)===L(t)}const G="zenchat.recentRooms";function W(){try{const e=localStorage.getItem(G);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,D):[]}catch{return[]}}function yt(e){const t={name:e.name,strategy:e.strategy,hasPassword:!!e.password,visitedAt:Date.now()},s=W().filter(i=>A(i)!==A(e)),n=[t,...s].slice(0,D);return localStorage.setItem(G,JSON.stringify(n)),n}const gt="zenchat.cache.v1.";function q(e){return gt+L(e)}function bt(e){try{const t=sessionStorage.getItem(q(e));if(!t)return[];const s=JSON.parse(t);return Array.isArray(s)?s.filter(vt).slice(-b):[]}catch{return[]}}function wt(e,t){try{sessionStorage.setItem(q(e),JSON.stringify(t.slice(-b)))}catch{}}function M(e){return e.length>b?e.slice(-b):e}function vt(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 Et{constructor(t,s=()=>{},n=at){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 Tt(e){return typeof e=="object"&&e!==null}function St(e){return e===m}function kt(e,t,s,n){return{v:m,type:"chat",id:s,ts:n,nick:y(e),text:t.trim().slice(0,J)}}function E(e){return{v:m,type:"hello",nick:y(e)}}function Lt(e){return{v:m,type:"typing",nick:y(e)}}function Rt(e){if(!Tt(e)||!St(e.v)||typeof e.type!="string")return null;const t=typeof e.nick=="string"?y(e.nick):"";if(!t)return null;if(e.type==="hello")return{v:m,type:"hello",nick:t};if(e.type==="typing")return{v:m,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,J);return s?{v:m,type:"chat",id:e.id,ts:e.ts,nick:t,text:s}:null}return null}const Ct={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 Pt(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)}))}}class It{lines=[];seen=new Set;seenOrder=[];hydrate(t){this.clear(),this.lines=M(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>rt;){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=M(this.lines),t}clear(){this.lines=[],this.seen.clear(),this.seenOrder=[]}}function O(e){return{kind:"chat",...e}}function At(e,t,s){return{kind:"system",id:e,text:t,ts:s}}const Mt="modulepreload",Ot=function(e){return"/ZenChat/"+e},N={},_=function(t,s,n){let i=Promise.resolve();if(s&&s.length>0){let C=function(u){return Promise.all(u.map(f=>Promise.resolve(f).then(g=>({status:"fulfilled",value:g}),g=>({status:"rejected",reason:g}))))};var c=C;document.getElementsByTagName("link");const l=document.querySelector("meta[property=csp-nonce]"),h=l?.nonce||l?.getAttribute("nonce");i=C(s.map(u=>{if(u=Ot(u),u in N)return;N[u]=!0;const f=u.endsWith(".css"),g=f?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${u}"]${g}`))return;const d=document.createElement("link");if(d.rel=f?"stylesheet":Mt,f||(d.as="script"),d.crossOrigin="",d.href=u,h&&d.setAttribute("nonce",h),document.head.appendChild(d),f)return new Promise((tt,et)=>{d.addEventListener("load",tt),d.addEventListener("error",()=>et(new Error(`Unable to preload CSS for ${u}`)))})}))}function o(l){const h=new Event("vite:preloadError",{cancelable:!0});if(h.payload=l,window.dispatchEvent(h),!h.defaultPrevented)throw l}return i.then(l=>{for(const h of l||[])h.status==="rejected"&&o(h.reason);return t().catch(o)})},Nt=[{urls:"stun:stun.cloudflare.com:3478"},{urls:"stun:stun.l.google.com:19302"}],_t={iceServers:Nt,iceCandidatePoolSize:8,bundlePolicy:"max-bundle",rtcpMuxPolicy:"require"},$t=["wss://tracker.webtorrent.dev","wss://tracker.openwebtorrent.com","wss://open.ftorrent.com:443"],xt=["wss://relay.damus.io","wss://nos.lol","wss://relay.snort.social","wss://nostr.wine"];function Bt(e){return Object.entries(e).map(([t,s])=>({url:t,readyState:s?.readyState??WebSocket.CLOSED}))}class Vt{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 R(t.strategy);const n=t.strategy==="torrent"?[...$t]:[...xt],i=this.module.joinRoom({appId:t.appId,...t.password?{password:t.password}:{},rtcConfig:_t,trickleIce:!0,relayConfig:{urls:n}},t.roomId,{onJoinError:c=>{s.onJoinError(c.error||"无法与对等节点完成握手")}}),o=i.makeAction("zen");o.onMessage=(c,{peerId:l})=>{s.onPayload(l,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?Bt(this.module.getRelaySockets()):[]}}const $=new Map;async function R(e){const t=$.get(e);if(t)return t;const s=e==="torrent"?_(()=>import("./index-BgLl3BAq.js"),__vite__mapDeps([0,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId})):_(()=>import("./index-DSJFV8_4.js"),__vite__mapDeps([2,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId}));return $.set(e,s),s}function Kt(){R("torrent"),R("nostr")}function Ht(e){return new Vt(e)}class Jt{transport;identity;listeners;createTransport;runtime;transcript=new It;presence;helloTimer=null;relayTimer=null;unbindVisibility=null;joined=!1;status={phase:"idle",detail:"尚未连接",relays:[],peerCount:0};emitMembers=jt(()=>{this.listeners.onMembers?.(this.presence.list())});sendTypingThrottled=Pt(()=>{this.joined&&this.transport.send(Lt(this.identity.nick))},lt);constructor(t,s,n={}){this.identity=t,this.listeners=s,this.createTransport=n.createTransport??Ht,this.runtime=n.runtime??Ct,this.transport=this.createTransport("torrent"),this.presence=new Et(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.transport=this.createTransport(t.strategy),this.joined=!0,this.setStatus({phase:"connecting",detail:t.strategy==="torrent"?"正在连接 Tracker…":"正在连接 Nostr…",relays:[],peerCount:0});try{await this.transport.join({appId:st,roomId:pt({name:p(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(E(this.identity.nick),s),this.emitMembers(),this.refreshStatus("connected",`直连 ${this.transport.peerIds().length}`),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();const i=this.transport.peerIds().length;this.refreshStatus(i>0?"connected":"connecting",i>0?`直连 ${i}`:"等待同伴")},onPayload:(s,n)=>this.handlePayload(s,n),onJoinError:s=>{this.refreshStatus("connecting",`握手受阻:${s}`)}})}catch(s){this.joined=!1;const n=s instanceof Error?s.message:"连接失败";throw this.refreshStatus("error",`无法启动 P2P:${n}`),s}this.bindVisibility(),this.startTimers(),this.refreshStatus("connecting","已宣布,等待对等节点")}async sendChat(t){if(!this.joined)return;const s=kt(this.identity.nick,t,k(8),this.runtime.now());s.text&&(this.pushLine(O({id:s.id,fromId:this.selfId,nick:this.identity.nick,text:s.text,ts:s.ts,self:!0})),this.transport.send(s))}sendTyping(){this.sendTypingThrottled()}setNick(t){this.identity={...this.identity,nick:t},this.joined&&this.transport.send(E(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:"已离开房间",relays:[],peerCount:0}),this.emitMembers(),this.listeners.onReset?.([]))}handlePayload(t,s){const n=Rt(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(O({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(At(k(6),t,this.runtime.now()))}pushLine(t){const s=this.transcript.append(t);s&&this.listeners.onLine?.(s)}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=()=>{this.runtime.hidden()?this.stopTimers():this.joined&&this.startTimers()};bindVisibility(){this.unbindVisibility?.(),this.unbindVisibility=this.runtime.onVisibilityChange(this.onVisibility)}startTimers(){this.stopTimers(),this.helloTimer=this.runtime.setInterval(()=>{this.transport.send(E(this.identity.nick))},ct),this.relayTimer=this.runtime.setInterval(()=>{this.refreshStatus(this.status.phase,this.status.detail)},ht)}stopTimers(){this.helloTimer!==null&&(this.runtime.clearInterval(this.helloTimer),this.helloTimer=null),this.relayTimer!==null&&(this.runtime.clearInterval(this.relayTimer),this.relayTimer=null)}}class Dt{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){if(this.spec&&this.session?.isJoined()&&ft(this.spec,t))return this.session;const s=++this.generation;if(await this.snapshotAndClose(),s!==this.generation)return null;yt(t),this.listeners.onMembers?.([]);const n=new Jt(this.identity,this.listeners,this.options),i=bt(t);i.length>0?n.hydrate(i):this.listeners.onReset?.([]),this.session=n,this.spec=t;try{await n.join(t)}catch(o){if(s!==this.generation)return await n.leave({silent:!0}),null;throw o}return s!==this.generation?(await n.leave({silent:!0}),null):n}async close(){this.generation+=1,await this.snapshotAndClose()}async snapshotAndClose(){this.session&&this.spec&&(wt(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 zt(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=p(decodeURIComponent(n[1]));if(!i)return{name:"lobby"};const o=x(s.searchParams,"s"),c=mt(o)?o:"torrent";return{name:"room",spec:{name:i,password:x(s.searchParams,"k"),strategy:c}}}return{name:"lobby"}}function w(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}`:""}`}const a={title:"禅聊",tagline:"无服务器 · 浏览器直连",source:"源码",nick:"昵称",room:"房间",password:"口令",passwordHint:"口令只隔离房间,不会上传。",strategy:"信令",torrent:"Tracker",nostr:"Nostr",join:"加入",more:"更多选项",lobbyHint:"同一房间即可直连,无需账号。",recent:"最近",share:"分享",shareWithKey:"含口令",leave:"大厅",send:"发送",placeholder:"发消息",members:"在线",you:"你",copied:"已复制",webrtcMissing:"需要支持 WebRTC 的浏览器,并用 HTTPS 打开。",insecure:"需要 HTTPS 或 localhost。",switch:"切换房间",addRoom:"加入房间",stamps:"表情",kaomoji:"颜文字",emoji:"表情",people:"成员"};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 B(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function Ft(e){return new Intl.DateTimeFormat("zh-CN",{hour:"2-digit",minute:"2-digit",hour12:!1}).format(e)}function Ut(e){switch(e){case WebSocket.CONNECTING:return"连接中";case WebSocket.OPEN:return"已连接";case WebSocket.CLOSING:return"断开中";default:return"未连接"}}function Gt(e){try{return new URL(e).host}catch{return e}}const Y=["(´・ω・`)","(。•́︿•̀。)","(´▽`ʃ♡ƪ)","(╯°□°)╯︵ ┻━┻","¯\\_(ツ)_/¯","(づ。◕‿‿◕。)づ","(ง'̀-'́)ง","( ̄▽ ̄)","(•̀ᴗ•́)و","ฅ^•ﻌ•^ฅ","(´;ω;`)","(≧∇≦)ノ","(´∀`)♡","┐( ̄ヘ ̄)┌"],X=["😀","😂","🥰","😎","🤔","😭","🙏","✨","🔥","🎉","👍","👋","❤️","🌸","🍵","🌙"],Wt=new Set(Y),qt=new Set(X);function Yt(e){const t=e.trim();return t?Wt.has(t)||qt.has(t)?!0:t.length<=8&&/^[\p{Emoji_Presentation}\s]+$/u.test(t):!1}function V(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 Xt{el;ids=new Set;constructor(){this.el=r("div",{class:"log",role:"log","aria-live":"polite"})}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(Zt(t)),s&&this.stick())}stick(){const t=this.el;requestAnimationFrame(()=>{t.scrollHeight-t.scrollTop-t.clientHeight<96&&(t.scrollTop=t.scrollHeight)})}}function Zt(e){if(e.kind==="system")return r("div",{class:"system"},[e.text]);const t=Yt(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",{},[Ft(e.ts)])]),r("p",{},[e.text])])}class Qt{el;open=!1;constructor(t){const s=r("div",{class:"picker-grid kaomoji"},Y.map(c=>K(c,()=>V(t,c)))),n=r("div",{class:"picker-grid emoji hidden"},X.map(c=>K(c,()=>V(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 K(e,t){const s=r("button",{class:"stamp-btn",type:"button"},[e]);return s.addEventListener("click",t),s}class te{root;log=new Xt;statusEl;membersEl;peopleBtn;lastStatus="";constructor(t){this.statusEl=r("div",{class:"status"}),this.membersEl=r("div",{class:"members"}),this.peopleBtn=r("button",{class:"btn ghost people",type:"button"},[a.people]),this.peopleBtn.addEventListener("click",l=>{l.stopPropagation(),this.root.classList.toggle("show-side")});const s=r("textarea",{rows:1,placeholder:a.placeholder,maxlength:4e3,enterkeyhint:"send"});s.setAttribute("enterkeyhint","send"),s.setAttribute("autocapitalize","sentences");const n=new Qt(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;s.value="",s.style.height="auto",n.hide(),t.send(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`,t.typing()}),this.root=r("div",{class:"chat"},[r("section",{class:"transcript"},[r("div",{class:"status-row"},[this.statusEl,this.peopleBtn]),this.log.el,n.el,r("div",{class:"composer"},[i,s,o])]),r("aside",{class:"side"},[r("h2",{},[a.members]),this.membersEl])]),this.members([],t.selfId)}hide(){this.root.hidden=!0,this.root.classList.remove("show-side")}show(){this.root.hidden=!1}status(t){const s=t.relays.filter(i=>i.readyState===WebSocket.OPEN).length,n=`${t.phase}|${t.detail}|${t.peerCount}|${s}`;n!==this.lastStatus&&(this.lastStatus=n,this.statusEl.replaceChildren(r("i",{class:`dot${t.peerCount>0?" ok":""}`}),r("span",{},[` ${t.detail}`])),this.peopleBtn.textContent=`${a.people} ${t.peerCount}`,this.statusEl.title=t.relays.map(i=>`${Gt(i.url)} ${Ut(i.readyState)}`).join(`
+`))}members(t,s){this.membersEl.replaceChildren(r("div",{class:"member"},[r("b",{},[a.you]),r("span",{class:"muted"},[s.slice(0,8)])]),...t.map(n=>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`:""])])))}}const Z="zenchat.theme";function ee(){const e=localStorage.getItem(Z);return e==="light"||e==="dark"||e==="system"?e:"system"}function se(e){localStorage.setItem(Z,e)}function ne(e){return e==="system"?"light":e==="light"?"dark":"system"}function ie(){return window.matchMedia("(prefers-color-scheme: dark)").matches}function re(e,t=ie()){return e==="system"?t?"dark":"light":e}function T(e){const t=re(e);return document.documentElement.dataset.theme=t,document.documentElement.dataset.themePref=e,document.documentElement.style.colorScheme=t,t}function H(e){return e==="light"?"白天":e==="dark"?"黑夜":"跟随系统"}function oe(){return typeof RTCPeerConnection=="function"}class ae{root;theme;identity=U();manager;themeBtn;tabsEl;toolsEl;main;jump;jumpRoom;jumpPass;jumpStrategy;footerEl;chat=null;lobbyEl=null;routing=!1;constructor(t){this.root=t,this.theme=ee(),T(this.theme),this.themeBtn=r("button",{class:"btn ghost",type:"button",title:"主题"},[H(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.footerEl=r("p",{class:"footer"},[r("a",{href:ut,target:"_blank",rel:"noreferrer"},[a.source])," · AGPL-3.0"]),this.manager=new Dt(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?.log.append(s),onReset:s=>this.chat?.log.reset(s)})}start(){this.mount(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>T(this.theme)),window.addEventListener("hashchange",()=>{this.routing||this.route()}),window.addEventListener("pagehide",()=>{this.manager.close()}),window.addEventListener("keydown",t=>{(t.metaKey||t.ctrlKey)&&t.key.toLowerCase()==="k"&&(t.preventDefault(),this.jump.showModal(),this.jumpRoom.focus()),t.key==="Escape"&&this.jump.close()}),this.themeBtn.addEventListener("click",()=>{this.theme=ne(this.theme),se(this.theme),T(this.theme),this.themeBtn.textContent=H(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.main,this.footerEl]),this.jump)}capabilityBanner(){return window.isSecureContext?oe()?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"},["取消"])])]),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}async route(){const t=zt(location.hash);if(this.refreshTabs(),this.refreshTools(t.name==="room"?t.spec:null),t.name==="lobby"){await this.manager.close(),this.showLobby();return}await this.showChat(t.spec)}go(t){const s=w({name:"room",spec:t},!!t.password);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=new te({selfId:this.identity.id,send:s=>{this.manager.getSession()?.sendChat(s)},typing:()=>this.manager.getSession()?.sendTyping()})),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:ot,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("form",{class:"panel form lobby-form"},[r("div",{class:"join-row"},[v(a.nick,t),v(a.room,s),r("button",{class:"btn primary join-btn",type:"submit"},[a.join])]),r("details",{class:"more"},[r("summary",{},[a.more]),r("div",{class:"row"},[v(a.password,n),v(a.strategy,i)]),r("span",{class:"hint"},[a.passwordHint])])]);return o.addEventListener("submit",c=>{c.preventDefault(),this.identity={...this.identity,nick:dt(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]),o])}refreshTabs(t){const s=W();B(this.tabsEl);for(const i of s){const o={name:i.name,password:"",strategy:i.strategy},c=!!(t&&i.name===p(t.name)&&i.strategy===t.strategy),l=r("a",{class:`tab${c?" on":""}`,href:w({name:"room",spec:o})},[i.name]);l.addEventListener("click",h=>{h.preventDefault(),this.go(le(i,t))}),this.tabsEl.append(l)}const n=r("button",{class:"tab add",type:"button",title:a.addRoom},["+"]);n.addEventListener("click",()=>{this.jump.showModal(),this.jumpRoom.focus()}),this.tabsEl.append(n)}refreshTools(t){if(B(this.toolsEl),this.toolsEl.append(this.themeBtn),!t)return;const s=r("button",{class:"btn ghost",type:"button"},[a.share]);if(s.addEventListener("click",async()=>{await navigator.clipboard.writeText(`${location.origin}${location.pathname}${w({name:"room",spec:t})}`),s.textContent=a.copied,window.setTimeout(()=>{s.textContent=a.share},1e3)}),this.toolsEl.append(s),t.password){const i=r("button",{class:"btn ghost",type:"button"},[a.shareWithKey]);i.addEventListener("click",async()=>{await navigator.clipboard.writeText(`${location.origin}${location.pathname}${w({name:"room",spec:t},!0)}`),i.textContent=a.copied}),this.toolsEl.append(i)}const n=r("a",{class:"btn ghost",href:"#/"},[a.leave]);this.toolsEl.append(n)}}function v(e,t){return r("label",{class:"field"},[r("span",{},[e]),t])}function le(e,t){const s=t&&e.name===p(t.name)&&e.strategy===t.strategy?t.password:"";return{name:e.name,password:s,strategy:e.strategy}}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 Q=document.querySelector("#app");if(!Q)throw new Error("缺少 #app 挂载点");ce();Kt();new ae(Q).start();
diff --git a/assets/index-Dl0q0iBn.js b/assets/index-Dl0q0iBn.js
deleted file mode 100644
index 8ec4fd0..0000000
--- a/assets/index-Dl0q0iBn.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 r of i)if(r.type==="childList")for(const c of r.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&n(c)}).observe(document,{childList:!0,subtree:!0});function s(i){const r={};return i.integrity&&(r.integrity=i.integrity),i.referrerPolicy&&(r.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?r.credentials="include":i.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function n(i){if(i.ep)return;i.ep=!0;const r=s(i);fetch(i.href,r)}})();const nt="zenchat.andyccr.v1",m=1,it=24,ot=64,V=4e3,J=10,v=200,rt="lobby",at=2500,lt=1800,ct=25e3,ht=5e3,dt="https://github.com/Andyccr/ZenChat",k="zenchat.nick",P="zenchat.peerId",j=["闲云","青石","疏影","晚风","折柳","听雨","归舟","松风"];function L(e){const t=new Uint8Array(e);return crypto.getRandomValues(t),[...t].map(s=>s.toString(16).padStart(2,"0")).join("")}const I=["#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,it)}function U(e){const t=Number.parseInt(e.slice(0,4),16)%j.length;return j[t]??"闲云"}function F(){const e=sessionStorage.getItem(P),t=e&&/^[0-9a-f]{16,}$/i.test(e)?e:L(12);sessionStorage.setItem(P,t);const s=localStorage.getItem(k),n=y(s??"")||U(t);return localStorage.setItem(k,n),{id:t,nick:n}}function ut(e){const t=y(e)||U(F().id);return localStorage.setItem(k,t),t}function mt(e){return e==="torrent"||e==="nostr"}function p(e){return e.normalize("NFKC").trim().replace(/\s+/g,"-").replace(/[/#?&]+/g,"").slice(0,ot)}function pt(e){const t=p(e.name);return e.password?`${t}::${e.password}`:t}function M(e){return`${e.strategy}:${p(e.name)}`}function T(e){return`${e.strategy}:${p(e.name)}:${e.password}`}function G(e,t){return T(e)===T(t)}const W="zenchat.recentRooms";function q(){try{const e=localStorage.getItem(W);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,J):[]}catch{return[]}}function ft(e){const t={name:e.name,strategy:e.strategy,hasPassword:!!e.password,visitedAt:Date.now()},s=q().filter(i=>M(i)!==M(e)),n=[t,...s].slice(0,J);return localStorage.setItem(W,JSON.stringify(n)),n}const yt="zenchat.cache.v1.";function Y(e){return yt+T(e)}function gt(e){try{const t=sessionStorage.getItem(Y(e));if(!t)return[];const s=JSON.parse(t);return Array.isArray(s)?s.filter(wt).slice(-v):[]}catch{return[]}}function bt(e,t){try{sessionStorage.setItem(Y(e),JSON.stringify(t.slice(-v)))}catch{}}function A(e){return e.length>v?e.slice(-v):e}function wt(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}function vt(e){return typeof e=="object"&&e!==null}function Et(e){return e===m}function St(e,t,s,n){return{v:m,type:"chat",id:s,ts:n,nick:y(e),text:t.trim().slice(0,V)}}function E(e){return{v:m,type:"hello",nick:y(e)}}function kt(e){return{v:m,type:"typing",nick:y(e)}}function Lt(e){if(!vt(e)||!Et(e.v)||typeof e.type!="string")return null;const t=typeof e.nick=="string"?y(e.nick):"";if(!t)return null;if(e.type==="hello")return{v:m,type:"hello",nick:t};if(e.type==="typing")return{v:m,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,V);return s?{v:m,type:"chat",id:e.id,ts:e.ts,nick:t,text:s}:null}return null}function Tt(e,t){let s=0;return(...n)=>{const i=Date.now();i-s{s=n,!t&&(t=requestAnimationFrame(()=>{t=0;const i=s;s=null,i&&e(...i)}))}}const Ct="modulepreload",Pt=function(e){return"/ZenChat/"+e},$={},O=function(t,s,n){let i=Promise.resolve();if(s&&s.length>0){let C=function(d){return Promise.all(d.map(f=>Promise.resolve(f).then(g=>({status:"fulfilled",value:g}),g=>({status:"rejected",reason:g}))))};var c=C;document.getElementsByTagName("link");const l=document.querySelector("meta[property=csp-nonce]"),h=l?.nonce||l?.getAttribute("nonce");i=C(s.map(d=>{if(d=Pt(d),d in $)return;$[d]=!0;const f=d.endsWith(".css"),g=f?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${d}"]${g}`))return;const u=document.createElement("link");if(u.rel=f?"stylesheet":Ct,f||(u.as="script"),u.crossOrigin="",u.href=d,h&&u.setAttribute("nonce",h),document.head.appendChild(u),f)return new Promise((et,st)=>{u.addEventListener("load",et),u.addEventListener("error",()=>st(new Error(`Unable to preload CSS for ${d}`)))})}))}function r(l){const h=new Event("vite:preloadError",{cancelable:!0});if(h.payload=l,window.dispatchEvent(h),!h.defaultPrevented)throw l}return i.then(l=>{for(const h of l||[])h.status==="rejected"&&r(h.reason);return t().catch(r)})},jt=[{urls:"stun:stun.cloudflare.com:3478"},{urls:"stun:stun.l.google.com:19302"}],It={iceServers:jt,iceCandidatePoolSize:8,bundlePolicy:"max-bundle",rtcpMuxPolicy:"require"},Mt=["wss://tracker.webtorrent.dev","wss://tracker.openwebtorrent.com","wss://open.ftorrent.com:443"],At=["wss://relay.damus.io","wss://nos.lol","wss://relay.snort.social","wss://nostr.wine"];function $t(e){return Object.entries(e).map(([t,s])=>({url:t,readyState:s?.readyState??WebSocket.CLOSED}))}class _{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 R(t.strategy);const n=t.strategy==="torrent"?[...Mt]:[...At],i=this.module.joinRoom({appId:t.appId,...t.password?{password:t.password}:{},rtcConfig:It,trickleIce:!0,relayConfig:{urls:n}},t.roomId,{onJoinError:c=>{s.onJoinError(c.error||"无法与对等节点完成握手")}}),r=i.makeAction("zen");r.onMessage=(c,{peerId:l})=>{s.onPayload(l,c)},i.onPeerJoin=c=>s.onPeerJoin(c),i.onPeerLeave=c=>s.onPeerLeave(c),this.room=i,this.action=r}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?$t(this.module.getRelaySockets()):[]}}const N=new Map;async function R(e){const t=N.get(e);if(t)return t;const s=e==="torrent"?O(()=>import("./index-BgLl3BAq.js"),__vite__mapDeps([0,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId})):O(()=>import("./index-DSJFV8_4.js"),__vite__mapDeps([2,1])).then(n=>({joinRoom:n.joinRoom,getRelaySockets:n.getRelaySockets,selfId:n.selfId}));return N.set(e,s),s}function Ot(){R("torrent"),R("nostr")}class _t{transport;identity;members=new Map;lines=[];seen=new Set;listeners;helloTimer=null;relayTimer=null;typingTimers=new Map;paused=!1;joined=!1;status={phase:"idle",detail:"尚未连接",relays:[],peerCount:0};emitMembers=Rt(()=>{this.listeners.onMembers?.([...this.members.values()].sort((t,s)=>t.joinedAt-s.joinedAt))});sendTypingThrottled=Tt(()=>{this.transport.send(kt(this.identity.nick))},lt);constructor(t,s,n){this.identity=t,this.listeners=s,this.transport=n??new _("torrent")}get selfId(){return this.transport.selfId()||this.identity.id}getLines(){return this.lines}hydrate(t){this.lines=A(t);for(const s of this.lines)s.kind==="chat"&&this.seen.add(s.id);this.listeners.onReset?.(this.lines)}async join(t){this.joined&&await this.leave({silent:!0}),this.transport=new _(t.strategy),this.joined=!0,this.setStatus({phase:"connecting",detail:t.strategy==="torrent"?"正在连接 Tracker…":"正在连接 Nostr…",relays:[],peerCount:0}),await this.transport.join({appId:nt,roomId:pt({name:p(t.name),password:t.password}),password:t.password,strategy:t.strategy},{onPeerJoin:s=>{this.upsertMember(s,"访客"),this.pushSystem(`${s.slice(0,6)} 加入了房间`),this.transport.send(E(this.identity.nick),s),this.emitMembers(),this.refreshStatus("connected",`直连 ${this.transport.peerIds().length}`),window.setTimeout(()=>{this.measure(s)},350)},onPeerLeave:s=>{const n=this.members.get(s);this.members.delete(s),this.pushSystem(`${n?.nick??s.slice(0,6)} 离开了房间`),this.emitMembers();const i=this.transport.peerIds().length;this.refreshStatus(i>0?"connected":"connecting",i>0?`直连 ${i}`:"等待同伴")},onPayload:(s,n)=>this.handlePayload(s,n),onJoinError:s=>{this.refreshStatus("connecting",`握手受阻:${s}`)}}),this.bindVisibility(),this.startTimers(),this.refreshStatus("connecting","已宣布,等待对等节点")}async sendChat(t){const s=St(this.identity.nick,t,L(8),Date.now());s.text&&(this.seen.add(s.id),this.pushLine({kind:"chat",id:s.id,fromId:this.selfId,nick:this.identity.nick,text:s.text,ts:s.ts,self:!0}),this.transport.send(s))}sendTyping(){this.sendTypingThrottled()}setNick(t){this.identity={...this.identity,nick:t},this.transport.send(E(t))}async leave(t={}){this.joined=!1,this.unbindVisibility(),this.stopTimers();for(const s of this.typingTimers.values())window.clearTimeout(s);this.typingTimers.clear(),await this.transport.leave(),this.members.clear(),this.lines=[],this.seen.clear(),t.silent||(this.setStatus({phase:"idle",detail:"已离开房间",relays:[],peerCount:0}),this.emitMembers(),this.listeners.onReset?.([]))}handlePayload(t,s){const n=Lt(s);if(n){if(this.upsertMember(t,n.nick),n.type==="hello"){this.emitMembers();return}if(n.type==="typing"){this.setTyping(t),this.emitMembers();return}this.seen.has(n.id)||(this.seen.add(n.id),this.pushLine({kind:"chat",id:n.id,fromId:t,nick:n.nick,text:n.text,ts:n.ts,self:!1}),this.emitMembers())}}upsertMember(t,s){const n=this.members.get(t),i=Date.now();this.members.set(t,{id:t,nick:s,joinedAt:n?.joinedAt??i,lastSeenAt:i,rttMs:n?.rttMs??null,typing:n?.typing??!1})}setTyping(t){const s=this.members.get(t);if(!s)return;s.typing=!0;const n=this.typingTimers.get(t);n&&window.clearTimeout(n),this.typingTimers.set(t,window.setTimeout(()=>{const i=this.members.get(t);i&&(i.typing=!1,this.emitMembers())},at))}async measure(t){try{const s=await this.transport.ping(t),n=this.members.get(t);n&&(n.rttMs=s,this.emitMembers())}catch{}}pushSystem(t){this.pushLine({kind:"system",id:L(6),text:t,ts:Date.now()})}pushLine(t){this.lines.push(t),this.lines=A(this.lines),this.listeners.onLine?.(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=()=>{document.hidden?this.stopTimers():this.joined&&this.startTimers()};bindVisibility(){document.addEventListener("visibilitychange",this.onVisibility)}unbindVisibility(){document.removeEventListener("visibilitychange",this.onVisibility)}startTimers(){this.stopTimers(),this.helloTimer=window.setInterval(()=>{this.paused||this.transport.send(E(this.identity.nick))},ct),this.relayTimer=window.setInterval(()=>{this.refreshStatus(this.status.phase,this.status.detail)},ht)}stopTimers(){this.helloTimer!==null&&(window.clearInterval(this.helloTimer),this.helloTimer=null),this.relayTimer!==null&&(window.clearInterval(this.relayTimer),this.relayTimer=null),this.paused=document.hidden}}class Nt{identity;listeners;session=null;spec=null;generation=0;constructor(t,s){this.identity=t,this.listeners=s}current(){return this.spec}getSession(){return this.session}setIdentity(t){this.identity=t,this.session?.setNick(t.nick)}async open(t){if(this.spec&&this.session&&G(this.spec,t))return this.session;const s=++this.generation;if(await this.snapshotAndClose(),s!==this.generation)return null;ft(t),this.listeners.onMembers?.([]);const n=new _t(this.identity,this.listeners),i=gt(t);return i.length>0?n.hydrate(i):this.listeners.onReset?.([]),this.session=n,this.spec=t,await n.join(t),s!==this.generation?(await n.leave({silent:!0}),null):n}async close(){this.generation+=1,await this.snapshotAndClose()}async snapshotAndClose(){this.session&&this.spec&&(bt(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 xt(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=p(decodeURIComponent(n[1]));if(!i)return{name:"lobby"};const r=x(s.searchParams,"s"),c=mt(r)?r:"torrent";return{name:"room",spec:{name:i,password:x(s.searchParams,"k"),strategy:c}}}return{name:"lobby"}}function b(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}`:""}`}const a={title:"禅聊",tagline:"无服务器 · 浏览器直连",source:"源码",nick:"昵称",room:"房间",password:"口令",passwordHint:"口令只隔离房间,不会上传。",strategy:"信令",torrent:"Tracker",nostr:"Nostr",join:"加入",more:"更多选项",lobbyHint:"同一房间即可直连,无需账号。",recent:"最近",share:"分享",shareWithKey:"含口令",leave:"大厅",send:"发送",placeholder:"发消息",members:"在线",you:"你",copied:"已复制",webrtcMissing:"需要支持 WebRTC 的浏览器,并用 HTTPS 打开。",insecure:"需要 HTTPS 或 localhost。",switch:"切换房间",addRoom:"加入房间",stamps:"表情",kaomoji:"颜文字",emoji:"表情",people:"成员"};function o(e,t={},s=[]){const n=document.createElement(e);for(const[i,r]of Object.entries(t))if(!(r===void 0||r===!1)){if(i==="class"){n.className=String(r);continue}if(typeof r=="boolean"){n.toggleAttribute(i,r);continue}if(i==="style"){n.setAttribute("style",String(r));continue}if(i in n&&i!=="list"){n[i]=r;continue}n.setAttribute(i,String(r))}for(const i of s)n.append(typeof i=="string"?document.createTextNode(i):i);return n}function B(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function Bt(e){return new Intl.DateTimeFormat("zh-CN",{hour:"2-digit",minute:"2-digit",hour12:!1}).format(e)}function Kt(e){switch(e){case WebSocket.CONNECTING:return"连接中";case WebSocket.OPEN:return"已连接";case WebSocket.CLOSING:return"断开中";default:return"未连接"}}function Ht(e){try{return new URL(e).host}catch{return e}}const X=["(´・ω・`)","(。•́︿•̀。)","(´▽`ʃ♡ƪ)","(╯°□°)╯︵ ┻━┻","¯\\_(ツ)_/¯","(づ。◕‿‿◕。)づ","(ง'̀-'́)ง","( ̄▽ ̄)","(•̀ᴗ•́)و","ฅ^•ﻌ•^ฅ","(´;ω;`)","(≧∇≦)ノ","(´∀`)♡","┐( ̄ヘ ̄)┌"],Z=["😀","😂","🥰","😎","🤔","😭","🙏","✨","🔥","🎉","👍","👋","❤️","🌸","🍵","🌙"],Dt=new Set(X),Vt=new Set(Z);function Jt(e){const t=e.trim();return t?Dt.has(t)||Vt.has(t)?!0:t.length<=8&&/^[\p{Emoji_Presentation}\s]+$/u.test(t):!1}function K(e,t){const s=e.selectionStart,n=e.selectionEnd,i=`${e.value.slice(0,s)}${t}${e.value.slice(n)}`;e.value=i;const r=s+t.length;e.setSelectionRange(r,r),e.focus(),e.dispatchEvent(new Event("input",{bubbles:!0}))}class zt{el;ids=new Set;constructor(){this.el=o("div",{class:"log",role:"log","aria-live":"polite"})}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(Ut(t)),s&&this.stick())}stick(){const t=this.el;requestAnimationFrame(()=>{t.scrollHeight-t.scrollTop-t.clientHeight<96&&(t.scrollTop=t.scrollHeight)})}}function Ut(e){if(e.kind==="system")return o("div",{class:"system"},[e.text]);const t=Jt(e.text);return o("article",{class:`bubble${e.self?" self":""}${t?" stamp":""}`},[o("header",{},[o("b",{style:`color:${z(e.fromId)}`},[e.nick]),o("time",{},[Bt(e.ts)])]),o("p",{},[e.text])])}class Ft{el;open=!1;constructor(t){const s=o("div",{class:"picker-grid kaomoji"},X.map(c=>H(c,()=>K(t,c)))),n=o("div",{class:"picker-grid emoji hidden"},Z.map(c=>H(c,()=>K(t,c)))),i=o("button",{class:"tab on",type:"button"},[a.kaomoji]),r=o("button",{class:"tab",type:"button"},[a.emoji]);i.addEventListener("click",()=>{s.classList.remove("hidden"),n.classList.add("hidden"),i.classList.add("on"),r.classList.remove("on")}),r.addEventListener("click",()=>{n.classList.remove("hidden"),s.classList.add("hidden"),r.classList.add("on"),i.classList.remove("on")}),this.el=o("div",{class:"picker hidden",role:"dialog","aria-label":a.stamps},[o("div",{class:"picker-tabs"},[i,r]),s,n])}toggle(){this.open=!this.open,this.el.classList.toggle("hidden",!this.open)}hide(){this.open=!1,this.el.classList.add("hidden")}}function H(e,t){const s=o("button",{class:"stamp-btn",type:"button"},[e]);return s.addEventListener("click",t),s}const Q="zenchat.theme";function Gt(){const e=localStorage.getItem(Q);return e==="light"||e==="dark"||e==="system"?e:"system"}function Wt(e){localStorage.setItem(Q,e)}function qt(e){return e==="system"?"light":e==="light"?"dark":"system"}function Yt(){return window.matchMedia("(prefers-color-scheme: dark)").matches}function Xt(e,t=Yt()){return e==="system"?t?"dark":"light":e}function S(e){const t=Xt(e);return document.documentElement.dataset.theme=t,document.documentElement.dataset.themePref=e,document.documentElement.style.colorScheme=t,t}function D(e){return e==="light"?"白天":e==="dark"?"黑夜":"跟随系统"}function Zt(){return typeof RTCPeerConnection=="function"}class Qt{root;theme;identity=F();manager;themeBtn;tabsEl;toolsEl;main;jump;jumpRoom;jumpPass;jumpStrategy;footerEl;chat=null;lobbyEl=null;routing=!1;constructor(t){this.root=t,this.theme=Gt(),S(this.theme),this.themeBtn=o("button",{class:"btn ghost",type:"button",title:"主题"},[D(this.theme)]),this.tabsEl=o("nav",{class:"tabs","aria-label":a.recent}),this.toolsEl=o("div",{class:"toolbar"}),this.main=o("main",{class:"main"}),this.jumpRoom=o("input",{placeholder:a.room,maxlength:64,autocomplete:"off"}),this.jumpPass=o("input",{placeholder:a.password,type:"password",autocomplete:"off"}),this.jumpStrategy=o("select",{},[o("option",{value:"torrent"},[a.torrent]),o("option",{value:"nostr"},[a.nostr])]),this.jump=this.buildJump(),this.footerEl=o("p",{class:"footer"},[o("a",{href:dt,target:"_blank",rel:"noreferrer"},[a.source])," · AGPL-3.0"]),this.manager=new Nt(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?.log.append(s),onReset:s=>this.chat?.log.reset(s)})}start(){this.mount(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>S(this.theme)),window.addEventListener("hashchange",()=>{this.routing||this.route()}),window.addEventListener("pagehide",()=>{this.manager.close()}),window.addEventListener("keydown",t=>{(t.metaKey||t.ctrlKey)&&t.key.toLowerCase()==="k"&&(t.preventDefault(),this.jump.showModal(),this.jumpRoom.focus()),t.key==="Escape"&&this.jump.close()}),this.themeBtn.addEventListener("click",()=>{this.theme=qt(this.theme),Wt(this.theme),S(this.theme),this.themeBtn.textContent=D(this.theme)}),this.route()}mount(){this.root.append(o("div",{class:"shell"},[o("header",{class:"topbar"},[o("a",{class:"brand",href:"#/"},[o("div",{class:"enso","aria-hidden":"true"}),o("strong",{},[a.title]),o("span",{class:"tagline"},[a.tagline])]),this.tabsEl,this.toolsEl]),this.capabilityBanner(),this.main,this.footerEl]),this.jump)}capabilityBanner(){return window.isSecureContext?Zt()?o("div"):o("div",{class:"banner"},[a.webrtcMissing]):o("div",{class:"banner"},[a.insecure])}buildJump(){const t=o("form",{class:"jump-form",method:"dialog"},[o("h2",{},[a.switch]),this.jumpRoom,this.jumpPass,this.jumpStrategy,o("div",{class:"actions"},[o("button",{class:"btn primary",type:"submit"},[a.join]),o("button",{class:"btn ghost",type:"button",value:"cancel"},["取消"])])]),s=o("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}async route(){const t=xt(location.hash);if(this.refreshTabs(),this.refreshTools(t.name==="room"?t.spec:null),t.name==="lobby"){await this.manager.close(),this.showLobby();return}await this.showChat(t.spec)}go(t){const s=b({name:"room",spec:t},!!t.password);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){const s=this.manager.current();if(this.chat||(this.chat=new te({selfId:this.identity.id,send:n=>{this.manager.getSession()?.sendChat(n)},typing:()=>this.manager.getSession()?.sendTyping()})),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),!(s&&G(s,t)))try{await this.manager.open(t)}catch(n){const i=n instanceof Error?n.message:"连接失败";this.chat.status({phase:"error",detail:`无法启动 P2P:${i}`,relays:[],peerCount:0})}}buildLobby(){const t=o("input",{maxlength:24,value:this.identity.nick,autocomplete:"nickname"}),s=o("input",{maxlength:64,value:rt,autocomplete:"off"}),n=o("input",{type:"password",autocomplete:"off"}),i=o("select",{},[o("option",{value:"torrent"},[a.torrent]),o("option",{value:"nostr"},[a.nostr])]),r=o("form",{class:"panel form lobby-form"},[o("div",{class:"join-row"},[w(a.nick,t),w(a.room,s),o("button",{class:"btn primary join-btn",type:"submit"},[a.join])]),o("details",{class:"more"},[o("summary",{},[a.more]),o("div",{class:"row"},[w(a.password,n),w(a.strategy,i)]),o("span",{class:"hint"},[a.passwordHint])])]);return r.addEventListener("submit",c=>{c.preventDefault(),this.identity={...this.identity,nick:ut(t.value)},this.manager.setIdentity(this.identity),this.go({name:s.value,password:n.value,strategy:i.value})}),o("div",{class:"lobby"},[o("p",{class:"lede"},[a.lobbyHint]),r])}refreshTabs(t){const s=q();B(this.tabsEl);for(const i of s){const r={name:i.name,password:"",strategy:i.strategy},c=!!(t&&i.name===p(t.name)&&i.strategy===t.strategy),l=o("a",{class:`tab${c?" on":""}`,href:b({name:"room",spec:r})},[i.name]);l.addEventListener("click",h=>{h.preventDefault(),this.go(ee(i,t))}),this.tabsEl.append(l)}const n=o("button",{class:"tab add",type:"button",title:a.addRoom},["+"]);n.addEventListener("click",()=>{this.jump.showModal(),this.jumpRoom.focus()}),this.tabsEl.append(n)}refreshTools(t){if(B(this.toolsEl),this.toolsEl.append(this.themeBtn),!t)return;const s=o("button",{class:"btn ghost",type:"button"},[a.share]);if(s.addEventListener("click",async()=>{await navigator.clipboard.writeText(`${location.origin}${location.pathname}${b({name:"room",spec:t})}`),s.textContent=a.copied,window.setTimeout(()=>{s.textContent=a.share},1e3)}),this.toolsEl.append(s),t.password){const i=o("button",{class:"btn ghost",type:"button"},[a.shareWithKey]);i.addEventListener("click",async()=>{await navigator.clipboard.writeText(`${location.origin}${location.pathname}${b({name:"room",spec:t},!0)}`),i.textContent=a.copied}),this.toolsEl.append(i)}const n=o("a",{class:"btn ghost",href:"#/"},[a.leave]);this.toolsEl.append(n)}}class te{root;log=new zt;statusEl;membersEl;peopleBtn;lastStatus="";constructor(t){this.statusEl=o("div",{class:"status"}),this.membersEl=o("div",{class:"members"}),this.peopleBtn=o("button",{class:"btn ghost people",type:"button"},[a.people]),this.peopleBtn.addEventListener("click",l=>{l.stopPropagation(),this.root.classList.toggle("show-side")});const s=o("textarea",{rows:1,placeholder:a.placeholder,maxlength:4e3,enterkeyhint:"send"});s.setAttribute("enterkeyhint","send"),s.setAttribute("autocapitalize","sentences");const n=new Ft(s),i=o("button",{class:"btn ghost",type:"button",title:a.stamps},["顔"]),r=o("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;s.value="",s.style.height="auto",n.hide(),t.send(l)};r.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`,t.typing()}),this.root=o("div",{class:"chat"},[o("section",{class:"transcript"},[o("div",{class:"status-row"},[this.statusEl,this.peopleBtn]),this.log.el,n.el,o("div",{class:"composer"},[i,s,r])]),o("aside",{class:"side"},[o("h2",{},[a.members]),this.membersEl])]),this.members([],t.selfId)}hide(){this.root.hidden=!0,this.root.classList.remove("show-side")}show(){this.root.hidden=!1}status(t){const s=t.relays.filter(i=>i.readyState===WebSocket.OPEN).length,n=`${t.phase}|${t.detail}|${t.peerCount}|${s}`;n!==this.lastStatus&&(this.lastStatus=n,this.statusEl.replaceChildren(o("i",{class:`dot${t.peerCount>0?" ok":""}`}),o("span",{},[` ${t.detail}`])),this.peopleBtn.textContent=`${a.people} ${t.peerCount}`,this.statusEl.title=t.relays.map(i=>`${Ht(i.url)} ${Kt(i.readyState)}`).join(`
-`))}members(t,s){this.membersEl.replaceChildren(o("div",{class:"member"},[o("b",{},[a.you]),o("span",{class:"muted"},[s.slice(0,8)])]),...t.map(n=>o("div",{class:"member"},[o("b",{style:`color:${z(n.id)}`},[n.nick]),o("span",{class:"muted"},[n.typing?"输入中":n.rttMs!==null?`${Math.round(n.rttMs)}ms`:""])])))}}function w(e,t){return o("label",{class:"field"},[o("span",{},[e]),t])}function ee(e,t){const s=t&&e.name===p(t.name)&&e.strategy===t.strategy?t.password:"";return{name:e.name,password:s,strategy:e.strategy}}function se(){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 tt=document.querySelector("#app");if(!tt)throw new Error("缺少 #app 挂载点");se();Ot();new Qt(tt).start();
diff --git a/index.html b/index.html
index 57485d4..244b68e 100644
--- a/index.html
+++ b/index.html
@@ -10,7 +10,7 @@
/>
禅聊 ZenChat
-
+
diff --git a/src/config/app.ts b/src/config/app.ts
index 792decf..39c3f7e 100644
--- a/src/config/app.ts
+++ b/src/config/app.ts
@@ -6,6 +6,7 @@ export const MAX_ROOM_LENGTH = 64
export const MAX_MESSAGE_LENGTH = 4000
export const MAX_RECENT_ROOMS = 10
export const MAX_LOG_LINES = 200
+export const MAX_SEEN_IDS = MAX_LOG_LINES * 2
export const DEFAULT_ROOM = 'lobby'
export const TYPING_TTL_MS = 2500
export const TYPING_THROTTLE_MS = 1800
diff --git a/src/core/presence.test.ts b/src/core/presence.test.ts
new file mode 100644
index 0000000..b12357a
--- /dev/null
+++ b/src/core/presence.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+import { Presence } from './presence'
+import { createMemoryRuntime } from './runtime'
+
+describe('Presence', () => {
+ it('upserts members, tracks typing until TTL, and reports RTT', () => {
+ const clock = createMemoryRuntime(1_000)
+ const ended: string[] = []
+ const presence = new Presence(clock.runtime, (id) => ended.push(id), 100)
+
+ presence.upsert('p1', '访客')
+ presence.upsert('p1', '晚风')
+ expect(presence.list()).toEqual([
+ expect.objectContaining({ id: 'p1', nick: '晚风', typing: false, rttMs: null }),
+ ])
+
+ expect(presence.markTyping('p1')).toBe(true)
+ expect(presence.list()[0]?.typing).toBe(true)
+ clock.advance(99)
+ expect(presence.list()[0]?.typing).toBe(true)
+ clock.advance(1)
+ expect(presence.list()[0]?.typing).toBe(false)
+ expect(ended).toEqual(['p1'])
+
+ expect(presence.setRtt('p1', 18)).toBe(true)
+ expect(presence.remove('p1')?.nick).toBe('晚风')
+ expect(presence.list()).toEqual([])
+ })
+})
diff --git a/src/core/presence.ts b/src/core/presence.ts
new file mode 100644
index 0000000..aef1dc2
--- /dev/null
+++ b/src/core/presence.ts
@@ -0,0 +1,76 @@
+import { TYPING_TTL_MS } from '../config/app'
+import type { Runtime } from './runtime'
+import type { Member } from './types'
+
+export class Presence {
+ private members = new Map()
+ private typingTimers = new Map()
+
+ constructor(
+ private runtime: Pick,
+ private onTypingEnd: (peerId: string) => void = () => undefined,
+ private typingTtl = TYPING_TTL_MS,
+ ) {}
+
+ upsert(id: string, nick: string): Member {
+ const existing = this.members.get(id)
+ const now = this.runtime.now()
+ const member: Member = {
+ id,
+ nick,
+ joinedAt: existing?.joinedAt ?? now,
+ lastSeenAt: now,
+ rttMs: existing?.rttMs ?? null,
+ typing: existing?.typing ?? false,
+ }
+ this.members.set(id, member)
+ return member
+ }
+
+ remove(id: string): Member | undefined {
+ this.clearTypingTimer(id)
+ const member = this.members.get(id)
+ this.members.delete(id)
+ return member
+ }
+
+ markTyping(peerId: string): boolean {
+ const member = this.members.get(peerId)
+ if (!member) return false
+ member.typing = true
+ this.clearTypingTimer(peerId)
+ this.typingTimers.set(
+ peerId,
+ this.runtime.setTimeout(() => {
+ const current = this.members.get(peerId)
+ if (current) {
+ current.typing = false
+ this.onTypingEnd(peerId)
+ }
+ }, this.typingTtl),
+ )
+ return true
+ }
+
+ setRtt(peerId: string, rttMs: number): boolean {
+ const member = this.members.get(peerId)
+ if (!member) return false
+ member.rttMs = rttMs
+ return true
+ }
+
+ list(): Member[] {
+ return [...this.members.values()].sort((a, b) => a.joinedAt - b.joinedAt)
+ }
+
+ clear(): void {
+ for (const id of this.typingTimers.keys()) this.clearTypingTimer(id)
+ this.members.clear()
+ }
+
+ private clearTypingTimer(peerId: string): void {
+ const previous = this.typingTimers.get(peerId)
+ if (previous !== undefined) this.runtime.clearTimeout(previous)
+ this.typingTimers.delete(peerId)
+ }
+}
diff --git a/src/core/protocol.test.ts b/src/core/protocol.test.ts
index 9417128..eaad8e7 100644
--- a/src/core/protocol.test.ts
+++ b/src/core/protocol.test.ts
@@ -22,4 +22,12 @@ describe('parsePayload', () => {
})
expect(parsed).toMatchObject({ type: 'hello', nick: '甲 乙' })
})
+
+ it('accepts typing payloads', () => {
+ expect(parsePayload({ v: 1, type: 'typing', nick: '听雨' })).toEqual({
+ v: 1,
+ type: 'typing',
+ nick: '听雨',
+ })
+ })
})
diff --git a/src/core/recent.test.ts b/src/core/recent.test.ts
new file mode 100644
index 0000000..3bac7d8
--- /dev/null
+++ b/src/core/recent.test.ts
@@ -0,0 +1,26 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { loadRecentRooms, rememberRoom } from './recent'
+
+beforeEach(() => {
+ const memory = new Map()
+ Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ value: {
+ getItem: (key: string) => memory.get(key) ?? null,
+ setItem: (key: string, value: string) => {
+ memory.set(key, value)
+ },
+ },
+ })
+})
+
+describe('recent rooms', () => {
+ it('dedupes by strategy and name, keeping the latest visit first', () => {
+ rememberRoom({ name: 'lobby', password: 'x', strategy: 'torrent' })
+ rememberRoom({ name: 'other', password: '', strategy: 'nostr' })
+ rememberRoom({ name: 'lobby', password: '', strategy: 'torrent' })
+ const list = loadRecentRooms()
+ expect(list[0]).toMatchObject({ name: 'lobby', strategy: 'torrent', hasPassword: false })
+ expect(list).toHaveLength(2)
+ })
+})
diff --git a/src/core/room-manager.test.ts b/src/core/room-manager.test.ts
new file mode 100644
index 0000000..b90715f
--- /dev/null
+++ b/src/core/room-manager.test.ts
@@ -0,0 +1,97 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { RoomManager } from './room-manager'
+import { createMemoryRuntime } from './runtime'
+import { FakeTransport } from './transports/fake'
+import type { ChatLine } from './types'
+
+const identity = { id: 'local', nick: '晚风' }
+
+function memoryStorage() {
+ const data = new Map()
+ return {
+ getItem: (key: string) => data.get(key) ?? null,
+ setItem: (key: string, value: string) => {
+ data.set(key, value)
+ },
+ }
+}
+
+beforeEach(() => {
+ Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: memoryStorage() })
+ Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: memoryStorage() })
+})
+
+function manager() {
+ const clock = createMemoryRuntime()
+ const created: FakeTransport[] = []
+ const resets: ChatLine[][] = []
+ const rm = new RoomManager(
+ identity,
+ { onReset: (lines) => resets.push(lines) },
+ {
+ runtime: clock.runtime,
+ createTransport: (strategy) => {
+ const fake = new FakeTransport(strategy)
+ created.push(fake)
+ return fake
+ },
+ },
+ )
+ return { rm, created, resets }
+}
+
+describe('RoomManager', () => {
+ it('reuses the live session for the same room', async () => {
+ const { rm } = manager()
+ const spec = { name: 'lobby', password: '', strategy: 'torrent' as const }
+ const first = await rm.open(spec)
+ const second = await rm.open(spec)
+ expect(second).toBe(first)
+ expect(rm.getSession()?.isJoined()).toBe(true)
+ })
+
+ it('retries after a failed join instead of sticking on the dead session', async () => {
+ const clock = createMemoryRuntime()
+ let fail = true
+ const rm = new RoomManager(identity, {}, {
+ runtime: clock.runtime,
+ createTransport: () => {
+ const fake = new FakeTransport()
+ if (fail) fake.failJoin = new Error('offline')
+ return fake
+ },
+ })
+ const spec = { name: 'lobby', password: '', strategy: 'torrent' as const }
+ await expect(rm.open(spec)).rejects.toThrow('offline')
+ expect(rm.getSession()?.isJoined()).toBe(false)
+ fail = false
+ await rm.open(spec)
+ expect(rm.getSession()?.isJoined()).toBe(true)
+ })
+
+ it('drops a superseded join when switching rooms mid-handshake', async () => {
+ const clock = createMemoryRuntime()
+ let release!: () => void
+ const gate = new Promise((resolve) => {
+ release = resolve
+ })
+ let builds = 0
+ const rm = new RoomManager(identity, {}, {
+ runtime: clock.runtime,
+ createTransport: (strategy) => {
+ const fake = new FakeTransport(strategy)
+ builds += 1
+ if (builds === 1) fake.joinGate = gate
+ return fake
+ },
+ })
+ const first = rm.open({ name: 'one', password: '', strategy: 'torrent' })
+ await Promise.resolve()
+ const second = rm.open({ name: 'two', password: '', strategy: 'torrent' })
+ release()
+ const [stale, live] = await Promise.all([first, second])
+ expect(stale).toBeNull()
+ expect(live?.isJoined()).toBe(true)
+ expect(rm.current()?.name).toBe('two')
+ })
+})
diff --git a/src/core/room-manager.ts b/src/core/room-manager.ts
index bb9b831..bfecd42 100644
--- a/src/core/room-manager.ts
+++ b/src/core/room-manager.ts
@@ -1,19 +1,21 @@
import { loadRoomLog, saveRoomLog } from './cache'
import { rememberRoom } from './recent'
import { sameRoom } from './room'
-import { ChatSession, type SessionListener } from './session'
+import { ChatSession, type SessionListener, type SessionOptions } from './session'
import type { Identity, RoomSpec } from './types'
export class RoomManager {
private identity: Identity
private listeners: SessionListener
+ private options: SessionOptions
private session: ChatSession | null = null
private spec: RoomSpec | null = null
private generation = 0
- constructor(identity: Identity, listeners: SessionListener) {
+ constructor(identity: Identity, listeners: SessionListener, options: SessionOptions = {}) {
this.identity = identity
this.listeners = listeners
+ this.options = options
}
current(): RoomSpec | null {
@@ -30,7 +32,7 @@ export class RoomManager {
}
async open(spec: RoomSpec): Promise {
- if (this.spec && this.session && sameRoom(this.spec, spec)) {
+ if (this.spec && this.session?.isJoined() && sameRoom(this.spec, spec)) {
return this.session
}
@@ -40,14 +42,22 @@ export class RoomManager {
rememberRoom(spec)
this.listeners.onMembers?.([])
- const session = new ChatSession(this.identity, this.listeners)
+ const session = new ChatSession(this.identity, this.listeners, this.options)
const cached = loadRoomLog(spec)
if (cached.length > 0) session.hydrate(cached)
else this.listeners.onReset?.([])
this.session = session
this.spec = spec
- await session.join(spec)
+ try {
+ await session.join(spec)
+ } catch (error) {
+ if (token !== this.generation) {
+ await session.leave({ silent: true })
+ return null
+ }
+ throw error
+ }
if (token !== this.generation) {
await session.leave({ silent: true })
return null
diff --git a/src/core/runtime.ts b/src/core/runtime.ts
new file mode 100644
index 0000000..cc61d5f
--- /dev/null
+++ b/src/core/runtime.ts
@@ -0,0 +1,98 @@
+export type Runtime = {
+ now(): number
+ hidden(): boolean
+ setTimeout(handler: () => void, ms: number): number
+ clearTimeout(id: number): void
+ setInterval(handler: () => void, ms: number): number
+ clearInterval(id: number): void
+ onVisibilityChange(handler: () => void): () => void
+}
+
+export const browserRuntime: Runtime = {
+ now: () => Date.now(),
+ hidden: () => document.hidden,
+ setTimeout: (handler, ms) => window.setTimeout(handler, ms),
+ clearTimeout: (id) => window.clearTimeout(id),
+ setInterval: (handler, ms) => window.setInterval(handler, ms),
+ clearInterval: (id) => window.clearInterval(id),
+ onVisibilityChange(handler) {
+ document.addEventListener('visibilitychange', handler)
+ return () => document.removeEventListener('visibilitychange', handler)
+ },
+}
+
+type Timer = {
+ id: number
+ at: number
+ fn: () => void
+ interval?: number
+}
+
+export function createMemoryRuntime(start = 0): {
+ runtime: Runtime
+ advance(ms: number): void
+ setHidden(hidden: boolean): void
+} {
+ let now = start
+ let seq = 1
+ let hidden = false
+ const timers = new Map()
+ const visibility = new Set<() => void>()
+
+ const runtime: Runtime = {
+ now: () => now,
+ hidden: () => hidden,
+ setTimeout(handler, ms) {
+ const id = seq++
+ timers.set(id, { id, at: now + ms, fn: handler })
+ return id
+ },
+ clearTimeout(id) {
+ timers.delete(id)
+ },
+ setInterval(handler, ms) {
+ const id = seq++
+ timers.set(id, { id, at: now + ms, fn: handler, interval: ms })
+ return id
+ },
+ clearInterval(id) {
+ timers.delete(id)
+ },
+ onVisibilityChange(handler) {
+ visibility.add(handler)
+ return () => {
+ visibility.delete(handler)
+ }
+ },
+ }
+
+ return {
+ runtime,
+ advance(ms: number) {
+ const target = now + ms
+ while (true) {
+ let next: Timer | undefined
+ for (const timer of timers.values()) {
+ if (timer.at > target) continue
+ if (!next || timer.at < next.at) next = timer
+ }
+ if (!next) {
+ now = target
+ return
+ }
+ now = next.at
+ if (next.interval !== undefined) {
+ next.fn()
+ if (timers.has(next.id)) next.at = now + next.interval
+ } else {
+ timers.delete(next.id)
+ next.fn()
+ }
+ }
+ },
+ setHidden(value: boolean) {
+ hidden = value
+ for (const handler of visibility) handler()
+ },
+ }
+}
diff --git a/src/core/scheduler.test.ts b/src/core/scheduler.test.ts
new file mode 100644
index 0000000..1f38cd8
--- /dev/null
+++ b/src/core/scheduler.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it, vi } from 'vitest'
+import { rafBatch, throttle } from './scheduler'
+
+describe('scheduler', () => {
+ it('throttles bursts to the first call in the window', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+ const fn = vi.fn()
+ const limited = throttle(fn, 100)
+ limited('a')
+ limited('b')
+ vi.setSystemTime(99)
+ limited('c')
+ vi.setSystemTime(100)
+ limited('d')
+ expect(fn.mock.calls).toEqual([['a'], ['d']])
+ vi.useRealTimers()
+ })
+
+ it('runs immediately when requestAnimationFrame is unavailable', () => {
+ const fn = vi.fn()
+ rafBatch(fn)('x')
+ expect(fn).toHaveBeenCalledWith('x')
+ })
+})
diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts
index ae02b00..c253852 100644
--- a/src/core/scheduler.ts
+++ b/src/core/scheduler.ts
@@ -1,5 +1,5 @@
export function throttle(fn: (...args: T) => void, ms: number): (...args: T) => void {
- let last = 0
+ let last = Number.NEGATIVE_INFINITY
return (...args: T) => {
const now = Date.now()
if (now - last < ms) return
@@ -9,6 +9,9 @@ export function throttle(fn: (...args: T) => void, ms: numb
}
export function rafBatch(fn: (...args: T) => void): (...args: T) => void {
+ if (typeof requestAnimationFrame !== 'function') {
+ return (...args: T) => fn(...args)
+ }
let token = 0
let pending: T | null = null
return (...args: T) => {
@@ -22,3 +25,4 @@ export function rafBatch(fn: (...args: T) => void): (...arg
})
}
}
+
diff --git a/src/core/session.test.ts b/src/core/session.test.ts
new file mode 100644
index 0000000..49fa2c7
--- /dev/null
+++ b/src/core/session.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from 'vitest'
+import { createTypingPayload } from './protocol'
+import { createMemoryRuntime } from './runtime'
+import { ChatSession } from './session'
+import { FakeTransport } from './transports/fake'
+import type { ChatLine, Member, SessionStatus } from './types'
+
+const identity = { id: 'local', nick: '晚风' }
+const spec = { name: '茶室', password: '', strategy: 'torrent' as const }
+
+function harness() {
+ const clock = createMemoryRuntime(1_700_000_000_000)
+ const created: FakeTransport[] = []
+ const lines: ChatLine[] = []
+ const members: Member[][] = []
+ const statuses: SessionStatus[] = []
+ const session = new ChatSession(
+ identity,
+ {
+ onLine: (line) => lines.push(line),
+ onMembers: (list) => members.push(list),
+ onStatus: (status) => statuses.push(status),
+ onReset: () => undefined,
+ },
+ {
+ runtime: clock.runtime,
+ createTransport: (strategy) => {
+ const fake = new FakeTransport(strategy)
+ created.push(fake)
+ return fake
+ },
+ },
+ )
+ return {
+ session,
+ clock,
+ lines,
+ members,
+ statuses,
+ transport: () => created.at(-1) as FakeTransport,
+ }
+}
+
+describe('ChatSession', () => {
+ it('joins, announces to a peer, and echos a local chat line', async () => {
+ const { session, lines, transport, statuses } = harness()
+ await session.join(spec)
+ expect(session.isJoined()).toBe(true)
+ expect(statuses.at(-1)?.detail).toBe('已宣布,等待对等节点')
+
+ transport().peerJoin('peer-aa')
+ expect(lines.some((line) => line.kind === 'system' && line.text.includes('加入'))).toBe(true)
+ expect(transport().sent.some((item) => (item.payload as { type: string }).type === 'hello' && item.target === 'peer-aa')).toBe(true)
+
+ await session.sendChat('你好')
+ const self = lines.find((line) => line.kind === 'chat' && line.self)
+ expect(self).toMatchObject({ text: '你好', nick: '晚风' })
+ expect(transport().sent.some((item) => (item.payload as { type: string }).type === 'chat')).toBe(true)
+ })
+
+ it('accepts remote chat once and ignores duplicates', async () => {
+ const { session, lines, transport } = harness()
+ await session.join(spec)
+ const payload = { v: 1, type: 'chat', id: 'aabbccdd12345678', ts: 1, nick: '青石', text: '在吗' }
+ transport().payload('peer-b', payload)
+ transport().payload('peer-b', payload)
+ expect(lines.filter((line) => line.kind === 'chat')).toHaveLength(1)
+ })
+
+ it('marks typing until the TTL elapses', async () => {
+ const { session, clock, members, transport } = harness()
+ await session.join(spec)
+ transport().payload('peer-c', createTypingPayload('听雨'))
+ expect(members.at(-1)?.some((member) => member.id === 'peer-c' && member.typing)).toBe(true)
+ clock.advance(2500)
+ expect(members.at(-1)?.some((member) => member.id === 'peer-c' && member.typing)).toBe(false)
+ })
+
+ it('records an error phase when the transport cannot join', async () => {
+ const clock = createMemoryRuntime()
+ const statuses: SessionStatus[] = []
+ const fake = new FakeTransport()
+ fake.failJoin = new Error('tracker down')
+ const session = new ChatSession(identity, { onStatus: (status) => statuses.push(status) }, {
+ runtime: clock.runtime,
+ createTransport: () => fake,
+ })
+ await expect(session.join(spec)).rejects.toThrow('tracker down')
+ expect(session.isJoined()).toBe(false)
+ expect(statuses.at(-1)).toMatchObject({ phase: 'error', detail: '无法启动 P2P:tracker down' })
+ })
+
+ it('does not send chat after leave', async () => {
+ const { session, transport } = harness()
+ await session.join(spec)
+ await session.leave()
+ const sent = transport().sent.length
+ await session.sendChat('迟了')
+ expect(transport().sent).toHaveLength(sent)
+ })
+})
diff --git a/src/core/session.ts b/src/core/session.ts
index 3362d84..b78eee8 100644
--- a/src/core/session.ts
+++ b/src/core/session.ts
@@ -1,6 +1,6 @@
-import { APP_ID, HELLO_INTERVAL_MS, RELAY_POLL_MS, TYPING_THROTTLE_MS, TYPING_TTL_MS } from '../config/app'
-import { trimLines } from './cache'
+import { APP_ID, HELLO_INTERVAL_MS, RELAY_POLL_MS, TYPING_THROTTLE_MS } from '../config/app'
import { randomHex } from './identity'
+import { Presence } from './presence'
import {
createChatPayload,
createHelloPayload,
@@ -8,8 +8,10 @@ import {
parsePayload,
} from './protocol'
import { normalizeRoomName, roomNamespace } from './room'
+import { browserRuntime, type Runtime } from './runtime'
import { rafBatch, throttle } from './scheduler'
-import { TrysteroTransport } from './transports/trystero'
+import { chatLine, systemLine, Transcript } from './transcript'
+import { createTransport, type TransportFactory } from './transports/create'
import type { SignallingTransport } from './transports/types'
import type { ChatLine, Identity, Member, RoomSpec, SessionStatus } from './types'
@@ -20,17 +22,22 @@ export type SessionListener = {
onReset?: (lines: ChatLine[]) => void
}
+export type SessionOptions = {
+ createTransport?: TransportFactory
+ runtime?: Runtime
+}
+
export class ChatSession {
private transport: SignallingTransport
private identity: Identity
- private members = new Map()
- private lines: ChatLine[] = []
- private seen = new Set()
private listeners: SessionListener
+ private createTransport: TransportFactory
+ private runtime: Runtime
+ private transcript = new Transcript()
+ private presence: Presence
private helloTimer: number | null = null
private relayTimer: number | null = null
- private typingTimers = new Map()
- private paused = false
+ private unbindVisibility: (() => void) | null = null
private joined = false
private status: SessionStatus = {
phase: 'idle',
@@ -40,38 +47,42 @@ export class ChatSession {
}
private readonly emitMembers = rafBatch(() => {
- this.listeners.onMembers?.([...this.members.values()].sort((a, b) => a.joinedAt - b.joinedAt))
+ this.listeners.onMembers?.(this.presence.list())
})
private readonly sendTypingThrottled = throttle(() => {
+ if (!this.joined) return
void this.transport.send(createTypingPayload(this.identity.nick))
}, TYPING_THROTTLE_MS)
- constructor(identity: Identity, listeners: SessionListener, transport?: SignallingTransport) {
+ constructor(identity: Identity, listeners: SessionListener, options: SessionOptions = {}) {
this.identity = identity
this.listeners = listeners
- this.transport = transport ?? new TrysteroTransport('torrent')
+ this.createTransport = options.createTransport ?? createTransport
+ this.runtime = options.runtime ?? browserRuntime
+ this.transport = this.createTransport('torrent')
+ this.presence = new Presence(this.runtime, () => this.emitMembers())
}
get selfId(): string {
return this.transport.selfId() || this.identity.id
}
+ isJoined(): boolean {
+ return this.joined
+ }
+
getLines(): ChatLine[] {
- return this.lines
+ return this.transcript.snapshot()
}
hydrate(lines: ChatLine[]): void {
- this.lines = trimLines(lines)
- for (const line of this.lines) {
- if (line.kind === 'chat') this.seen.add(line.id)
- }
- this.listeners.onReset?.(this.lines)
+ this.listeners.onReset?.(this.transcript.hydrate(lines))
}
async join(spec: RoomSpec): Promise {
if (this.joined) await this.leave({ silent: true })
- this.transport = new TrysteroTransport(spec.strategy)
+ this.transport = this.createTransport(spec.strategy)
this.joined = true
this.setStatus({
phase: 'connecting',
@@ -80,36 +91,42 @@ export class ChatSession {
peerCount: 0,
})
- await this.transport.join(
- {
- appId: APP_ID,
- roomId: roomNamespace({ name: normalizeRoomName(spec.name), password: spec.password }),
- password: spec.password,
- strategy: spec.strategy,
- },
- {
- onPeerJoin: (peerId) => {
- this.upsertMember(peerId, '访客')
- this.pushSystem(`${peerId.slice(0, 6)} 加入了房间`)
- void this.transport.send(createHelloPayload(this.identity.nick), peerId)
- this.emitMembers()
- this.refreshStatus('connected', `直连 ${this.transport.peerIds().length}`)
- window.setTimeout(() => void this.measure(peerId), 350)
- },
- onPeerLeave: (peerId) => {
- const member = this.members.get(peerId)
- this.members.delete(peerId)
- this.pushSystem(`${member?.nick ?? peerId.slice(0, 6)} 离开了房间`)
- this.emitMembers()
- const n = this.transport.peerIds().length
- this.refreshStatus(n > 0 ? 'connected' : 'connecting', n > 0 ? `直连 ${n}` : '等待同伴')
+ try {
+ await this.transport.join(
+ {
+ appId: APP_ID,
+ roomId: roomNamespace({ name: normalizeRoomName(spec.name), password: spec.password }),
+ password: spec.password,
+ strategy: spec.strategy,
},
- onPayload: (peerId, payload) => this.handlePayload(peerId, payload),
- onJoinError: (detail) => {
- this.refreshStatus('connecting', `握手受阻:${detail}`)
+ {
+ onPeerJoin: (peerId) => {
+ this.presence.upsert(peerId, '访客')
+ this.pushSystem(`${peerId.slice(0, 6)} 加入了房间`)
+ void this.transport.send(createHelloPayload(this.identity.nick), peerId)
+ this.emitMembers()
+ this.refreshStatus('connected', `直连 ${this.transport.peerIds().length}`)
+ 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()
+ const n = this.transport.peerIds().length
+ this.refreshStatus(n > 0 ? 'connected' : 'connecting', n > 0 ? `直连 ${n}` : '等待同伴')
+ },
+ onPayload: (peerId, payload) => this.handlePayload(peerId, payload),
+ onJoinError: (detail) => {
+ this.refreshStatus('connecting', `握手受阻:${detail}`)
+ },
},
- },
- )
+ )
+ } catch (error) {
+ this.joined = false
+ const detail = error instanceof Error ? error.message : '连接失败'
+ this.refreshStatus('error', `无法启动 P2P:${detail}`)
+ throw error
+ }
this.bindVisibility()
this.startTimers()
@@ -117,18 +134,19 @@ export class ChatSession {
}
async sendChat(text: string): Promise {
- const payload = createChatPayload(this.identity.nick, text, randomHex(8), Date.now())
+ if (!this.joined) return
+ const payload = createChatPayload(this.identity.nick, text, randomHex(8), this.runtime.now())
if (!payload.text) return
- this.seen.add(payload.id)
- this.pushLine({
- kind: 'chat',
- id: payload.id,
- fromId: this.selfId,
- nick: this.identity.nick,
- text: payload.text,
- ts: payload.ts,
- self: true,
- })
+ this.pushLine(
+ chatLine({
+ id: payload.id,
+ fromId: this.selfId,
+ nick: this.identity.nick,
+ text: payload.text,
+ ts: payload.ts,
+ self: true,
+ }),
+ )
void this.transport.send(payload)
}
@@ -138,19 +156,17 @@ export class ChatSession {
setNick(nick: string): void {
this.identity = { ...this.identity, nick }
- void this.transport.send(createHelloPayload(nick))
+ if (this.joined) void this.transport.send(createHelloPayload(nick))
}
async leave(options: { silent?: boolean } = {}): Promise {
this.joined = false
- this.unbindVisibility()
+ this.unbindVisibility?.()
+ this.unbindVisibility = null
this.stopTimers()
- for (const timer of this.typingTimers.values()) window.clearTimeout(timer)
- this.typingTimers.clear()
+ this.presence.clear()
await this.transport.leave()
- this.members.clear()
- this.lines = []
- this.seen.clear()
+ this.transcript.clear()
if (!options.silent) {
this.setStatus({ phase: 'idle', detail: '已离开房间', relays: [], peerCount: 0 })
this.emitMembers()
@@ -161,7 +177,7 @@ export class ChatSession {
private handlePayload(peerId: string, raw: unknown): void {
const payload = parsePayload(raw)
if (!payload) return
- this.upsertMember(peerId, payload.nick)
+ this.presence.upsert(peerId, payload.nick)
if (payload.type === 'hello') {
this.emitMembers()
@@ -169,77 +185,42 @@ export class ChatSession {
}
if (payload.type === 'typing') {
- this.setTyping(peerId)
+ this.presence.markTyping(peerId)
this.emitMembers()
return
}
- if (this.seen.has(payload.id)) return
- this.seen.add(payload.id)
- this.pushLine({
- kind: 'chat',
- id: payload.id,
- fromId: peerId,
- nick: payload.nick,
- text: payload.text,
- ts: payload.ts,
- self: false,
- })
- this.emitMembers()
- }
-
- private upsertMember(id: string, nick: string): void {
- const existing = this.members.get(id)
- const now = Date.now()
- this.members.set(id, {
- id,
- nick,
- joinedAt: existing?.joinedAt ?? now,
- lastSeenAt: now,
- rttMs: existing?.rttMs ?? null,
- typing: existing?.typing ?? false,
- })
- }
-
- private setTyping(peerId: string): void {
- const member = this.members.get(peerId)
- if (!member) return
- member.typing = true
- const previous = this.typingTimers.get(peerId)
- if (previous) window.clearTimeout(previous)
- this.typingTimers.set(
- peerId,
- window.setTimeout(() => {
- const current = this.members.get(peerId)
- if (current) {
- current.typing = false
- this.emitMembers()
- }
- }, TYPING_TTL_MS),
+ if (this.transcript.has(payload.id)) return
+ this.pushLine(
+ chatLine({
+ id: payload.id,
+ fromId: peerId,
+ nick: payload.nick,
+ text: payload.text,
+ ts: payload.ts,
+ self: false,
+ }),
)
+ this.emitMembers()
}
private async measure(peerId: string): Promise {
+ if (!this.joined) return
try {
const rtt = await this.transport.ping(peerId)
- const member = this.members.get(peerId)
- if (member) {
- member.rttMs = rtt
- this.emitMembers()
- }
+ if (this.joined && this.presence.setRtt(peerId, rtt)) this.emitMembers()
} catch {
// Ping can fail during ICE restart; presence still stands.
}
}
private pushSystem(text: string): void {
- this.pushLine({ kind: 'system', id: randomHex(6), text, ts: Date.now() })
+ this.pushLine(systemLine(randomHex(6), text, this.runtime.now()))
}
private pushLine(line: ChatLine): void {
- this.lines.push(line)
- this.lines = trimLines(this.lines)
- this.listeners.onLine?.(line)
+ const accepted = this.transcript.append(line)
+ if (accepted) this.listeners.onLine?.(accepted)
}
private refreshStatus(phase: SessionStatus['phase'], detail: string): void {
@@ -267,37 +248,33 @@ export class ChatSession {
}
private onVisibility = (): void => {
- if (document.hidden) this.stopTimers()
+ if (this.runtime.hidden()) this.stopTimers()
else if (this.joined) this.startTimers()
}
private bindVisibility(): void {
- document.addEventListener('visibilitychange', this.onVisibility)
- }
-
- private unbindVisibility(): void {
- document.removeEventListener('visibilitychange', this.onVisibility)
+ this.unbindVisibility?.()
+ this.unbindVisibility = this.runtime.onVisibilityChange(this.onVisibility)
}
private startTimers(): void {
this.stopTimers()
- this.helloTimer = window.setInterval(() => {
- if (!this.paused) void this.transport.send(createHelloPayload(this.identity.nick))
+ this.helloTimer = this.runtime.setInterval(() => {
+ void this.transport.send(createHelloPayload(this.identity.nick))
}, HELLO_INTERVAL_MS)
- this.relayTimer = window.setInterval(() => {
+ this.relayTimer = this.runtime.setInterval(() => {
this.refreshStatus(this.status.phase, this.status.detail)
}, RELAY_POLL_MS)
}
private stopTimers(): void {
if (this.helloTimer !== null) {
- window.clearInterval(this.helloTimer)
+ this.runtime.clearInterval(this.helloTimer)
this.helloTimer = null
}
if (this.relayTimer !== null) {
- window.clearInterval(this.relayTimer)
+ this.runtime.clearInterval(this.relayTimer)
this.relayTimer = null
}
- this.paused = document.hidden
}
}
diff --git a/src/core/transcript.test.ts b/src/core/transcript.test.ts
new file mode 100644
index 0000000..717b960
--- /dev/null
+++ b/src/core/transcript.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { MAX_LOG_LINES, MAX_SEEN_IDS } from '../config/app'
+import { chatLine, systemLine, Transcript } from './transcript'
+
+function chat(id: string, text = id): ReturnType {
+ return chatLine({ id, fromId: 'a', nick: '晚风', text, ts: 1, self: false })
+}
+
+describe('Transcript', () => {
+ it('hydrates, dedupes chat ids, and keeps system lines', () => {
+ const log = new Transcript()
+ log.hydrate([systemLine('s1', 'in', 1), chat('c1', 'hi')])
+ expect(log.append(chat('c1', 'hi'))).toBeNull()
+ expect(log.append(chat('c2', 'there'))?.id).toBe('c2')
+ expect(log.append(systemLine('s2', 'out', 2))?.kind).toBe('system')
+ expect(log.snapshot().map((line) => line.id)).toEqual(['s1', 'c1', 'c2', 's2'])
+ })
+
+ it('trims the live log and caps remembered ids', () => {
+ const log = new Transcript()
+ for (let i = 0; i < MAX_LOG_LINES + 5; i += 1) log.append(chat(`id-${i}`))
+ expect(log.snapshot()).toHaveLength(MAX_LOG_LINES)
+ expect(log.snapshot()[0]?.id).toBe('id-5')
+ expect(log.has('id-0')).toBe(true)
+
+ const ids = new Transcript()
+ for (let i = 0; i < MAX_SEEN_IDS + 10; i += 1) ids.remember(`seen-${i}`)
+ expect(ids.has('seen-0')).toBe(false)
+ expect(ids.has(`seen-${MAX_SEEN_IDS + 9}`)).toBe(true)
+ })
+})
diff --git a/src/core/transcript.ts b/src/core/transcript.ts
new file mode 100644
index 0000000..ef30188
--- /dev/null
+++ b/src/core/transcript.ts
@@ -0,0 +1,67 @@
+import { MAX_SEEN_IDS } from '../config/app'
+import { trimLines } from './cache'
+import type { ChatLine } from './types'
+
+export class Transcript {
+ private lines: ChatLine[] = []
+ private seen = new Set()
+ private seenOrder: string[] = []
+
+ hydrate(lines: ChatLine[]): ChatLine[] {
+ this.clear()
+ this.lines = trimLines(lines)
+ for (const line of this.lines) {
+ if (line.kind === 'chat') this.remember(line.id)
+ }
+ return this.lines
+ }
+
+ snapshot(): ChatLine[] {
+ return this.lines
+ }
+
+ has(id: string): boolean {
+ return this.seen.has(id)
+ }
+
+ remember(id: string): void {
+ if (this.seen.has(id)) return
+ this.seen.add(id)
+ this.seenOrder.push(id)
+ while (this.seenOrder.length > MAX_SEEN_IDS) {
+ const oldest = this.seenOrder.shift()
+ if (oldest) this.seen.delete(oldest)
+ }
+ }
+
+ append(line: ChatLine): ChatLine | null {
+ if (line.kind === 'chat') {
+ if (this.seen.has(line.id)) return null
+ this.remember(line.id)
+ }
+ this.lines.push(line)
+ this.lines = trimLines(this.lines)
+ return line
+ }
+
+ clear(): void {
+ this.lines = []
+ this.seen.clear()
+ this.seenOrder = []
+ }
+}
+
+export function chatLine(input: {
+ id: string
+ fromId: string
+ nick: string
+ text: string
+ ts: number
+ self: boolean
+}): ChatLine {
+ return { kind: 'chat', ...input }
+}
+
+export function systemLine(id: string, text: string, ts: number): ChatLine {
+ return { kind: 'system', id, text, ts }
+}
diff --git a/src/core/transports/create.ts b/src/core/transports/create.ts
new file mode 100644
index 0000000..16f5425
--- /dev/null
+++ b/src/core/transports/create.ts
@@ -0,0 +1,9 @@
+import type { SignalStrategy } from '../types'
+import { TrysteroTransport } from './trystero'
+import type { SignallingTransport } from './types'
+
+export type TransportFactory = (strategy: SignalStrategy) => SignallingTransport
+
+export function createTransport(strategy: SignalStrategy): SignallingTransport {
+ return new TrysteroTransport(strategy)
+}
diff --git a/src/core/transports/fake.ts b/src/core/transports/fake.ts
new file mode 100644
index 0000000..a5b484b
--- /dev/null
+++ b/src/core/transports/fake.ts
@@ -0,0 +1,67 @@
+import type { RelayStatus, SignalStrategy } from '../types'
+import type { JoinOptions, SignallingTransport, TransportHandlers } from './types'
+
+export class FakeTransport implements SignallingTransport {
+ readonly strategy: SignalStrategy
+ handlers: TransportHandlers | null = null
+ sent: Array<{ payload: unknown; target?: string }> = []
+ peers = new Set()
+ id = 'self-test'
+ pingMs = 12
+ joinGate: Promise | null = null
+ failJoin: Error | null = null
+ private abandoned = false
+
+ constructor(strategy: SignalStrategy = 'torrent') {
+ this.strategy = strategy
+ }
+
+ selfId(): string {
+ return this.id
+ }
+
+ async join(_options: JoinOptions, handlers: TransportHandlers): Promise {
+ this.abandoned = false
+ if (this.joinGate) await this.joinGate
+ if (this.abandoned) throw new Error('aborted')
+ if (this.failJoin) throw this.failJoin
+ this.handlers = handlers
+ }
+
+ async leave(): Promise {
+ this.abandoned = true
+ this.handlers = null
+ this.peers.clear()
+ }
+
+ async send(payload: unknown, target?: string): Promise {
+ if (target !== undefined) this.sent.push({ payload, target })
+ else this.sent.push({ payload })
+ }
+
+ ping(_peerId: string): Promise {
+ return Promise.resolve(this.pingMs)
+ }
+
+ peerIds(): string[] {
+ return [...this.peers]
+ }
+
+ relays(): RelayStatus[] {
+ return []
+ }
+
+ peerJoin(peerId: string): void {
+ this.peers.add(peerId)
+ this.handlers?.onPeerJoin(peerId)
+ }
+
+ peerLeave(peerId: string): void {
+ this.peers.delete(peerId)
+ this.handlers?.onPeerLeave(peerId)
+ }
+
+ payload(peerId: string, data: unknown): void {
+ this.handlers?.onPayload(peerId, data)
+ }
+}
diff --git a/src/ui/app.ts b/src/ui/app.ts
index fd4dbe2..83209d6 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -1,14 +1,13 @@
import { DEFAULT_ROOM, SOURCE_URL } from '../config/app'
-import { colorFromId, loadIdentity, persistNick } from '../core/identity'
+import { loadIdentity, persistNick } from '../core/identity'
import { loadRecentRooms, type RecentRoom } from '../core/recent'
-import { normalizeRoomName, sameRoom } from '../core/room'
+import { normalizeRoomName } from '../core/room'
import { RoomManager } from '../core/room-manager'
import { parseHash, toHash } from '../core/router'
-import type { Member, RoomSpec, SessionStatus, SignalStrategy, ThemePreference } from '../core/types'
+import type { RoomSpec, SignalStrategy, ThemePreference } from '../core/types'
+import { ChatPane } from './chat-pane'
import { copy } from './copy'
-import { el, empty, hostOf, wsLabel } from './dom'
-import { LogView } from './log-view'
-import { StampPicker } from './picker'
+import { el, empty } from './dom'
import { applyTheme, cycleTheme, loadThemePreference, persistThemePreference, themeLabel } from './theme'
function webrtcReady(): boolean {
@@ -173,7 +172,6 @@ export class App {
}
private async showChat(spec: RoomSpec): Promise {
- const current = this.manager.current()
if (!this.chat) {
this.chat = new ChatPane({
selfId: this.identity.id,
@@ -188,7 +186,6 @@ export class App {
this.chat.show()
this.refreshTabs(spec)
this.refreshTools(spec)
- if (current && sameRoom(current, spec)) return
try {
await this.manager.open(spec)
} catch (error) {
@@ -285,117 +282,6 @@ export class App {
}
}
-class ChatPane {
- readonly root: HTMLElement
- readonly log = new LogView()
- private statusEl: HTMLElement
- private membersEl: HTMLElement
- private peopleBtn: HTMLButtonElement
- private lastStatus = ''
-
- constructor(options: { selfId: string; send: (text: string) => void; typing: () => void }) {
- this.statusEl = el('div', { class: 'status' })
- this.membersEl = el('div', { class: 'members' })
- this.peopleBtn = el('button', { class: 'btn ghost people', type: 'button' }, [copy.people])
- this.peopleBtn.addEventListener('click', (event) => {
- event.stopPropagation()
- this.root.classList.toggle('show-side')
- })
- const composer = el('textarea', {
- rows: 1,
- placeholder: copy.placeholder,
- maxlength: 4000,
- enterkeyhint: 'send',
- }) as HTMLTextAreaElement
- composer.setAttribute('enterkeyhint', 'send')
- composer.setAttribute('autocapitalize', 'sentences')
- const picker = new StampPicker(composer)
- const stampBtn = el('button', { class: 'btn ghost', type: 'button', title: copy.stamps }, ['顔'])
- const sendBtn = el('button', { class: 'btn primary send', type: 'button', 'aria-label': copy.send }, ['↑'])
- stampBtn.addEventListener('click', (event) => {
- event.stopPropagation()
- picker.toggle()
- })
- this.peopleBtn.addEventListener('pointerdown', (event) => event.stopPropagation())
- document.addEventListener('pointerdown', (event) => {
- if (!this.root.contains(event.target as Node)) return
- if (!(event.target as HTMLElement).closest('.picker, .people, .side')) picker.hide()
- })
- picker.el.addEventListener('click', (event) => event.stopPropagation())
-
- const send = () => {
- const text = composer.value
- composer.value = ''
- composer.style.height = 'auto'
- picker.hide()
- options.send(text)
- }
- sendBtn.addEventListener('click', send)
- composer.addEventListener('keydown', (event) => {
- if (event.key === 'Enter' && !event.shiftKey) {
- event.preventDefault()
- send()
- }
- })
- composer.addEventListener('input', () => {
- composer.style.height = 'auto'
- composer.style.height = `${Math.min(composer.scrollHeight, 96)}px`
- options.typing()
- })
-
- this.root = el('div', { class: 'chat' }, [
- el('section', { class: 'transcript' }, [
- el('div', { class: 'status-row' }, [this.statusEl, this.peopleBtn]),
- this.log.el,
- picker.el,
- el('div', { class: 'composer' }, [stampBtn, composer, sendBtn]),
- ]),
- el('aside', { class: 'side' }, [
- el('h2', {}, [copy.members]),
- this.membersEl,
- ]),
- ])
- this.members([], options.selfId)
- }
-
- hide(): void {
- this.root.hidden = true
- this.root.classList.remove('show-side')
- }
-
- show(): void {
- this.root.hidden = false
- }
-
- status(status: SessionStatus): void {
- const open = status.relays.filter((relay) => relay.readyState === WebSocket.OPEN).length
- const key = `${status.phase}|${status.detail}|${status.peerCount}|${open}`
- if (key === this.lastStatus) return
- this.lastStatus = key
- this.statusEl.replaceChildren(
- el('i', { class: `dot${status.peerCount > 0 ? ' ok' : ''}` }),
- el('span', {}, [` ${status.detail}`]),
- )
- this.peopleBtn.textContent = `${copy.people} ${status.peerCount}`
- this.statusEl.title = status.relays.map((relay) => `${hostOf(relay.url)} ${wsLabel(relay.readyState)}`).join('\n')
- }
-
- members(list: Member[], selfId: string): void {
- this.membersEl.replaceChildren(
- el('div', { class: 'member' }, [
- el('b', {}, [copy.you]),
- el('span', { class: 'muted' }, [selfId.slice(0, 8)]),
- ]),
- ...list.map((member) =>
- el('div', { class: 'member' }, [
- el('b', { style: `color:${colorFromId(member.id)}` }, [member.nick]),
- el('span', { class: 'muted' }, [member.typing ? '输入中' : member.rttMs !== null ? `${Math.round(member.rttMs)}ms` : '']),
- ]),
- ),
- )
- }
-}
-
function field(label: string, control: HTMLElement): HTMLElement {
return el('label', { class: 'field' }, [el('span', {}, [label]), control])
}
diff --git a/src/ui/chat-pane.ts b/src/ui/chat-pane.ts
new file mode 100644
index 0000000..564d010
--- /dev/null
+++ b/src/ui/chat-pane.ts
@@ -0,0 +1,117 @@
+import { colorFromId } from '../core/identity'
+import type { Member, SessionStatus } from '../core/types'
+import { copy } from './copy'
+import { el, hostOf, wsLabel } from './dom'
+import { LogView } from './log-view'
+import { StampPicker } from './picker'
+
+export class ChatPane {
+ readonly root: HTMLElement
+ readonly log = new LogView()
+ private statusEl: HTMLElement
+ private membersEl: HTMLElement
+ private peopleBtn: HTMLButtonElement
+ private lastStatus = ''
+
+ constructor(options: { selfId: string; send: (text: string) => void; typing: () => void }) {
+ this.statusEl = el('div', { class: 'status' })
+ this.membersEl = el('div', { class: 'members' })
+ this.peopleBtn = el('button', { class: 'btn ghost people', type: 'button' }, [copy.people])
+ this.peopleBtn.addEventListener('click', (event) => {
+ event.stopPropagation()
+ this.root.classList.toggle('show-side')
+ })
+ const composer = el('textarea', {
+ rows: 1,
+ placeholder: copy.placeholder,
+ maxlength: 4000,
+ enterkeyhint: 'send',
+ }) as HTMLTextAreaElement
+ composer.setAttribute('enterkeyhint', 'send')
+ composer.setAttribute('autocapitalize', 'sentences')
+ const picker = new StampPicker(composer)
+ const stampBtn = el('button', { class: 'btn ghost', type: 'button', title: copy.stamps }, ['顔'])
+ const sendBtn = el('button', { class: 'btn primary send', type: 'button', 'aria-label': copy.send }, ['↑'])
+ stampBtn.addEventListener('click', (event) => {
+ event.stopPropagation()
+ picker.toggle()
+ })
+ this.peopleBtn.addEventListener('pointerdown', (event) => event.stopPropagation())
+ document.addEventListener('pointerdown', (event) => {
+ if (!this.root.contains(event.target as Node)) return
+ if (!(event.target as HTMLElement).closest('.picker, .people, .side')) picker.hide()
+ })
+ picker.el.addEventListener('click', (event) => event.stopPropagation())
+
+ const send = () => {
+ const text = composer.value
+ composer.value = ''
+ composer.style.height = 'auto'
+ picker.hide()
+ options.send(text)
+ }
+ sendBtn.addEventListener('click', send)
+ composer.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault()
+ send()
+ }
+ })
+ composer.addEventListener('input', () => {
+ composer.style.height = 'auto'
+ composer.style.height = `${Math.min(composer.scrollHeight, 96)}px`
+ options.typing()
+ })
+
+ this.root = el('div', { class: 'chat' }, [
+ el('section', { class: 'transcript' }, [
+ el('div', { class: 'status-row' }, [this.statusEl, this.peopleBtn]),
+ this.log.el,
+ picker.el,
+ el('div', { class: 'composer' }, [stampBtn, composer, sendBtn]),
+ ]),
+ el('aside', { class: 'side' }, [
+ el('h2', {}, [copy.members]),
+ this.membersEl,
+ ]),
+ ])
+ this.members([], options.selfId)
+ }
+
+ hide(): void {
+ this.root.hidden = true
+ this.root.classList.remove('show-side')
+ }
+
+ show(): void {
+ this.root.hidden = false
+ }
+
+ status(status: SessionStatus): void {
+ const open = status.relays.filter((relay) => relay.readyState === WebSocket.OPEN).length
+ const key = `${status.phase}|${status.detail}|${status.peerCount}|${open}`
+ if (key === this.lastStatus) return
+ this.lastStatus = key
+ this.statusEl.replaceChildren(
+ el('i', { class: `dot${status.peerCount > 0 ? ' ok' : ''}` }),
+ el('span', {}, [` ${status.detail}`]),
+ )
+ this.peopleBtn.textContent = `${copy.people} ${status.peerCount}`
+ this.statusEl.title = status.relays.map((relay) => `${hostOf(relay.url)} ${wsLabel(relay.readyState)}`).join('\n')
+ }
+
+ members(list: Member[], selfId: string): void {
+ this.membersEl.replaceChildren(
+ el('div', { class: 'member' }, [
+ el('b', {}, [copy.you]),
+ el('span', { class: 'muted' }, [selfId.slice(0, 8)]),
+ ]),
+ ...list.map((member) =>
+ el('div', { class: 'member' }, [
+ el('b', { style: `color:${colorFromId(member.id)}` }, [member.nick]),
+ el('span', { class: 'muted' }, [member.typing ? '输入中' : member.rttMs !== null ? `${Math.round(member.rttMs)}ms` : '']),
+ ]),
+ ),
+ )
+ }
+}