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 @@ -19,7 +19,7 @@
<link rel="canonical" href="https://andyccr.github.io/ZenChat/" />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<title>禅聊 ZenChat</title>
<script type="module" crossorigin src="/ZenChat/assets/index-DfhOSYdI.js"></script>
<script type="module" crossorigin src="/ZenChat/assets/index-DfinYlQ3.js"></script>
<link rel="stylesheet" crossorigin href="/ZenChat/assets/index-9O3FLWNJ.css">
</head>
<body>
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@ flowchart LR
| RoomManager | Room lifecycle, durable log cache, skip reconnect if the same room is already joined. UI never reaches into `ChatSession`. |
| ChatSession | Thin orchestrator: transport in, machine + heartbeat + router + outbound + presence + transcript out. |
| SessionMachine | Pure join/wait/live/error transitions. Status copy is derived from the machine. |
| Heartbeat | Hello interval, relay/RTT poll, visibility pause, stale presence prune. |
| PayloadRouter | Decode v1 hello/typing/ack/chat; session only applies effects. |
| Outbound | Delivery timers plus the original payload so a late ack or tap-to-resend can settle. Peers that advertise `ack` in hello get a receipt. |
| Presence / Transcript | Members + typing TTL + last-seen prune; capped message log, dedupe ids, durable hydrate (drop pending, keep failed). |
| Heartbeat | Hello interval, relay/RTT poll, visibility pause, stale presence prune (skips peers still in the transport set). |
| PayloadRouter | Decode v1 hello/typing/ack/chat and dispatch to session effects. |
| Outbound | Delivery timers that pause while the tab is hidden, plus the original payload so a late ack or tap-to-resend can settle. |
| Presence / Transcript | Members + typing TTL + last-seen prune; capped message log, shared durable-line normalize (drop pending, keep failed). |
| Room names | One canonical `RoomSpec` at the navigation boundary. Empty names are rejected; lobby/jump/hash/recent all store the same id. |
| Transport factory | Default Trystero torrent/nostr. Tests inject a fake. A `Libp2pTransport` can plug in here. |
| DataChannel | Encrypted chat after ICE succeeds. Trackers never see plaintext. |

Expand Down
3 changes: 0 additions & 3 deletions assets/index-DfhOSYdI.js

This file was deleted.

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

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<link rel="canonical" href="https://andyccr.github.io/ZenChat/" />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<title>禅聊 ZenChat</title>
<script type="module" crossorigin src="/ZenChat/assets/index-DfhOSYdI.js"></script>
<script type="module" crossorigin src="/ZenChat/assets/index-DfinYlQ3.js"></script>
<link rel="stylesheet" crossorigin href="/ZenChat/assets/index-9O3FLWNJ.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 @@ -14,5 +14,6 @@ export const HELLO_INTERVAL_MS = 25_000
export const RELAY_POLL_MS = 5000
export const PRESENCE_STALE_MS = HELLO_INTERVAL_MS * 3
export const ACK_TIMEOUT_MS = 8000
export const CACHE_DEBOUNCE_MS = 2000
export const SOURCE_URL = 'https://github.com/Andyccr/ZenChat'
export const PAGES_URL = 'https://andyccr.github.io/ZenChat/'
41 changes: 27 additions & 14 deletions src/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,20 @@ export function loadRoomLog(spec: RoomSpec): ChatLine[] {
}
}

export function durableLines(lines: ChatLine[]): ChatLine[] {
return trimLines(lines.map(dropPendingDelivery))
}

