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:
co-authored by
Claude Sonnet 5
parent
aa1c46ff22
commit
f549248c08
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+195
-8
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user