import { type Signal, signal } from "@preact/signals-core"; import type { ReadonlySignal } from "../../../core/signals/createStore.ts"; import { toReadonly } from "../../../core/signals/createStore.ts"; import type { ChannelNumber } from "../../../core/types.ts"; import type { Message } from "../domain/Message.ts"; import { MessageState } from "../domain/MessageState.ts"; /** * Messages grouped by conversation bucket. Direct messages are keyed by * `direct:`; broadcast messages by `channel:`. */ export class ChatStore { private readonly buckets = new Map>(); private readonly readBuckets = new Map>(); channelKey(channel: ChannelNumber): string { return `channel:${channel}`; } directKey(peer: number): string { return `direct:${peer}`; } messagesForChannel(channel: ChannelNumber): ReadonlySignal { return this.readBucket(this.channelKey(channel)); } messagesForDirect(peer: number): ReadonlySignal { return this.readBucket(this.directKey(peer)); } append(key: string, message: Message): void { const bucket = this.writeBucket(key); bucket.value = [...bucket.value, message]; } /** * Whether a message with `id` is already present in the bucket. Used by * the inbound-packet subscriber to skip duplicates of an outbound message * that was optimistically appended on send. */ hasMessage(key: string, id: number): boolean { const bucket = this.buckets.get(key); if (!bucket) return false; return bucket.value.some((m) => m.id === id); } /** * Inserts an older message at the front of the bucket. Used when paginating * backwards; preserves chronological order because callers feed older-first. */ prepend(key: string, message: Message): void { const bucket = this.writeBucket(key); bucket.value = [message, ...bucket.value]; } /** * Empties a single conversation bucket. Signal subscribers re-render with * an empty array. */ clearBucket(key: string): void { const bucket = this.buckets.get(key); if (bucket && bucket.value.length > 0) bucket.value = []; } /** * Empties every existing bucket. Buckets that have never been subscribed * to don't exist yet, so nothing needs doing for them. */ clearAll(): void { for (const bucket of this.buckets.values()) { if (bucket.value.length > 0) bucket.value = []; } } updateState(id: number, state: MessageState): void { for (const [, bucket] of this.buckets) { const idx = bucket.value.findIndex((m) => m.id === id); if (idx !== -1) { const next = bucket.value.slice(); const existing = next[idx]; if (!existing) continue; next[idx] = { ...existing, state }; bucket.value = next; return; } } } private writeBucket(key: string): Signal { let bucket = this.buckets.get(key); if (!bucket) { bucket = signal([]); this.buckets.set(key, bucket); this.readBuckets.set(key, toReadonly(bucket)); } return bucket; } private readBucket(key: string): ReadonlySignal { this.writeBucket(key); const read = this.readBuckets.get(key); if (!read) throw new Error("unreachable"); return read; } }