feat(web): real chat list + P2P conversation view against live Tinode data

Phase 2 core loop: chat list and single-chat conversation now read/write real
Tinode topic data end to end, replacing the earlier hardcoded placeholders.

- lib/tinode/topics.ts: per-topic pub-sub layered on connection.ts's pattern
  (Topic.onData/onInfo/etc are single-slot callbacks, claimed once and fanned
  out). Implements the old app's subscribe lifecycle: `me` subscribed once
  per session (metadata only), each contact gets a one-shot preview
  subscription (withLaterData(1), never left), opening a conversation
  leaves-then-resubscribes with a full history page (withData, not
  withLaterData -- a preview prefetch already nudging _maxSeq would make
  withLaterData return empty).
- lib/tinode/hooks.ts: useChatList()/useTopicMessages() via
  useSyncExternalStore, snapshots cached and only invalidated on real
  topic-change notifications (not recomputed every render).
- lib/tinode/send.ts: sendText/retryMessage via createMessage+publishDraft
  (optimistic local echo), not the simpler publish() -- matches the old
  app's approach. retryMessage flushes the failed local entry and rebuilds
  before republishing, per the old app's context-menu retry handler.
- lib/messages/{time,avatar,attachments,status,preview,dispatch,drafty}:
  formatting utilities. Avatar color hash ports the old app's Java-style
  string hash but with modulus 11 (this design's palette) not the old app's
  16. @mention renders flat accent blue per the new design, not the old
  app's per-user hash color -- a deliberate simplification. File-type icons
  (PDF/Word/Excel/PPT) are new-design-only; the old app buckets all
  documents identically. Date/time formatting is written fresh against the
  design spec (today+time combined, weekday tier) since the old app never
  combines a relative-date word with a time in one string.
- ChatListColumn, MessageBubble, ConversationView: real components replacing
  the placeholder chat list and "占位" conversation view.
- tinode-sdk.d.ts: expanded with the real Topic/MetaGetBuilder/Drafty
  surface needed above, verified against the installed SDK source
  (createMessage/publishDraft/messages/msgStatus/noteRead/flushMessage/
  Drafty.format's exact treeBottomUp contract, MESSAGE_STATUS_* statics).

Verified end to end against the real backend: used the still-functional old
app (a second account, ixp355087) to create a real friendship and exchange
messages with the new app's test account, confirming the new chat list and
conversation view correctly render pre-existing real data, sending a new
message shows the right optimistic-then-delivered status transition, and
the old app receives it live in return.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-08 01:49:14 +08:00
co-authored by Claude Sonnet 5
parent aa1c46ff22
commit f549248c08
18 changed files with 968 additions and 68 deletions
+19 -1
View File
@@ -1,6 +1,9 @@
import { useSyncExternalStore } from 'react';
import { useCallback, useSyncExternalStore } from 'react';
import type { Contact, StoredMessage } from 'tinode-sdk';
import { getConnectionStatus, getTinode, subscribeConnectionStatus } from './connection';
import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicChanges } from './topics';
/** The current WS connection status, reactive via useSyncExternalStore. */
export function useConnectionStatus() {
@@ -11,3 +14,18 @@ export function useConnectionStatus() {
export function useTinode() {
return getTinode();
}
const EMPTY_MESSAGES: StoredMessage[] = [];
const noopSubscribe = () => () => {};
/** Live message list for one topic. Empty (and non-reactive) until a topicName is provided. */
export function useTopicMessages(topicName: string | undefined): StoredMessage[] {
const subscribe = useCallback((onChange: () => void) => (topicName ? subscribeTopicChanges(topicName, onChange) : noopSubscribe()), [topicName]);
const getSnapshot = useCallback(() => (topicName ? getMessagesSnapshot(topicName) : EMPTY_MESSAGES), [topicName]);
return useSyncExternalStore(subscribe, getSnapshot);
}
/** Live chat-list rows, derived from `me.contacts()` and re-read whenever any topic changes. */
export function useChatList(): Contact[] {
return useSyncExternalStore(subscribeAnyTopicChange, getChatListSnapshot);
}
+32
View File
@@ -0,0 +1,32 @@
import { Drafty, type DraftyContent, type StoredMessage, type Topic } from 'tinode-sdk';
/**
* Plain-text send: createMessage + publishDraft (not the simpler `.publish()`), matching the old
* app's own choice -- publishDraft inserts the message into the local cache and fires onData
* immediately, showing "sending" before the network round trip resolves, rather than waiting on it.
*/
export function sendText(topic: Topic, text: string, head?: Record<string, unknown>): Promise<unknown> {
const parsed = Drafty.parse(text);
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
const msg = topic.createMessage(content, false);
if (head) msg.head = Object.assign(msg.head || {}, head);
return topic.publishDraft(msg, Promise.resolve());
}
/**
* Retries a failed send by flushing the old local entry and building+publishing a fresh one with
* the same content/head -- ported from the old app's context-menu "重试" handler (context-menu.jsx),
* not a simple re-publish of the same object. Returns undefined if `seq` is no longer in the local
* cache (already flushed, or never existed).
*/
export function retryMessage(topic: Topic, seq: number): Promise<unknown> | undefined {
const failed = topic.flushMessage(seq);
if (!failed) return undefined;
const msg = topic.createMessage((failed.content as DraftyContent | string) ?? '', false);
if (failed.head) msg.head = Object.assign(msg.head || {}, failed.head);
return topic.publishDraft(msg, Promise.resolve());
}
export function messageIsMine(msg: StoredMessage, myUserId: string | null): boolean {
return !!myUserId && msg.from === myUserId;
}
+195 -8
View File
@@ -91,21 +91,143 @@ declare module 'tinode-sdk' {
latestMessage?: () => StoredMessage | undefined;
}
/**
* `_sending`/`_failed`/`_fatal`/`_cancelled` are the SDK's own optimistic-send bookkeeping fields,
* mutated in place on the same object the app holds -- not separate app-tracked state. `seq` is a
* local placeholder (`>= LOCAL_SEQID`, see `LOCAL_SEQID` below) until the server confirms and
* `swapMessageId` rewrites it to the real sequence number.
*/
export interface StoredMessage {
seq: number;
from?: string;
ts?: Date;
content?: unknown;
content?: DraftyContent | string;
head?: Record<string, unknown>;
_sending?: boolean;
_failed?: boolean;
_fatal?: boolean;
_cancelled?: boolean;
_status?: number;
}
/**
* Drafty's own document shape: `txt` is the plain-text backbone, `fmt` is a list of (offset,
* length) format spans referencing either a style tag (`tp`) or an entity index (`key` into
* `ent`), `ent` holds the actual entity payloads (images, links, mentions, quotes, ...). Kept
* loose (not modeling every known `tp`/`data` shape) since this app only ever constructs content
* through the `Drafty.*` builder functions below rather than hand-assembling this object.
*/
export interface DraftyContent {
txt: string;
fmt?: Array<{ at?: number; len?: number; tp?: string; key?: number }>;
ent?: Array<{ tp: string; data: Record<string, unknown> }>;
}
/**
* Fluent GET-query builder returned by `Topic.startMetaQuery()`. `.build()` produces the `params`
* object `Topic.getMeta()`/`Tinode.getMeta()` expect. Verified against tinode.dev.js:3858-3976 --
* the old app never calls `withEarlierData` (paging always goes through `getMessagesPage`'s gap-fill
* path instead, to correctly backfill ranges with server-side deletions).
*/
export class MetaGetBuilder {
withData(since?: number, before?: number, limit?: number): this;
withLaterData(limit?: number): this;
withEarlierData(limit?: number): this;
withDataRanges(ranges: Array<{ low: number; hi?: number }>, limit?: number): this;
withDesc(ims?: Date): this;
withLaterDesc(): this;
withSub(ims?: Date, limit?: number, userOrTopic?: string): this;
withOneSub(ims?: Date, userOrTopic?: string): this;
withLaterOneSub(userOrTopic?: string): this;
withLaterSub(limit?: number): this;
withTags(): this;
withCred(): this;
withAux(): this;
withDel(since?: number, limit?: number): this;
withLaterDel(limit?: number): this;
extract(what: string): unknown;
build(): Record<string, unknown> | undefined;
}
export class Topic {
name: string;
subscribe(): Promise<unknown>;
created?: Date;
updated?: Date;
touched?: Date;
public?: TopicDescPublic | null;
private?: TopicDescPrivate | null;
subscribe(getParams?: unknown, setParams?: unknown): Promise<{ code: number; topic?: string; ts?: Date; params?: Record<string, unknown> }>;
leave(unsub?: boolean): Promise<unknown>;
/** Debounced 100ms-ish delayed leave -- used when a view might immediately reopen the same topic. */
leaveDelayed(unsub: boolean, delayMs: number): void;
getMeta(query: unknown): Promise<unknown>;
publish(data: unknown, head?: Record<string, unknown>): Promise<unknown>;
startMetaQuery(): MetaGetBuilder;
/** Builds the `{pub}` packet shape but does not send it -- pair with `publishDraft`/`publishMessage`. */
createMessage(content: DraftyContent | string, noEcho?: boolean): StoredMessage;
/**
* Optimistically inserts `pub` into the local message cache and fires `onData` immediately
* (so the bubble shows "sending" with no network round trip), then sends once `prom` resolves.
* `prom` is typically `Promise.all(...)` over any attachment uploads that must finish first; pass
* `Promise.resolve()` when there's nothing to wait on. This is what the old app used for every
* real send (not the simpler `publish()`), specifically to get that optimistic local echo.
*/
publishDraft(pub: StoredMessage, prom?: Promise<unknown>): Promise<{ code: number; ts?: Date; params?: { seq: number } }>;
publishMessage(pub: StoredMessage): Promise<{ code: number; ts?: Date; params?: { seq: number } }>;
/** Convenience wrapper: `createMessage` + immediate `publishMessage`, no optimistic local echo. */
publish(data: DraftyContent | string, noEcho?: boolean): Promise<unknown>;
isSubscribed(): boolean;
isP2PType(): boolean;
isGroupType(): boolean;
isSelfType(): boolean;
getAccessMode(): AccessMode | undefined;
/**
* Iterates locally-cached messages in `[sinceId, beforeId]` (both omitted = everything cached).
* `callback(msg, prevMsg, nextMsg, bufferIndex)` fires once per message, already resolved to the
* latest edited version if the message was replaced. There is no separate app-held message array
* anywhere in this codebase's design -- call this fresh to get the current list, and re-call it
* whenever `onData`/`onInfo` fires.
*/
messages(
callback: (msg: StoredMessage, prev: StoredMessage | undefined, next: StoredMessage | undefined, idx: number) => void,
sinceId?: number,
beforeId?: number,
context?: unknown,
): void;
findMessage(seq: number): StoredMessage | undefined;
latestMessage(): StoredMessage | undefined;
/** Locally-queued (not-yet-confirmed-sent) messages -- used to auto-resend after a reconnect. */
queuedMessages(callback: (msg: StoredMessage) => void, context?: unknown): void;
/** Removes a message from the local cache (e.g. before rebuilding+republishing a failed send). Returns the removed message, or undefined if seq wasn't found. */
flushMessage(seq: number): StoredMessage | undefined;
maxMsgSeq(): number;
minMsgSeq(): number;
isNewMessage(seqId: number): boolean;
/** Locally-cached-range gap detector feeding `getMessagesPage`'s `gaps` argument. */
msgHasMoreMessages(min: number, max: number, newer: boolean): Array<{ low: number; hi?: number }>;
getMessagesPage(limit: number, gaps?: Array<{ low: number; hi?: number }>, min?: number, max?: number, newer?: boolean): Promise<unknown>;
/** Per-message delivery/read status -- one of the `Tinode.MESSAGE_STATUS_*` constants. Not cached
* anywhere; recompute on every relevant re-render (`upd:true` also persists it to local storage). */
msgStatus(msg: StoredMessage, upd?: boolean): number;
msgReadCount(seq: number): number;
msgRecvCount(seq: number): number;
/** Sends a `{note}` read receipt if `seq` advances this topic's locally-recorded read watermark. */
noteRead(seq: number): void;
delMessagesAll(hardDel?: boolean): Promise<unknown>;
delMessagesList(list: number[], hardDel?: boolean): Promise<unknown>;
/** Single-slot callbacks the app wires fresh per "currently open" topic and tears down on leave. */
onData?: (data?: StoredMessage) => void;
onAllMessagesReceived?: (count: number) => void;
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; seq?: number; from?: string }) => void;
onMetaDesc?: (desc: unknown) => void;
onSubsUpdated?: (subs: string[], count?: number) => void;
onPres?: (pres: unknown) => void;
onAuxUpdated?: (aux: unknown) => void;
}
export class MeTopic extends Topic {
@@ -146,20 +268,85 @@ declare module 'tinode-sdk' {
/** Wraps a single {meth,val,resp,params} credential into the array shape {acc}/createAccountBasic expects. */
static credential(cred: CredentialInput): CredentialParam[] | null;
/**
* Per-message delivery/read status constants returned by `Topic.msgStatus()`. Exposed as static
* props on the class itself (verified tinode.dev.js:7448-7456), not string literals, so reference
* these rather than hardcoding the underlying numbers (0/10/20/30/40/50/60/70/80).
*/
static MESSAGE_STATUS_NONE: number;
static MESSAGE_STATUS_QUEUED: number;
static MESSAGE_STATUS_SENDING: number;
static MESSAGE_STATUS_FAILED: number;
static MESSAGE_STATUS_FATAL: number;
static MESSAGE_STATUS_SENT: number;
static MESSAGE_STATUS_RECEIVED: number;
static MESSAGE_STATUS_READ: number;
/** Status value for a message that's TO me (i.e. not mine) -- msgStatus never distinguishes read/unread for incoming messages this way, that's tracked per-topic instead. */
static MESSAGE_STATUS_TO_ME: number;
/** Local placeholder seq threshold (0xFFFFFFF) -- a message with `seq >= this` hasn't been confirmed by the server yet. */
static MSG_DELETE_AGE: string;
onConnect?: () => void;
onDisconnect?: (err?: Error) => void;
onLogin?: (code: number, text: string) => void;
onNetworkProbe?: () => void;
/**
* Fires on every SDK-internal reconnect attempt. `seconds` is the SDK's own next-retry countdown
* (exponential backoff, ~2s base doubling up to 10 iterations, +/-30% jitter -- entirely internal
* to the SDK, this app does not implement its own backoff). `promise` is only present while a
* retry is actually in flight; when absent, the app is expected to run its own 1s countdown
* display down to the next attempt.
*/
onAutoreconnectIteration?: (seconds: number, promise?: Promise<unknown>) => void;
}
export const Drafty: {
init(text: string): unknown;
parse(text: string): unknown;
toPlainText(content: unknown): string;
preview(content: unknown, length: number, cutFirst?: boolean): unknown;
videoCall(audioOnly: boolean): unknown;
init(text: string): DraftyContent;
parse(text: string): DraftyContent;
toPlainText(content: DraftyContent | string): string;
preview(content: DraftyContent | string, length: number, cutFirst?: boolean): DraftyContent;
videoCall(audioOnly: boolean): DraftyContent;
isValid(content: unknown): boolean;
isPlainText(content: DraftyContent | string): boolean;
hasEntities(content: DraftyContent | string): boolean;
entities(content: DraftyContent | string, callback: (data: Record<string, unknown> | undefined) => void, context?: unknown): void;
/** Deep-copies `content` and strips anything the server would reject when re-sent (e.g. as a quote/forward payload). */
sanitizeEntities(content: DraftyContent): DraftyContent;
/** Concatenates two Drafty documents, remapping `fmt[].key` entity indices in `second`. */
append(first: DraftyContent | undefined, second: DraftyContent): DraftyContent;
/** Builds a quoted-reply PREVIEW block (not the wire content itself -- the old app inlines the
* quoted body via `append`+`sanitizeEntities` for the actual sent message; `quote()` is only for
* rendering the reply-preview strip above the composer). */
quote(header: string, uid: string, body: DraftyContent): DraftyContent;
mention(name: string, uid: string): DraftyContent;
forwardedContent(original: DraftyContent): DraftyContent;
appendLink(content: DraftyContent | undefined, linkData: { txt: string; url: string }): DraftyContent;
appendImage(content: DraftyContent | undefined, imageDesc: Record<string, unknown>): DraftyContent;
insertImage(content: DraftyContent | undefined, at: number, imageDesc: Record<string, unknown>): DraftyContent;
appendAudio(content: DraftyContent | undefined, audioDesc: Record<string, unknown>): DraftyContent;
insertAudio(content: DraftyContent | undefined, at: number, audioDesc: Record<string, unknown>): DraftyContent;
attachFile(content: DraftyContent | undefined, attachmentDesc: { mime: string; data?: string; filename?: string; refurl?: string; size?: number; urlPromise?: Promise<string> }): DraftyContent;
appendButton(content: DraftyContent | undefined, title: string, name: string, actionType: 'url' | 'pub', actionValue: string, refUrl?: string): DraftyContent | null;
appendLineBreak(content: DraftyContent | undefined): DraftyContent;
appendTheCard(content: DraftyContent | undefined, theCardData: Record<string, unknown>): DraftyContent;
getContentType(): string;
/** Preview-length trims a Drafty document to `limit` chars for a reply-quote preview (README's QUOTED_REPLY_LENGTH=30 usage). */
replyContent(content: DraftyContent, limit: number): DraftyContent;
/**
* Bottom-up tree walk over `content`: `formatterFn(style, data, values, key, stack)` is invoked
* once per node. `style` is `undefined` (not a style-tag string) for a plain-text run or the root
* text container -- verified against the SDK's own `treeBottomUp` (tinode.dev.js:2971-2996): a
* leaf with no children and no `.text` gets `values === null`; a leaf with `.text` and no children
* gets `values === [text]`; only a node with actual child nodes gets multiple entries. A formatter
* MUST handle `style === undefined` (render `values` through as-is) and `values === null` (nothing
* to render) -- e.g. the old app's `fullFormatter` returns React elements per node, and this
* function assembles them into the final tree with no separate "Drafty AST -> React" pass.
*/
format<T>(
content: DraftyContent | string,
formatterFn: (style: string | undefined, data: Record<string, unknown> | undefined, values: T[] | null, key: number | string, stack: unknown[]) => T,
context?: unknown,
): T;
[key: string]: unknown;
};
+149
View File
@@ -0,0 +1,149 @@
// Per-topic pub-sub + subscription lifecycle, layered on top of connection.ts's connection-level
// pub-sub. Topic.onData/onInfo/etc. are single-slot callbacks exactly like Tinode.onConnect/etc.
// (see connection.ts's header comment) -- this module claims them once per topic and fans out to
// any number of subscribers, which is what hooks.ts's useSyncExternalStore-based hooks consume.
//
// Subscription lifecycle mirrors the old app's (verified against its tinode-web.jsx/messages-view.jsx):
// - The `me` topic is subscribed once per session, metadata only (no message bodies).
// - Every contact gets a one-shot "preview" subscription (withLaterData(1)) so the chat list can
// show a last-message preview; it is never explicitly left afterward.
// - Opening a conversation subscribes with a full history page. A topic already subscribed (from
// its preview prefetch) has its subscribe() short-circuited by the SDK if called again with new
// getParams -- so this always leaves first when already subscribed, then resubscribes.
import type { Contact, StoredMessage, Topic } from 'tinode-sdk';
import { MESSAGES_PAGE } from '@/lib/config';
import { getTinode } from './connection';
const topicListeners = new Map<string, Set<() => void>>();
const anyTopicListeners = new Set<() => void>();
const wiredTopics = new WeakSet<Topic>();
const previewPrefetched = new Set<string>();
const messageSnapshots = new Map<string, StoredMessage[]>();
function notifyTopic(topicName: string) {
messageSnapshots.delete(topicName);
topicListeners.get(topicName)?.forEach((cb) => cb());
anyTopicListeners.forEach((cb) => cb());
}
export function subscribeTopicChanges(topicName: string, callback: () => void): () => void {
let set = topicListeners.get(topicName);
if (!set) {
set = new Set();
topicListeners.set(topicName, set);
}
set.add(callback);
return () => set.delete(callback);
}
/** Fires on ANY topic's change -- used by the chat list, which derives its rows from every contact
* at once (`me.contacts()`) rather than tracking one topic in particular. */
export function subscribeAnyTopicChange(callback: () => void): () => void {
anyTopicListeners.add(callback);
return () => anyTopicListeners.delete(callback);
}
function wireTopicCallbacks(topic: Topic) {
if (wiredTopics.has(topic)) return;
const notify = () => notifyTopic(topic.name);
topic.onData = notify;
topic.onInfo = notify;
topic.onMetaDesc = notify;
topic.onSubsUpdated = notify;
topic.onPres = notify;
topic.onAuxUpdated = notify;
topic.onAllMessagesReceived = notify;
wiredTopics.add(topic);
}
/** Cached per topic; only recomputed when notifyTopic() invalidates it, so useSyncExternalStore's
* getSnapshot returns a stable reference between real changes instead of a new array every render. */
export function getMessagesSnapshot(topicName: string): StoredMessage[] {
let cached = messageSnapshots.get(topicName);
if (!cached) {
const topic = getTinode().getTopic(topicName);
cached = [];
topic.messages((msg) => cached!.push(msg));
messageSnapshots.set(topicName, cached);
}
return cached;
}
/** Call once at session start (after login) -- chat-list metadata only, no message bodies. */
export function subscribeMeTopic(): Promise<unknown> {
const me = getTinode().getMeTopic();
wireTopicCallbacks(me);
return me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build());
}
/**
* One-shot per topic: subscribes just enough to learn its latest message for the chat-list preview.
* Deliberately never left afterward (matches the old app) -- simpler than tracking teardown for a
* subscription that's cheap to leave attached and likely to be reopened soon anyway.
*/
export function prefetchPreview(topicName: string): void {
if (previewPrefetched.has(topicName)) return;
previewPrefetched.add(topicName);
const topic = getTinode().getTopic(topicName);
wireTopicCallbacks(topic);
topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {
previewPrefetched.delete(topicName);
});
}
/**
* Opens a topic for full conversation view: fetches a page of history and wires live updates.
* Uses withData(undefined, undefined, limit), NOT withLaterData(limit) -- if prefetchPreview already
* nudged the topic's local _maxSeq up to just that one preview message, withLaterData would ask for
* "newer than that" and the server would correctly report back empty.
*
* Known gap: for a brand-new P2P topic the peer has never messaged, the old app defers subscribing
* at all until the user's first send (avoiding server-side subscription state for chats opened but
* never used) -- not yet replicated here since the new-conversation picker flow doesn't exist yet.
*/
export async function openConversation(topicName: string): Promise<Topic> {
const topic = getTinode().getTopic(topicName);
wireTopicCallbacks(topic);
if (topic.isSubscribed()) {
await topic.leave(false);
}
const query = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux().withData(undefined, undefined, MESSAGES_PAGE);
await topic.subscribe(query.build());
return topic;
}
/** Soft-leave (detach, not unsubscribe) -- cached messages stay in the SDK's local buffer. */
export function closeConversation(topicName: string): void {
getTinode().getTopic(topicName).leave(false).catch(() => {});
}
let chatListSnapshot: Contact[] | null = null;
/**
* Cached the same way getMessagesSnapshot is, but invalidated by ANY topic's change (a new message
* on a contact that isn't currently open still needs to move that row and refresh its preview).
* Triggers each contact's one-shot preview prefetch as a side effect of being read -- cheap and
* idempotent (prefetchPreview no-ops after the first call per topic), so it's fine to do this on
* every rebuild rather than needing a separate "wire up previews" pass.
*/
export function getChatListSnapshot(): Contact[] {
if (chatListSnapshot) return chatListSnapshot;
const byTopic = new Map<string, Contact>();
getTinode()
.getMeTopic()
.contacts((contact) => {
if (!contact.topic) return;
prefetchPreview(contact.topic);
byTopic.set(contact.topic, contact);
});
const rows = [...byTopic.values()];
chatListSnapshot = rows;
return rows;
}
function invalidateChatListSnapshot() {
chatListSnapshot = null;
}
anyTopicListeners.add(invalidateChatListSnapshot);