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
+2
View File
@@ -7,6 +7,7 @@ import { PasswordInput } from '@/features/auth/components/PasswordInput';
import { BASE_APP_NAME } from '@/lib/config';
import { connect, getTinode } from '@/lib/tinode/connection';
import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
import { subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
export function LoginPage() {
@@ -48,6 +49,7 @@ export function LoginPage() {
const authToken = tinode.getAuthToken();
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
setMyUserId(tinode.getCurrentUserID() || null);
subscribeMeTopic().catch(() => {});
navigate('/messages');
} catch (err) {
setError(loginErrorMessage(err));
+2
View File
@@ -9,6 +9,7 @@ import { verifyRegisterCode } from '@/lib/api/auth';
import { ImApiError } from '@/lib/api/client';
import { getTinode, registerAccount } from '@/lib/tinode/connection';
import { registerErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
import { subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
const USERNAME_RE = /^[a-z0-9]{1,10}$/;
@@ -88,6 +89,7 @@ export function RegisterPage() {
const authToken = getTinode().getAuthToken();
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
setMyUserId((ctrl.params?.user as string | undefined) ?? null);
subscribeMeTopic().catch(() => {});
navigate('/messages');
} catch (err) {
setFormError(registerErrorMessage(err));