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));
@@ -0,0 +1,73 @@
import { Link, useParams } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatListTime } from '@/lib/messages/time';
import { formatMessagePreview } from '@/lib/messages/preview';
import { useChatList } from '@/lib/tinode/hooks';
import { useSessionStore } from '@/stores/session';
/**
* Chat-list row sort: by `touched` (last activity) descending. Pinned-conversations-first isn't
* wired yet -- that's driven by the P2P conversation-settings API (GET/PATCH /conversations/{topic}/
* settings {pinned}), which the detail panel doesn't build until its "置顶聊天" toggle lands.
*/
export function ChatListColumn() {
const { topicId } = useParams();
const myUserId = useSessionStore((s) => s.myUserId);
const contacts = useChatList();
const sorted = [...contacts].sort((a, b) => (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0));
return (
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">消息</h1>
<Icon name="add_comment" className="text-text-heading" />
</header>
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
placeholder="搜索会话、联系人、消息"
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
/>
</div>
<div className="flex flex-1 flex-col overflow-y-auto">
{sorted.map((contact) => {
const name = contact.public?.fn || contact.topic;
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
const active = topicId === contact.topic;
return (
<Link
key={contact.topic}
to={`/messages/${contact.topic}`}
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item px-4 py-2.5 no-underline hover:bg-[#f6f8fb] ${
active ? 'bg-accent-selected-bg' : 'bg-white'
}`}
>
<span className={`relative grid h-11 w-11 place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(contact.topic || '')}`}>
{avatarLetter(name)}
{contact.online ? (
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-online" />
) : null}
</span>
<span className="flex min-w-0 flex-col gap-1">
<span className="flex items-center gap-1.5">
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
</span>
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
</span>
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
<time className="text-xs text-text-tertiary-3">{contact.touched ? formatListTime(contact.touched) : ''}</time>
{contact.unread ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{contact.unread}
</b>
) : null}
</span>
</Link>
);
})}
</div>
</aside>
);
}
+112 -7
View File
@@ -1,26 +1,92 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useOutletContext, useParams } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { MessageRow } from '@/features/messages/MessageBubble';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { sendText } from '@/lib/tinode/send';
import { closeConversation, openConversation } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
interface OutletCtx {
onToggleDetail: () => void;
}
/** Placeholder chat surface -- real header/bubbles/composer land in Phase 1. */
export function ConversationView() {
const { topicId } = useParams();
const { onToggleDetail } = useOutletContext<OutletCtx>();
const myUserId = useSessionStore((s) => s.myUserId);
const tinode = useTinode();
const contacts = useChatList();
const contact = contacts.find((c) => c.topic === topicId);
const messages = useTopicMessages(topicId);
const [draft, setDraft] = useState('');
const streamRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!topicId) return undefined;
openConversation(topicId).catch(() => {});
return () => closeConversation(topicId);
}, [topicId]);
useEffect(() => {
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
}, [messages.length]);
useEffect(() => {
if (!topicId || messages.length === 0) return;
tinode.getTopic(topicId).noteRead(0);
}, [topicId, messages.length, tinode]);
if (!topicId) return null;
const topic = tinode.getTopic(topicId);
const peerName = contact?.public?.fn || topicId;
const isGroup = topic.isGroupType();
const submit = () => {
const text = draft.trim();
if (!text) return;
setDraft('');
sendText(topic, text);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Enter' || e.shiftKey) return;
// Guards against CJK IME candidate-selection Enter keystrokes being mistaken for submit --
// a real, unfixed bug class in the old app (confirmed by its own research: zero isComposing/
// compositionstart/keyCode-229 guards anywhere in that codebase).
if (e.nativeEvent.isComposing) return;
e.preventDefault();
submit();
};
return (
<div className="flex h-full w-full flex-col bg-surface-chat">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-5">
<div className="flex items-center gap-2">
{/* Only the <960 tiers hide the list column, so only there do we need our own way back. */}
<Link to="/messages" className="grid h-9.5 w-9.5 place-items-center text-text-heading md:hidden">
<div className="flex items-center gap-2.5">
<Link to="/messages" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<strong className="text-base font-semibold text-text-heading">{topicId}</strong>
<span className={`grid h-9.5 w-9.5 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topicId)}`}>
{avatarLetter(peerName)}
</span>
<div className="flex flex-col">
<strong className="text-base font-semibold text-text-heading">{peerName}</strong>
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>{contact?.online ? '在线' : '离线'}</span>
</div>
</div>
<div className="flex items-center gap-1">
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="call" />
</button>
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="videocam" />
</button>
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="search" />
</button>
<button
type="button"
onClick={onToggleDetail}
@@ -28,9 +94,48 @@ export function ConversationView() {
>
<Icon name="info" />
</button>
</div>
</header>
<div className="flex flex-1 items-center justify-center text-sm text-text-tertiary-4">
消息流占位(阶段1实现)
<div ref={streamRef} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
{messages.map((msg) => (
<MessageRow
key={msg.seq}
topic={topic}
msg={msg}
mine={!!myUserId && msg.from === myUserId}
isGroup={isGroup}
peerName={peerName}
/>
))}
</div>
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
<div className="flex items-center gap-1 px-1 pb-1">
<Icon name="sentiment_satisfied_alt" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="image" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="attach_file" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="mic_none" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
</div>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
className="min-h-[72px] w-full resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
/>
<div className="flex justify-end">
<button
type="button"
onClick={submit}
disabled={!draft.trim()}
className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
发送
</button>
</div>
</div>
</div>
);
@@ -0,0 +1,72 @@
import type { StoredMessage, Topic } from 'tinode-sdk';
import { Icon } from '@/app/Icon';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { isRecalled, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
import { retryMessage } from '@/lib/tinode/send';
interface MessageRowProps {
topic: Topic;
msg: StoredMessage;
mine: boolean;
isGroup: boolean;
peerName: string;
}
export function MessageRow({ topic, msg, mine, isGroup, peerName }: MessageRowProps) {
if (isServerDeleted(msg)) {
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
}
const noticeText = systemNoticeText(msg);
if (noticeText) {
return (
<div className="flex flex-col items-center gap-1 py-1">
<div className="text-xs text-text-tertiary-4">{noticeText}</div>
{msg.ts ? <div className="text-xs text-text-tertiary-4">{formatEventTimestamp(msg.ts)}</div> : null}
</div>
);
}
const failed = isFailedStatus(topic, msg);
const sending = isSendingStatus(topic, msg);
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
const retry = () => {
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq);
};
return (
<div className={`flex items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine ? (
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(msg.from || peerName)}`}
>
{avatarLetter(peerName)}
</span>
) : (
<span className="w-9 flex-none" />
)}
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
<div
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''}`}
>
{isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
</div>
{failed ? (
<button type="button" onClick={retry} className="flex items-center gap-1 text-xs text-danger-text">
<Icon name="error" className="text-[16px]" />
发送失败,点击重试
</button>
) : (
<span className="text-[11px] text-text-tertiary-3">{sending ? '发送中…' : statusLabel}</span>
)}
</div>
</div>
);
}
+1 -45
View File
@@ -3,51 +3,7 @@ import { useState } from 'react';
import { Outlet, useParams } from 'react-router-dom';
import { Icon } from '@/app/Icon';
const PLACEHOLDER_CHATS = [
{ id: 'grp1', name: '项目协作群', summary: '陈明远:@我 明天上午十点开项目会,请准时参加', time: '10:24', unread: 5 },
{ id: 'p1', name: '接语彤', summary: '我:收到,我会提前准备材料。', time: '10:12', unread: 0 },
{ id: 'grp2', name: '产品评审群', summary: '[图片]', time: '09:47', unread: 2 },
];
function ChatListColumn() {
const { topicId } = useParams();
return (
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">消息</h1>
<Icon name="add_comment" className="text-text-heading" />
</header>
<div className="flex flex-1 flex-col overflow-y-auto">
{PLACEHOLDER_CHATS.map((chat) => (
<a
key={chat.id}
href={`/messages/${chat.id}`}
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item px-4 py-2.5 no-underline ${
topicId === chat.id ? 'bg-accent-selected-bg' : 'bg-white'
}`}
>
<span className="grid h-11 w-11 place-items-center rounded-lg bg-avatar-1 text-base font-medium text-white">
{chat.name.slice(0, 1)}
</span>
<span className="flex min-w-0 flex-col gap-1">
<strong className="truncate text-[15px] font-medium text-[#272b31]">{chat.name}</strong>
<small className="truncate text-[13px] text-text-tertiary-2">{chat.summary}</small>
</span>
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
<time className="text-xs text-text-tertiary-3">{chat.time}</time>
{chat.unread ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{chat.unread}
</b>
) : null}
</span>
</a>
))}
</div>
</aside>
);
}
import { ChatListColumn } from '@/features/messages/ChatListColumn';
function DetailColumn({ onClose }: { onClose?: () => void }) {
return (
+32
View File
@@ -0,0 +1,32 @@
/**
* Deterministic avatar background color per user/topic ID. Algorithm (Java-style String#hashCode,
* rolling `hash = hash*31 + charCode`) ported from the old app's `letterTileColorId` -- but the old
* app's palette had 16 colors (`% 16`) where this design's token palette (index.css's --color-avatar-1
* through -11) has 11, so the modulus is adjusted to match; the hash function itself is unchanged.
*/
function stringToColorHash(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash = hash & hash;
}
return hash;
}
const AVATAR_COLOR_COUNT = 11;
/** 1-based index matching the --color-avatar-1..11 tokens in index.css. */
export function avatarColorIndex(id: string): number {
return (Math.abs(stringToColorHash(id)) % AVATAR_COLOR_COUNT) + 1;
}
const AVATAR_BG_CLASSES = Array.from({ length: AVATAR_COLOR_COUNT }, (_, i) => `bg-avatar-${i + 1}`);
export function avatarBgClass(id: string): string {
return AVATAR_BG_CLASSES[avatarColorIndex(id) - 1];
}
/** First character of a display name, matching the old app's letter-tile convention. */
export function avatarLetter(name: string | undefined): string {
return (name || '').trim().slice(0, 1) || '?';
}
+4
View File
@@ -44,6 +44,10 @@ export const BROKEN_IMAGE_SIZE = 32;
export const NO_DIMENSIONS_VIDEO = 128;
export const MESSAGES_PAGE = 24;
/** Scroll-to-top threshold (px) that triggers loading an older page of history. */
export const FETCH_PAGE_TRIGGER = 40;
/** Poll interval (ms) for the throttled read-receipt queue. */
export const NOTIFICATION_EXEC_INTERVAL = 300;
export const MAX_INBAND_ATTACHMENT_SIZE = 262_144;
export const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23;
+42
View File
@@ -0,0 +1,42 @@
// File-type -> icon/color for the message-stream file card (README §3.2). The old app has no
// equivalent here -- it buckets every document extension (pdf/doc/xls/ppt/txt) into one identical
// "document" icon+color (confirmed via its widgets/attachment.jsx), so this mapping is written fresh
// against the new design's explicit 4-color split rather than ported business logic.
export interface FileTypeStyle {
icon: string;
bg: string;
fg: string;
}
const PDF: FileTypeStyle = { icon: 'picture_as_pdf', bg: '#fff0e5', fg: '#ff951c' };
const WORD: FileTypeStyle = { icon: 'description', bg: '#e8f1fe', fg: '#1677ff' };
const EXCEL: FileTypeStyle = { icon: 'table_chart', bg: '#eefaf3', fg: '#16ae54' };
const PPT: FileTypeStyle = { icon: 'slideshow', bg: '#f3efff', fg: '#7c5cf6' };
const GENERIC: FileTypeStyle = { icon: 'insert_drive_file', bg: '#f1f2f4', fg: '#8a8f98' };
const EXT_STYLE: Record<string, FileTypeStyle> = {
pdf: PDF,
doc: WORD,
docx: WORD,
xls: EXCEL,
xlsx: EXCEL,
ppt: PPT,
pptx: PPT,
};
export function fileTypeStyle(filename: string | undefined, mime: string | undefined): FileTypeStyle {
const ext = (filename || '').split('.').pop()?.toLowerCase();
if (ext && EXT_STYLE[ext]) return EXT_STYLE[ext];
const m = (mime || '').toLowerCase();
if (m === 'application/pdf') return PDF;
if (m.includes('msword') || m.includes('wordprocessingml')) return WORD;
if (m.includes('spreadsheetml') || m === 'application/vnd.ms-excel') return EXCEL;
if (m.includes('presentationml') || m === 'application/vnd.ms-powerpoint') return PPT;
return GENERIC;
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
+37
View File
@@ -0,0 +1,37 @@
import type { StoredMessage } from 'tinode-sdk';
/**
* Two different "message is gone" protocol paths render differently (confirmed against the old
* app's chat-message.jsx, a real, previously-hit gap): `msg.hi` is the server's own soft-delete
* marker (renders as a centered system-style line, no bubble at all); Tinode's recall protocol
* instead nulls out `content` on an otherwise-normal message (still a regular aligned bubble, just
* with placeholder text). Getting these swapped either crashes on a null content or silently
* misrenders a recalled message as a deleted-system-line.
*/
export function isServerDeleted(msg: StoredMessage): boolean {
return !!(msg as StoredMessage & { hi?: boolean }).hi;
}
export function isRecalled(msg: StoredMessage): boolean {
return !isServerDeleted(msg) && msg.content == null;
}
interface SystemNoticeContent {
text: string;
}
function isSystemNoticeContent(content: unknown): content is SystemNoticeContent {
return !!content && typeof content === 'object' && typeof (content as SystemNoticeContent).text === 'string';
}
/**
* Plain centered system-event text (e.g. "你们已成为好友,现在可以开始聊天了") -- backend-custom
* `head['x-im-type']`, not a Drafty content type. Doesn't yet cover the actionable group-invitation
* card variant (same head type, but with a real join button) since that's group-chat territory
* (Phase 3); when that lands it needs to be excluded here the same way the old app excludes it.
*/
export function systemNoticeText(msg: StoredMessage): string | null {
const headType = msg.head?.['x-im-type'];
if (headType !== 'system_notice' && headType !== 'system_action') return null;
return isSystemNoticeContent(msg.content) ? msg.content.text : null;
}
+68
View File
@@ -0,0 +1,68 @@
import { Fragment, type ReactNode } from 'react';
import { Drafty, type DraftyContent } from 'tinode-sdk';
/**
* Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown
* incrementally alongside composer features -- IM/VD/AU/QQ/TC/VC/BN/FM land when their respective
* message types (images, voice, quotes, cards, calls, forms) are actually built, matching this
* project's existing pattern for the SDK's own ambient types.
*
* @mention renders as a flat accent blue (README §3.2's "@提及:文本内 color:#1677ff") rather than
* porting the old app's per-user hash-colored mentions -- a deliberate new-design simplification,
* not an oversight (confirmed against the old app's formatters.js:122-128, which does hash-color
* mentions the same way avatars are colored).
*/
function formatNode(
style: string | undefined,
data: Record<string, unknown> | undefined,
values: ReactNode[] | null,
key: number | string,
): ReactNode {
const children = values ?? undefined;
switch (style) {
case 'ST':
return <b key={key}>{children}</b>;
case 'EM':
return <i key={key}>{children}</i>;
case 'DL':
return <s key={key}>{children}</s>;
case 'CO':
return (
<code key={key} className="rounded bg-surface-input px-1 py-0.5 font-mono text-[0.9em]">
{children}
</code>
);
case 'BR':
return <br key={key} />;
case 'MN':
return (
<span key={key} className="text-accent">
{children}
</span>
);
case 'HL':
return (
<mark key={key} className="bg-warn-bg">
{children}
</mark>
);
case 'LN': {
const url = typeof data?.url === 'string' ? data.url : undefined;
return (
<a key={key} href={url} target="_blank" rel="noreferrer" className="text-accent underline">
{children}
</a>
);
}
default:
return <Fragment key={key}>{children}</Fragment>;
}
}
export function renderDrafty(content: DraftyContent | string | null | undefined): ReactNode {
if (content == null) return null;
if (typeof content === 'string') return content;
if (Drafty.isPlainText(content)) return Drafty.toPlainText(content);
return Drafty.format(content, formatNode);
}
+37
View File
@@ -0,0 +1,37 @@
import { Drafty, type DraftyContent, type StoredMessage } from 'tinode-sdk';
/**
* One-line chat-list preview text (README §2.1): plain text renders as-is, everything else falls
* back to a bracketed type label. Own messages get a "我:" prefix. Grown incrementally -- VC (call)
* duration/answered-state isn't modeled yet (Phase 6 territory), so call messages get a plain label
* for now rather than guessing at a content shape no research has confirmed.
*/
export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: string | null): string {
if (!msg) return '';
const prefix = msg.from && myUserId && msg.from === myUserId ? '我:' : '';
const content = msg.content;
if (content == null) return `${prefix}[消息已删除]`;
if (typeof content === 'string' || Drafty.isPlainText(content)) {
return prefix + Drafty.toPlainText(content);
}
const firstEnt = (content as DraftyContent).ent?.[0];
switch (firstEnt?.tp) {
case 'IM':
return `${prefix}[图片]`;
case 'VD':
return `${prefix}[视频]`;
case 'AU': {
const durationMs = typeof firstEnt.data?.duration === 'number' ? firstEnt.data.duration : 0;
const seconds = Math.round(durationMs / 1000);
return `${prefix}[语音] 0:${String(seconds).padStart(2, '0')}`;
}
case 'EX':
return `${prefix}[文件] ${typeof firstEnt.data?.name === 'string' ? firstEnt.data.name : ''}`;
case 'TC':
return `${prefix}[名片]`;
case 'VC':
return `${prefix}[语音/视频通话]`;
default:
return prefix + Drafty.toPlainText(Drafty.preview(content, 40));
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Tinode, type StoredMessage, type Topic } from 'tinode-sdk';
import { formatBubbleTime } from './time';
/**
* Bubble status line (README §3.2): the design wants a text label ("已读 · 10:01" / "已送达 ·
* 10:12" / group "12 人已读 · 10:31"), unlike the old app's icon-only ReceivedMarker (hourglass/
* check/double-check) -- so this reads the same underlying `topic.msgStatus()` computation the old
* app relies on, but renders it as the new design's text instead of porting the checkmark icons.
* Returns null for messages that aren't mine (the other party's bubbles only ever show a time).
*/
export function outgoingStatusLabel(topic: Topic, msg: StoredMessage, isGroup: boolean): string | null {
const status = topic.msgStatus(msg, false);
if (status === Tinode.MESSAGE_STATUS_TO_ME) return null;
const time = msg.ts ? formatBubbleTime(msg.ts) : '';
if (status === Tinode.MESSAGE_STATUS_READ) {
if (isGroup) {
const n = topic.msgReadCount(msg.seq);
return `${n} 人已读 · ${time}`;
}
return `已读 · ${time}`;
}
if (status === Tinode.MESSAGE_STATUS_RECEIVED) {
return `已送达 · ${time}`;
}
return time;
}
export function isFailedStatus(topic: Topic, msg: StoredMessage): boolean {
const status = topic.msgStatus(msg, false);
return status === Tinode.MESSAGE_STATUS_FAILED || status === Tinode.MESSAGE_STATUS_FATAL;
}
export function isSendingStatus(topic: Topic, msg: StoredMessage): boolean {
return topic.msgStatus(msg, false) === Tinode.MESSAGE_STATUS_SENDING;
}
+48
View File
@@ -0,0 +1,48 @@
// Date/time formatting for the chat list and message stream. The old app's own two formatters
// (shortDateFormat/relativeDateFormat, lib/strformat.js) never combine a relative-date word with a
// time-of-day in one string, and have no "day of week" tier -- but this design's chat-list rows
// ("今天 HH:mm"/"昨天"/weekday/"M/D", README §2.1) and message-stream event lines ("今天 09:58")
// both call for exactly that, so these are written fresh against the design spec rather than ported.
const WEEKDAY_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
function daysBetween(earlier: Date, later: Date): number {
return Math.round((startOfDay(later) - startOfDay(earlier)) / 86_400_000);
}
function pad2(n: number): string {
return n.toString().padStart(2, '0');
}
function timeOfDay(d: Date): string {
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
/** Chat-list row time (README §2.1): today -> "HH:mm", yesterday -> "昨天", this week -> weekday, older -> "M/D" (year-qualified across years). */
export function formatListTime(date: Date, now: Date = new Date()): string {
const diff = daysBetween(date, now);
if (diff <= 0) return timeOfDay(date);
if (diff === 1) return '昨天';
if (diff < 7) return WEEKDAY_NAMES[date.getDay()];
if (date.getFullYear() === now.getFullYear()) return `${date.getMonth() + 1}/${date.getDate()}`;
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
}
/** Message-stream date divider / system-event timestamp, e.g. "今天 09:58", "昨天 14:23", "周一 09:12". */
export function formatEventTimestamp(date: Date, now: Date = new Date()): string {
const diff = daysBetween(date, now);
const time = timeOfDay(date);
if (diff <= 0) return `今天 ${time}`;
if (diff === 1) return `昨天 ${time}`;
if (diff < 7) return `${WEEKDAY_NAMES[date.getDay()]} ${time}`;
if (date.getFullYear() === now.getFullYear()) return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`;
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${time}`;
}
/** Per-bubble status line time -- always just the time, never a relative-date word (README §3.2). */
export function formatBubbleTime(date: Date): string {
return timeOfDay(date);
}
+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);