export function saveRoomLog(spec: RoomSpec, lines: ChatLine[]): void {
try {
sessionStorage.setItem(cacheKey(spec), JSON.stringify(durableLines(lines)))
} catch {
// Quota errors are non-fatal; the live session still works.
export function durableLine(line: ChatLine): ChatLine {
if (line.kind !== 'chat') return line
if (line.delivery !== 'pending' && line.delivery !== 'acked' && line.delivery !== 'failed') {
return {
kind: 'chat',
id: line.id,
fromId: line.fromId,
nick: line.nick,
text: line.text,
ts: line.ts,
self: line.self,
}
}
}

function dropPendingDelivery(line: ChatLine): ChatLine {
if (line.kind !== 'chat' || line.delivery !== 'pending') return line
if (line.delivery !== 'pending') return line
return {
kind: 'chat',
id: line.id,
Expand All @@ -45,6 +45,18 @@ function dropPendingDelivery(line: ChatLine): ChatLine {
}
}

export function durableLines(lines: ChatLine[]): ChatLine[] {
return trimLines(lines.map(durableLine))
}

export function saveRoomLog(spec: RoomSpec, lines: ChatLine[]): void {
try {
sessionStorage.setItem(cacheKey(spec), JSON.stringify(durableLines(lines)))
} catch {
// Quota errors are non-fatal; the live session still works.
}
}

export function trimLines(lines: ChatLine[]): ChatLine[] {
return lines.length > MAX_LOG_LINES ? lines.slice(-MAX_LOG_LINES) : lines
}
Expand All @@ -62,7 +74,8 @@ function isChatLine(value: unknown): value is ChatLine {
typeof row.nick === 'string' &&
typeof row.text === 'string' &&
typeof row.ts === 'number' &&
typeof row.self === 'boolean'
typeof row.self === 'boolean' &&
(row.delivery === undefined || row.delivery === 'pending' || row.delivery === 'acked' || row.delivery === 'failed')
)
}
return false
Expand Down
15 changes: 15 additions & 0 deletions src/core/outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('Outbound', () => {
expect(outbound.payload('msg-1')).toEqual({ text: 'hi' })
expect(outbound.ack('msg-1')).toBe(true)
expect(outbound.payload('msg-1')).toBeUndefined()
outbound.dispose()
})

it('clears a timer when the ack arrives first', () => {
Expand All @@ -26,5 +27,19 @@ describe('Outbound', () => {
clock.advance(100)
expect(expired).toEqual([])
expect(outbound.payload('msg-2')).toBeUndefined()
outbound.dispose()
})

it('does not expire while the tab is hidden, then settles remaining time on resume', () => {
const clock = createMemoryRuntime(0)
const expired: string[] = []
const outbound = new Outbound(clock.runtime, 100, (id) => expired.push(id))
outbound.expect('msg-3', { text: 'bg' })
clock.setHidden(true)
clock.advance(250)
expect(expired).toEqual([])
clock.setHidden(false)
expect(expired).toEqual(['msg-3'])
outbound.dispose()
})
})
58 changes: 47 additions & 11 deletions src/core/outbound.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
import type { Runtime } from './runtime'

type Clock = Pick<Runtime, 'now' | 'hidden' | 'setTimeout' | 'clearTimeout' | 'onVisibilityChange'>

export class Outbound<T = unknown> {
private timers = new Map<string, number>()
private payloads = new Map<string, T>()
private deadlines = new Map<string, number>()
private unbindVisibility: (() => void) | null = null

constructor(
private runtime: Pick<Runtime, 'setTimeout' | 'clearTimeout'>,
private runtime: Clock,
private timeoutMs: number,
private onExpire: (id: string) => void,
) {}
) {
this.unbindVisibility = this.runtime.onVisibilityChange(this.onVisibility)
}

expect(id: string, payload?: T): void {
this.cancelTimer(id)
if (payload !== undefined) this.payloads.set(id, payload)
this.timers.set(
id,
this.runtime.setTimeout(() => {
this.timers.delete(id)
this.onExpire(id)
}, this.timeoutMs),
)
this.deadlines.set(id, this.runtime.now() + this.timeoutMs)
this.arm(id)
}

ack(id: string): boolean {
if (!this.timers.has(id) && !this.payloads.has(id)) return false
if (!this.deadlines.has(id) && !this.payloads.has(id)) return false
this.cancel(id)
return true
}
Expand All @@ -34,11 +35,46 @@ export class Outbound<T = unknown> {

cancel(id: string): void {
this.cancelTimer(id)
this.deadlines.delete(id)
this.payloads.delete(id)
}

clear(): void {
for (const id of [...this.timers.keys(), ...this.payloads.keys()]) this.cancel(id)
for (const id of new Set([...this.deadlines.keys(), ...this.payloads.keys()])) this.cancel(id)
}

dispose(): void {
this.clear()
this.unbindVisibility?.()
this.unbindVisibility = null
}

private onVisibility = (): void => {
if (this.runtime.hidden()) {
for (const id of [...this.timers.keys()]) this.cancelTimer(id)
return
}
for (const id of [...this.deadlines.keys()]) this.arm(id)
}

private arm(id: string): void {
const deadline = this.deadlines.get(id)
if (deadline === undefined) return
this.cancelTimer(id)
if (this.runtime.hidden()) return
const wait = deadline - this.runtime.now()
if (wait <= 0) {
this.deadlines.delete(id)
this.onExpire(id)
return
}
this.timers.set(
id,
this.runtime.setTimeout(() => {
this.timers.delete(id)
this.arm(id)
}, wait),
)
}

private cancelTimer(id: string): void {
Expand Down
13 changes: 12 additions & 1 deletion src/core/payload-router.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { decodeIncoming } from './payload-router'
import { applyIncoming, decodeIncoming } from './payload-router'
import { createAckPayload, createChatPayload, createHelloPayload, createTypingPayload } from './protocol'

describe('decodeIncoming', () => {
Expand All @@ -21,4 +21,15 @@ describe('decodeIncoming', () => {
expect(decodeIncoming(chat, () => false)).toMatchObject({ type: 'chat', duplicate: false, text: '在吗' })
expect(decodeIncoming(chat, (id) => id === chat.id)).toMatchObject({ type: 'chat', duplicate: true })
})

it('dispatches decoded payloads to the matching handler', () => {
const seen: string[] = []
applyIncoming('peer-a', createHelloPayload('晚风'), () => false, {
hello: (peerId, nick, features) => seen.push(`${peerId}:${nick}:${features.join(',')}`),
typing: () => seen.push('typing'),
ack: () => seen.push('ack'),
chat: () => seen.push('chat'),
})
expect(seen).toEqual(['peer-a:晚风:ack'])
})
})
22 changes: 22 additions & 0 deletions src/core/payload-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ export type Incoming =
| { type: 'ack'; nick: string; id: string }
| { type: 'chat'; nick: string; id: string; text: string; ts: number; duplicate: boolean }

export type IncomingHandler = {
hello(peerId: string, nick: string, features: string[]): void
typing(peerId: string, nick: string): void
ack(peerId: string, id: string, nick: string): void
chat(peerId: string, incoming: Extract<Incoming, { type: 'chat' }>): void
}

export function decodeIncoming(raw: unknown, seen: (id: string) => boolean): Incoming | null {
const payload = parsePayload(raw)
if (!payload) return null
Expand All @@ -21,3 +28,18 @@ export function decodeIncoming(raw: unknown, seen: (id: string) => boolean): Inc
duplicate: seen(payload.id),
}
}

export function applyIncoming(
peerId: string,
raw: unknown,
seen: (id: string) => boolean,
handler: IncomingHandler,
): boolean {
const incoming = decodeIncoming(raw, seen)
if (!incoming) return false
if (incoming.type === 'hello') handler.hello(peerId, incoming.nick, incoming.features)
else if (incoming.type === 'typing') handler.typing(peerId, incoming.nick)
else if (incoming.type === 'ack') handler.ack(peerId, incoming.id, incoming.nick)
else handler.chat(peerId, incoming)
return true
}
10 changes: 10 additions & 0 deletions src/core/presence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,14 @@ describe('Presence', () => {
expect(presence.prune(100).map((member) => member.id)).toEqual(['fresh'])
expect(presence.list().map((member) => member.id)).toEqual(['stale'])
})

it('keeps connected peer ids even when lastSeenAt is stale', () => {
const clock = createMemoryRuntime(1_000)
const presence = new Presence(clock.runtime)
presence.upsert('live', '晚风')
presence.upsert('ghost', '青石')
clock.advance(200)
expect(presence.prune(100, ['live']).map((member) => member.id)).toEqual(['ghost'])
expect(presence.list().map((member) => member.id)).toEqual(['live'])
})
})
5 changes: 3 additions & 2 deletions src/core/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ export class Presence {
return [...this.members.values()].sort((a, b) => a.joinedAt - b.joinedAt)
}

prune(maxAgeMs: number): Member[] {
prune(maxAgeMs: number, keep: Iterable<string> = []): Member[] {
const held = new Set(keep)
const cutoff = this.runtime.now() - maxAgeMs
const gone = this.list().filter((member) => member.lastSeenAt < cutoff)
const gone = this.list().filter((member) => member.lastSeenAt < cutoff && !held.has(member.id))
for (const member of gone) this.remove(member.id)
return gone
}
Expand Down
5 changes: 5 additions & 0 deletions src/core/recent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ describe('recent rooms', () => {
expect(list).toHaveLength(2)
})

it('stores a canonical room name', () => {
rememberRoom({ name: ' 茶 室 ', password: '', strategy: 'torrent' })
expect(loadRecentRooms()[0]).toMatchObject({ name: '茶-室', strategy: 'torrent' })
})

it('asks for a password when a locked recent room has no stored secret', () => {
rememberRoom({ name: 'vault', password: 'x', strategy: 'torrent' })
expect(specFromRecent(loadRecentRooms()[0]!)).toBe('need-password')
Expand Down
11 changes: 6 additions & 5 deletions src/core/recent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MAX_RECENT_ROOMS } from '../config/app'
import { normalizeRoomName, recentRoomKey } from './room'
import { canonicalizeSpec, normalizeRoomName, recentRoomKey } from './room'
import { recallSecret } from './secrets'
import type { RoomSpec } from './types'

Expand Down Expand Up @@ -36,13 +36,14 @@ export function loadRecentRooms(): RecentRoom[] {
}

export function rememberRoom(spec: RoomSpec): RecentRoom[] {
const clean = canonicalizeSpec(spec) ?? spec
const next: RecentRoom = {
name: spec.name,
strategy: spec.strategy,
hasPassword: Boolean(spec.password),
name: normalizeRoomName(clean.name) || clean.name,
strategy: clean.strategy,
hasPassword: Boolean(clean.password),
visitedAt: Date.now(),
}
const others = loadRecentRooms().filter((item) => recentRoomKey(item) !== recentRoomKey(spec))
const others = loadRecentRooms().filter((item) => recentRoomKey(item) !== recentRoomKey(clean))
const list = [next, ...others].slice(0, MAX_RECENT_ROOMS)
localStorage.setItem(KEY, JSON.stringify(list))
return list
Expand Down
15 changes: 14 additions & 1 deletion src/core/room-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { CACHE_DEBOUNCE_MS } from '../config/app'
import { loadRoomLog } from './cache'
import { RoomManager } from './room-manager'
import { createMemoryRuntime } from './runtime'
import { FakeTransport } from './transports/fake'
Expand Down Expand Up @@ -37,7 +39,7 @@ function manager() {
},
},
)
return { rm, created, resets }
return { rm, created, resets, clock }
}

describe('RoomManager', () => {
Expand Down Expand Up @@ -102,4 +104,15 @@ describe('RoomManager', () => {
expect(rm.isJoined()).toBe(true)
expect(rm.current()?.name).toBe('two')
})

it('rejects an empty room name and snapshots chat after the debounce window', async () => {
const { rm, clock } = manager()
expect(await rm.open({ name: ' ', password: '', strategy: 'torrent' })).toBe(false)
const spec = { name: 'lobby', password: '', strategy: 'torrent' as const }
await rm.open(spec)
await rm.sendChat('hi')
expect(loadRoomLog(spec)).toEqual([])
clock.advance(CACHE_DEBOUNCE_MS)
expect(loadRoomLog(spec).some((line) => line.kind === 'chat' && line.text === 'hi')).toBe(true)
})
})
Loading
Loading