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
@@ -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