- {/* Only the <960 tiers hide the list column, so only there do we need our own way back. */}
-
+
+
-
{topicId}
+
+ {avatarLetter(peerName)}
+
+
+ {peerName}
+ {contact?.online ? '在线' : '离线'}
+
+
+
+
+
+
+
-
-
- 消息流占位(阶段1实现)
+
+
+ {messages.map((msg) => (
+
+ ))}
+
+
+
);
diff --git a/web/src/features/messages/MessageBubble.tsx b/web/src/features/messages/MessageBubble.tsx
new file mode 100644
index 00000000..2b1126b7
--- /dev/null
+++ b/web/src/features/messages/MessageBubble.tsx
@@ -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
此消息已被删除
;
+ }
+
+ const noticeText = systemNoticeText(msg);
+ if (noticeText) {
+ return (
+
+
{noticeText}
+ {msg.ts ?
{formatEventTimestamp(msg.ts)}
: null}
+
+ );
+ }
+
+ 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 (
+
+ {!mine ? (
+
+ {avatarLetter(peerName)}
+
+ ) : (
+
+ )}
+
+
+ {isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
+
+ {failed ? (
+
+ ) : (
+
{sending ? '发送中…' : statusLabel}
+ )}
+
+
+ );
+}
diff --git a/web/src/features/messages/MessagesLayout.tsx b/web/src/features/messages/MessagesLayout.tsx
index 60930192..7c8bee06 100644
--- a/web/src/features/messages/MessagesLayout.tsx
+++ b/web/src/features/messages/MessagesLayout.tsx
@@ -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 (
-
- );
-}
+import { ChatListColumn } from '@/features/messages/ChatListColumn';
function DetailColumn({ onClose }: { onClose?: () => void }) {
return (
diff --git a/web/src/lib/avatar.ts b/web/src/lib/avatar.ts
new file mode 100644
index 00000000..9dabaa7c
--- /dev/null
+++ b/web/src/lib/avatar.ts
@@ -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) || '?';
+}
diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts
index 03ae1056..5cd6001d 100644
--- a/web/src/lib/config.ts
+++ b/web/src/lib/config.ts
@@ -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;
diff --git a/web/src/lib/messages/attachments.ts b/web/src/lib/messages/attachments.ts
new file mode 100644
index 00000000..2f1f04fd
--- /dev/null
+++ b/web/src/lib/messages/attachments.ts
@@ -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
= {
+ 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`;
+}
diff --git a/web/src/lib/messages/dispatch.ts b/web/src/lib/messages/dispatch.ts
new file mode 100644
index 00000000..761f81cd
--- /dev/null
+++ b/web/src/lib/messages/dispatch.ts
@@ -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;
+}
diff --git a/web/src/lib/messages/drafty.tsx b/web/src/lib/messages/drafty.tsx
new file mode 100644
index 00000000..1d41bde4
--- /dev/null
+++ b/web/src/lib/messages/drafty.tsx
@@ -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 | undefined,
+ values: ReactNode[] | null,
+ key: number | string,
+): ReactNode {
+ const children = values ?? undefined;
+ switch (style) {
+ case 'ST':
+ return {children};
+ case 'EM':
+ return {children};
+ case 'DL':
+ return {children};
+ case 'CO':
+ return (
+
+ {children}
+
+ );
+ case 'BR':
+ return
;
+ case 'MN':
+ return (
+
+ {children}
+
+ );
+ case 'HL':
+ return (
+
+ {children}
+
+ );
+ case 'LN': {
+ const url = typeof data?.url === 'string' ? data.url : undefined;
+ return (
+
+ {children}
+
+ );
+ }
+ default:
+ return {children};
+ }
+}
+
+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);
+}
diff --git a/web/src/lib/messages/preview.ts b/web/src/lib/messages/preview.ts
new file mode 100644
index 00000000..a493c6cf
--- /dev/null
+++ b/web/src/lib/messages/preview.ts
@@ -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));
+ }
+}
diff --git a/web/src/lib/messages/status.ts b/web/src/lib/messages/status.ts
new file mode 100644
index 00000000..721b7c63
--- /dev/null
+++ b/web/src/lib/messages/status.ts
@@ -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;
+}
diff --git a/web/src/lib/messages/time.ts b/web/src/lib/messages/time.ts
new file mode 100644
index 00000000..83fdd7ab
--- /dev/null
+++ b/web/src/lib/messages/time.ts
@@ -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);
+}
diff --git a/web/src/lib/tinode/hooks.ts b/web/src/lib/tinode/hooks.ts
index 806580b4..04663473 100644
--- a/web/src/lib/tinode/hooks.ts
+++ b/web/src/lib/tinode/hooks.ts
@@ -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);
+}
diff --git a/web/src/lib/tinode/send.ts b/web/src/lib/tinode/send.ts
new file mode 100644
index 00000000..20c06572
--- /dev/null
+++ b/web/src/lib/tinode/send.ts
@@ -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): Promise {
+ 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 | 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;
+}
diff --git a/web/src/lib/tinode/tinode-sdk.d.ts b/web/src/lib/tinode/tinode-sdk.d.ts
index f1329e8a..cf2262e5 100644
--- a/web/src/lib/tinode/tinode-sdk.d.ts
+++ b/web/src/lib/tinode/tinode-sdk.d.ts
@@ -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;
+ _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 }>;
+ }
+
+ /**
+ * 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 | undefined;
}
export class Topic {
name: string;
- subscribe(): Promise;
+ 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 }>;
leave(unsub?: boolean): Promise;
+ /** 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;
- publish(data: unknown, head?: Record): Promise;
+ 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): 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;
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;
+ /** 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;
+ delMessagesList(list: number[], hardDel?: boolean): Promise;
+
+ /** 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) => 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 | 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): DraftyContent;
+ insertImage(content: DraftyContent | undefined, at: number, imageDesc: Record): DraftyContent;
+ appendAudio(content: DraftyContent | undefined, audioDesc: Record): DraftyContent;
+ insertAudio(content: DraftyContent | undefined, at: number, audioDesc: Record): DraftyContent;
+ attachFile(content: DraftyContent | undefined, attachmentDesc: { mime: string; data?: string; filename?: string; refurl?: string; size?: number; urlPromise?: Promise }): 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): 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(
+ content: DraftyContent | string,
+ formatterFn: (style: string | undefined, data: Record | undefined, values: T[] | null, key: number | string, stack: unknown[]) => T,
+ context?: unknown,
+ ): T;
[key: string]: unknown;
};
diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts
new file mode 100644
index 00000000..7cbe34f7
--- /dev/null
+++ b/web/src/lib/tinode/topics.ts
@@ -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 void>>();
+const anyTopicListeners = new Set<() => void>();
+const wiredTopics = new WeakSet();
+const previewPrefetched = new Set();
+const messageSnapshots = new Map();
+
+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 {
+ 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 {
+ 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();
+ 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);