Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion 404.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
/>
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<title>禅聊 ZenChat</title>
<script type="module" crossorigin src="/ZenChat/assets/index-Dl0q0iBn.js"></script>
<script type="module" crossorigin src="/ZenChat/assets/index-D53iHK7S.js"></script>
<link rel="stylesheet" crossorigin href="/ZenChat/assets/index-BxoYAeQ1.css">
</head>
<body>
Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ flowchart LR
UI[UI Shell]
RM[RoomManager]
CS[ChatSession]
P[Presence]
L[Transcript]
TR[SignallingTransport]
end

Expand All @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions assets/index-D53iHK7S.js

Large diffs are not rendered by default.

3 changes: 0 additions & 3 deletions assets/index-Dl0q0iBn.js

This file was deleted.

2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
/>
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<title>禅聊 ZenChat</title>
<script type="module" crossorigin src="/ZenChat/assets/index-Dl0q0iBn.js"></script>
<script type="module" crossorigin src="/ZenChat/assets/index-D53iHK7S.js"></script>
<link rel="stylesheet" crossorigin href="/ZenChat/assets/index-BxoYAeQ1.css">
</head>
<body>
Expand Down
1 change: 1 addition & 0 deletions src/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/core/presence.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
76 changes: 76 additions & 0 deletions src/core/presence.ts
Original file line number Diff line number Diff line change
@@ -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<string, Member>()
private typingTimers = new Map<string, number>()

constructor(
private runtime: Pick<Runtime, 'now' | 'setTimeout' | 'clearTimeout'>,
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)
}
}
8 changes: 8 additions & 0 deletions src/core/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '听雨',
})
})
})
26 changes: 26 additions & 0 deletions src/core/recent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { loadRecentRooms, rememberRoom } from './recent'

beforeEach(() => {
const memory = new Map<string, string>()
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)
})
})
97 changes: 97 additions & 0 deletions src/core/room-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()
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<void>((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')
})
})
20 changes: 15 additions & 5 deletions src/core/room-manager.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -30,7 +32,7 @@ export class RoomManager {
}

async open(spec: RoomSpec): Promise<ChatSession | null> {
if (this.spec && this.session && sameRoom(this.spec, spec)) {
if (this.spec && this.session?.isJoined() && sameRoom(this.spec, spec)) {
return this.session
}

Expand All @@ -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
Expand Down
Loading
Loading