feat(web): real chat list + P2P conversation view against live Tinode data
Phase 2 core loop: chat list and single-chat conversation now read/write real
Tinode topic data end to end, replacing the earlier hardcoded placeholders.
- lib/tinode/topics.ts: per-topic pub-sub layered on connection.ts's pattern
(Topic.onData/onInfo/etc are single-slot callbacks, claimed once and fanned
out). Implements the old app's subscribe lifecycle: `me` subscribed once
per session (metadata only), each contact gets a one-shot preview
subscription (withLaterData(1), never left), opening a conversation
leaves-then-resubscribes with a full history page (withData, not
withLaterData -- a preview prefetch already nudging _maxSeq would make
withLaterData return empty).
- lib/tinode/hooks.ts: useChatList()/useTopicMessages() via
useSyncExternalStore, snapshots cached and only invalidated on real
topic-change notifications (not recomputed every render).
- lib/tinode/send.ts: sendText/retryMessage via createMessage+publishDraft
(optimistic local echo), not the simpler publish() -- matches the old
app's approach. retryMessage flushes the failed local entry and rebuilds
before republishing, per the old app's context-menu retry handler.
- lib/messages/{time,avatar,attachments,status,preview,dispatch,drafty}:
formatting utilities. Avatar color hash ports the old app's Java-style
string hash but with modulus 11 (this design's palette) not the old app's
16. @mention renders flat accent blue per the new design, not the old
app's per-user hash color -- a deliberate simplification. File-type icons
(PDF/Word/Excel/PPT) are new-design-only; the old app buckets all
documents identically. Date/time formatting is written fresh against the
design spec (today+time combined, weekday tier) since the old app never
combines a relative-date word with a time in one string.
- ChatListColumn, MessageBubble, ConversationView: real components replacing
the placeholder chat list and "占位" conversation view.
- tinode-sdk.d.ts: expanded with the real Topic/MetaGetBuilder/Drafty
surface needed above, verified against the installed SDK source
(createMessage/publishDraft/messages/msgStatus/noteRead/flushMessage/
Drafty.format's exact treeBottomUp contract, MESSAGE_STATUS_* statics).
Verified end to end against the real backend: used the still-functional old
app (a second account, ixp355087) to create a real friendship and exchange
messages with the new app's test account, confirming the new chat list and
conversation view correctly render pre-existing real data, sending a new
message shows the right optimistic-then-delivered status transition, and
the old app receives it live in return.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
aa1c46ff22
commit
f549248c08
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) || '?';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import type { Contact, StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import { getConnectionStatus, getTinode, subscribeConnectionStatus } from './connection';
|
||||
import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicChanges } from './topics';
|
||||
|
||||
/** The current WS connection status, reactive via useSyncExternalStore. */
|
||||
export function useConnectionStatus() {
|
||||
@@ -11,3 +14,18 @@ export function useConnectionStatus() {
|
||||
export function useTinode() {
|
||||
return getTinode();
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGES: StoredMessage[] = [];
|
||||
const noopSubscribe = () => () => {};
|
||||
|
||||
/** Live message list for one topic. Empty (and non-reactive) until a topicName is provided. */
|
||||
export function useTopicMessages(topicName: string | undefined): StoredMessage[] {
|
||||
const subscribe = useCallback((onChange: () => void) => (topicName ? subscribeTopicChanges(topicName, onChange) : noopSubscribe()), [topicName]);
|
||||
const getSnapshot = useCallback(() => (topicName ? getMessagesSnapshot(topicName) : EMPTY_MESSAGES), [topicName]);
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
||||
/** Live chat-list rows, derived from `me.contacts()` and re-read whenever any topic changes. */
|
||||
export function useChatList(): Contact[] {
|
||||
return useSyncExternalStore(subscribeAnyTopicChange, getChatListSnapshot);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Drafty, type DraftyContent, type StoredMessage, type Topic } from 'tinode-sdk';
|
||||
|
||||
/**
|
||||
* Plain-text send: createMessage + publishDraft (not the simpler `.publish()`), matching the old
|
||||
* app's own choice -- publishDraft inserts the message into the local cache and fires onData
|
||||
* immediately, showing "sending" before the network round trip resolves, rather than waiting on it.
|
||||
*/
|
||||
export function sendText(topic: Topic, text: string, head?: Record<string, unknown>): Promise<unknown> {
|
||||
const parsed = Drafty.parse(text);
|
||||
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
|
||||
const msg = topic.createMessage(content, false);
|
||||
if (head) msg.head = Object.assign(msg.head || {}, head);
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries a failed send by flushing the old local entry and building+publishing a fresh one with
|
||||
* the same content/head -- ported from the old app's context-menu "重试" handler (context-menu.jsx),
|
||||
* not a simple re-publish of the same object. Returns undefined if `seq` is no longer in the local
|
||||
* cache (already flushed, or never existed).
|
||||
*/
|
||||
export function retryMessage(topic: Topic, seq: number): Promise<unknown> | undefined {
|
||||
const failed = topic.flushMessage(seq);
|
||||
if (!failed) return undefined;
|
||||
const msg = topic.createMessage((failed.content as DraftyContent | string) ?? '', false);
|
||||
if (failed.head) msg.head = Object.assign(msg.head || {}, failed.head);
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
export function messageIsMine(msg: StoredMessage, myUserId: string | null): boolean {
|
||||
return !!myUserId && msg.from === myUserId;
|
||||
}
|
||||
Vendored
+195
-8
@@ -91,21 +91,143 @@ declare module 'tinode-sdk' {
|
||||
latestMessage?: () => StoredMessage | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `_sending`/`_failed`/`_fatal`/`_cancelled` are the SDK's own optimistic-send bookkeeping fields,
|
||||
* mutated in place on the same object the app holds -- not separate app-tracked state. `seq` is a
|
||||
* local placeholder (`>= LOCAL_SEQID`, see `LOCAL_SEQID` below) until the server confirms and
|
||||
* `swapMessageId` rewrites it to the real sequence number.
|
||||
*/
|
||||
export interface StoredMessage {
|
||||
seq: number;
|
||||
from?: string;
|
||||
ts?: Date;
|
||||
content?: unknown;
|
||||
content?: DraftyContent | string;
|
||||
head?: Record<string, unknown>;
|
||||
_sending?: boolean;
|
||||
_failed?: boolean;
|
||||
_fatal?: boolean;
|
||||
_cancelled?: boolean;
|
||||
_status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drafty's own document shape: `txt` is the plain-text backbone, `fmt` is a list of (offset,
|
||||
* length) format spans referencing either a style tag (`tp`) or an entity index (`key` into
|
||||
* `ent`), `ent` holds the actual entity payloads (images, links, mentions, quotes, ...). Kept
|
||||
* loose (not modeling every known `tp`/`data` shape) since this app only ever constructs content
|
||||
* through the `Drafty.*` builder functions below rather than hand-assembling this object.
|
||||
*/
|
||||
export interface DraftyContent {
|
||||
txt: string;
|
||||
fmt?: Array<{ at?: number; len?: number; tp?: string; key?: number }>;
|
||||
ent?: Array<{ tp: string; data: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fluent GET-query builder returned by `Topic.startMetaQuery()`. `.build()` produces the `params`
|
||||
* object `Topic.getMeta()`/`Tinode.getMeta()` expect. Verified against tinode.dev.js:3858-3976 --
|
||||
* the old app never calls `withEarlierData` (paging always goes through `getMessagesPage`'s gap-fill
|
||||
* path instead, to correctly backfill ranges with server-side deletions).
|
||||
*/
|
||||
export class MetaGetBuilder {
|
||||
withData(since?: number, before?: number, limit?: number): this;
|
||||
withLaterData(limit?: number): this;
|
||||
withEarlierData(limit?: number): this;
|
||||
withDataRanges(ranges: Array<{ low: number; hi?: number }>, limit?: number): this;
|
||||
withDesc(ims?: Date): this;
|
||||
withLaterDesc(): this;
|
||||
withSub(ims?: Date, limit?: number, userOrTopic?: string): this;
|
||||
withOneSub(ims?: Date, userOrTopic?: string): this;
|
||||
withLaterOneSub(userOrTopic?: string): this;
|
||||
withLaterSub(limit?: number): this;
|
||||
withTags(): this;
|
||||
withCred(): this;
|
||||
withAux(): this;
|
||||
withDel(since?: number, limit?: number): this;
|
||||
withLaterDel(limit?: number): this;
|
||||
extract(what: string): unknown;
|
||||
build(): Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export class Topic {
|
||||
name: string;
|
||||
subscribe(): Promise<unknown>;
|
||||
created?: Date;
|
||||
updated?: Date;
|
||||
touched?: Date;
|
||||
public?: TopicDescPublic | null;
|
||||
private?: TopicDescPrivate | null;
|
||||
|
||||
subscribe(getParams?: unknown, setParams?: unknown): Promise<{ code: number; topic?: string; ts?: Date; params?: Record<string, unknown> }>;
|
||||
leave(unsub?: boolean): Promise<unknown>;
|
||||
/** Debounced 100ms-ish delayed leave -- used when a view might immediately reopen the same topic. */
|
||||
leaveDelayed(unsub: boolean, delayMs: number): void;
|
||||
getMeta(query: unknown): Promise<unknown>;
|
||||
publish(data: unknown, head?: Record<string, unknown>): Promise<unknown>;
|
||||
startMetaQuery(): MetaGetBuilder;
|
||||
|
||||
/** Builds the `{pub}` packet shape but does not send it -- pair with `publishDraft`/`publishMessage`. */
|
||||
createMessage(content: DraftyContent | string, noEcho?: boolean): StoredMessage;
|
||||
/**
|
||||
* Optimistically inserts `pub` into the local message cache and fires `onData` immediately
|
||||
* (so the bubble shows "sending" with no network round trip), then sends once `prom` resolves.
|
||||
* `prom` is typically `Promise.all(...)` over any attachment uploads that must finish first; pass
|
||||
* `Promise.resolve()` when there's nothing to wait on. This is what the old app used for every
|
||||
* real send (not the simpler `publish()`), specifically to get that optimistic local echo.
|
||||
*/
|
||||
publishDraft(pub: StoredMessage, prom?: Promise<unknown>): Promise<{ code: number; ts?: Date; params?: { seq: number } }>;
|
||||
publishMessage(pub: StoredMessage): Promise<{ code: number; ts?: Date; params?: { seq: number } }>;
|
||||
/** Convenience wrapper: `createMessage` + immediate `publishMessage`, no optimistic local echo. */
|
||||
publish(data: DraftyContent | string, noEcho?: boolean): Promise<unknown>;
|
||||
isSubscribed(): boolean;
|
||||
|
||||
isP2PType(): boolean;
|
||||
isGroupType(): boolean;
|
||||
isSelfType(): boolean;
|
||||
getAccessMode(): AccessMode | undefined;
|
||||
|
||||
/**
|
||||
* Iterates locally-cached messages in `[sinceId, beforeId]` (both omitted = everything cached).
|
||||
* `callback(msg, prevMsg, nextMsg, bufferIndex)` fires once per message, already resolved to the
|
||||
* latest edited version if the message was replaced. There is no separate app-held message array
|
||||
* anywhere in this codebase's design -- call this fresh to get the current list, and re-call it
|
||||
* whenever `onData`/`onInfo` fires.
|
||||
*/
|
||||
messages(
|
||||
callback: (msg: StoredMessage, prev: StoredMessage | undefined, next: StoredMessage | undefined, idx: number) => void,
|
||||
sinceId?: number,
|
||||
beforeId?: number,
|
||||
context?: unknown,
|
||||
): void;
|
||||
findMessage(seq: number): StoredMessage | undefined;
|
||||
latestMessage(): StoredMessage | undefined;
|
||||
/** Locally-queued (not-yet-confirmed-sent) messages -- used to auto-resend after a reconnect. */
|
||||
queuedMessages(callback: (msg: StoredMessage) => void, context?: unknown): void;
|
||||
/** Removes a message from the local cache (e.g. before rebuilding+republishing a failed send). Returns the removed message, or undefined if seq wasn't found. */
|
||||
flushMessage(seq: number): StoredMessage | undefined;
|
||||
maxMsgSeq(): number;
|
||||
minMsgSeq(): number;
|
||||
isNewMessage(seqId: number): boolean;
|
||||
/** Locally-cached-range gap detector feeding `getMessagesPage`'s `gaps` argument. */
|
||||
msgHasMoreMessages(min: number, max: number, newer: boolean): Array<{ low: number; hi?: number }>;
|
||||
getMessagesPage(limit: number, gaps?: Array<{ low: number; hi?: number }>, min?: number, max?: number, newer?: boolean): Promise<unknown>;
|
||||
/** Per-message delivery/read status -- one of the `Tinode.MESSAGE_STATUS_*` constants. Not cached
|
||||
* anywhere; recompute on every relevant re-render (`upd:true` also persists it to local storage). */
|
||||
msgStatus(msg: StoredMessage, upd?: boolean): number;
|
||||
msgReadCount(seq: number): number;
|
||||
msgRecvCount(seq: number): number;
|
||||
/** Sends a `{note}` read receipt if `seq` advances this topic's locally-recorded read watermark. */
|
||||
noteRead(seq: number): void;
|
||||
|
||||
delMessagesAll(hardDel?: boolean): Promise<unknown>;
|
||||
delMessagesList(list: number[], hardDel?: boolean): Promise<unknown>;
|
||||
|
||||
/** Single-slot callbacks the app wires fresh per "currently open" topic and tears down on leave. */
|
||||
onData?: (data?: StoredMessage) => void;
|
||||
onAllMessagesReceived?: (count: number) => void;
|
||||
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; seq?: number; from?: string }) => void;
|
||||
onMetaDesc?: (desc: unknown) => void;
|
||||
onSubsUpdated?: (subs: string[], count?: number) => void;
|
||||
onPres?: (pres: unknown) => void;
|
||||
onAuxUpdated?: (aux: unknown) => void;
|
||||
}
|
||||
|
||||
export class MeTopic extends Topic {
|
||||
@@ -146,20 +268,85 @@ declare module 'tinode-sdk' {
|
||||
/** Wraps a single {meth,val,resp,params} credential into the array shape {acc}/createAccountBasic expects. */
|
||||
static credential(cred: CredentialInput): CredentialParam[] | null;
|
||||
|
||||
/**
|
||||
* Per-message delivery/read status constants returned by `Topic.msgStatus()`. Exposed as static
|
||||
* props on the class itself (verified tinode.dev.js:7448-7456), not string literals, so reference
|
||||
* these rather than hardcoding the underlying numbers (0/10/20/30/40/50/60/70/80).
|
||||
*/
|
||||
static MESSAGE_STATUS_NONE: number;
|
||||
static MESSAGE_STATUS_QUEUED: number;
|
||||
static MESSAGE_STATUS_SENDING: number;
|
||||
static MESSAGE_STATUS_FAILED: number;
|
||||
static MESSAGE_STATUS_FATAL: number;
|
||||
static MESSAGE_STATUS_SENT: number;
|
||||
static MESSAGE_STATUS_RECEIVED: number;
|
||||
static MESSAGE_STATUS_READ: number;
|
||||
/** Status value for a message that's TO me (i.e. not mine) -- msgStatus never distinguishes read/unread for incoming messages this way, that's tracked per-topic instead. */
|
||||
static MESSAGE_STATUS_TO_ME: number;
|
||||
/** Local placeholder seq threshold (0xFFFFFFF) -- a message with `seq >= this` hasn't been confirmed by the server yet. */
|
||||
static MSG_DELETE_AGE: string;
|
||||
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: (err?: Error) => void;
|
||||
onLogin?: (code: number, text: string) => void;
|
||||
onNetworkProbe?: () => void;
|
||||
/**
|
||||
* Fires on every SDK-internal reconnect attempt. `seconds` is the SDK's own next-retry countdown
|
||||
* (exponential backoff, ~2s base doubling up to 10 iterations, +/-30% jitter -- entirely internal
|
||||
* to the SDK, this app does not implement its own backoff). `promise` is only present while a
|
||||
* retry is actually in flight; when absent, the app is expected to run its own 1s countdown
|
||||
* display down to the next attempt.
|
||||
*/
|
||||
onAutoreconnectIteration?: (seconds: number, promise?: Promise<unknown>) => void;
|
||||
}
|
||||
|
||||
export const Drafty: {
|
||||
init(text: string): unknown;
|
||||
parse(text: string): unknown;
|
||||
toPlainText(content: unknown): string;
|
||||
preview(content: unknown, length: number, cutFirst?: boolean): unknown;
|
||||
videoCall(audioOnly: boolean): unknown;
|
||||
init(text: string): DraftyContent;
|
||||
parse(text: string): DraftyContent;
|
||||
toPlainText(content: DraftyContent | string): string;
|
||||
preview(content: DraftyContent | string, length: number, cutFirst?: boolean): DraftyContent;
|
||||
videoCall(audioOnly: boolean): DraftyContent;
|
||||
isValid(content: unknown): boolean;
|
||||
isPlainText(content: DraftyContent | string): boolean;
|
||||
hasEntities(content: DraftyContent | string): boolean;
|
||||
entities(content: DraftyContent | string, callback: (data: Record<string, unknown> | undefined) => void, context?: unknown): void;
|
||||
/** Deep-copies `content` and strips anything the server would reject when re-sent (e.g. as a quote/forward payload). */
|
||||
sanitizeEntities(content: DraftyContent): DraftyContent;
|
||||
/** Concatenates two Drafty documents, remapping `fmt[].key` entity indices in `second`. */
|
||||
append(first: DraftyContent | undefined, second: DraftyContent): DraftyContent;
|
||||
/** Builds a quoted-reply PREVIEW block (not the wire content itself -- the old app inlines the
|
||||
* quoted body via `append`+`sanitizeEntities` for the actual sent message; `quote()` is only for
|
||||
* rendering the reply-preview strip above the composer). */
|
||||
quote(header: string, uid: string, body: DraftyContent): DraftyContent;
|
||||
mention(name: string, uid: string): DraftyContent;
|
||||
forwardedContent(original: DraftyContent): DraftyContent;
|
||||
appendLink(content: DraftyContent | undefined, linkData: { txt: string; url: string }): DraftyContent;
|
||||
appendImage(content: DraftyContent | undefined, imageDesc: Record<string, unknown>): DraftyContent;
|
||||
insertImage(content: DraftyContent | undefined, at: number, imageDesc: Record<string, unknown>): DraftyContent;
|
||||
appendAudio(content: DraftyContent | undefined, audioDesc: Record<string, unknown>): DraftyContent;
|
||||
insertAudio(content: DraftyContent | undefined, at: number, audioDesc: Record<string, unknown>): DraftyContent;
|
||||
attachFile(content: DraftyContent | undefined, attachmentDesc: { mime: string; data?: string; filename?: string; refurl?: string; size?: number; urlPromise?: Promise<string> }): DraftyContent;
|
||||
appendButton(content: DraftyContent | undefined, title: string, name: string, actionType: 'url' | 'pub', actionValue: string, refUrl?: string): DraftyContent | null;
|
||||
appendLineBreak(content: DraftyContent | undefined): DraftyContent;
|
||||
appendTheCard(content: DraftyContent | undefined, theCardData: Record<string, unknown>): DraftyContent;
|
||||
getContentType(): string;
|
||||
/** Preview-length trims a Drafty document to `limit` chars for a reply-quote preview (README's QUOTED_REPLY_LENGTH=30 usage). */
|
||||
replyContent(content: DraftyContent, limit: number): DraftyContent;
|
||||
/**
|
||||
* Bottom-up tree walk over `content`: `formatterFn(style, data, values, key, stack)` is invoked
|
||||
* once per node. `style` is `undefined` (not a style-tag string) for a plain-text run or the root
|
||||
* text container -- verified against the SDK's own `treeBottomUp` (tinode.dev.js:2971-2996): a
|
||||
* leaf with no children and no `.text` gets `values === null`; a leaf with `.text` and no children
|
||||
* gets `values === [text]`; only a node with actual child nodes gets multiple entries. A formatter
|
||||
* MUST handle `style === undefined` (render `values` through as-is) and `values === null` (nothing
|
||||
* to render) -- e.g. the old app's `fullFormatter` returns React elements per node, and this
|
||||
* function assembles them into the final tree with no separate "Drafty AST -> React" pass.
|
||||
*/
|
||||
format<T>(
|
||||
content: DraftyContent | string,
|
||||
formatterFn: (style: string | undefined, data: Record<string, unknown> | undefined, values: T[] | null, key: number | string, stack: unknown[]) => T,
|
||||
context?: unknown,
|
||||
): T;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Per-topic pub-sub + subscription lifecycle, layered on top of connection.ts's connection-level
|
||||
// pub-sub. Topic.onData/onInfo/etc. are single-slot callbacks exactly like Tinode.onConnect/etc.
|
||||
// (see connection.ts's header comment) -- this module claims them once per topic and fans out to
|
||||
// any number of subscribers, which is what hooks.ts's useSyncExternalStore-based hooks consume.
|
||||
//
|
||||
// Subscription lifecycle mirrors the old app's (verified against its tinode-web.jsx/messages-view.jsx):
|
||||
// - The `me` topic is subscribed once per session, metadata only (no message bodies).
|
||||
// - Every contact gets a one-shot "preview" subscription (withLaterData(1)) so the chat list can
|
||||
// show a last-message preview; it is never explicitly left afterward.
|
||||
// - Opening a conversation subscribes with a full history page. A topic already subscribed (from
|
||||
// its preview prefetch) has its subscribe() short-circuited by the SDK if called again with new
|
||||
// getParams -- so this always leaves first when already subscribed, then resubscribes.
|
||||
import type { Contact, StoredMessage, Topic } from 'tinode-sdk';
|
||||
|
||||
import { MESSAGES_PAGE } from '@/lib/config';
|
||||
|
||||
import { getTinode } from './connection';
|
||||
|
||||
const topicListeners = new Map<string, Set<() => void>>();
|
||||
const anyTopicListeners = new Set<() => void>();
|
||||
const wiredTopics = new WeakSet<Topic>();
|
||||
const previewPrefetched = new Set<string>();
|
||||
const messageSnapshots = new Map<string, StoredMessage[]>();
|
||||
|
||||
function notifyTopic(topicName: string) {
|
||||
messageSnapshots.delete(topicName);
|
||||
topicListeners.get(topicName)?.forEach((cb) => cb());
|
||||
anyTopicListeners.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
export function subscribeTopicChanges(topicName: string, callback: () => void): () => void {
|
||||
let set = topicListeners.get(topicName);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
topicListeners.set(topicName, set);
|
||||
}
|
||||
set.add(callback);
|
||||
return () => set.delete(callback);
|
||||
}
|
||||
|
||||
/** Fires on ANY topic's change -- used by the chat list, which derives its rows from every contact
|
||||
* at once (`me.contacts()`) rather than tracking one topic in particular. */
|
||||
export function subscribeAnyTopicChange(callback: () => void): () => void {
|
||||
anyTopicListeners.add(callback);
|
||||
return () => anyTopicListeners.delete(callback);
|
||||
}
|
||||
|
||||
function wireTopicCallbacks(topic: Topic) {
|
||||
if (wiredTopics.has(topic)) return;
|
||||
const notify = () => notifyTopic(topic.name);
|
||||
topic.onData = notify;
|
||||
topic.onInfo = notify;
|
||||
topic.onMetaDesc = notify;
|
||||
topic.onSubsUpdated = notify;
|
||||
topic.onPres = notify;
|
||||
topic.onAuxUpdated = notify;
|
||||
topic.onAllMessagesReceived = notify;
|
||||
wiredTopics.add(topic);
|
||||
}
|
||||
|
||||
/** Cached per topic; only recomputed when notifyTopic() invalidates it, so useSyncExternalStore's
|
||||
* getSnapshot returns a stable reference between real changes instead of a new array every render. */
|
||||
export function getMessagesSnapshot(topicName: string): StoredMessage[] {
|
||||
let cached = messageSnapshots.get(topicName);
|
||||
if (!cached) {
|
||||
const topic = getTinode().getTopic(topicName);
|
||||
cached = [];
|
||||
topic.messages((msg) => cached!.push(msg));
|
||||
messageSnapshots.set(topicName, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Call once at session start (after login) -- chat-list metadata only, no message bodies. */
|
||||
export function subscribeMeTopic(): Promise<unknown> {
|
||||
const me = getTinode().getMeTopic();
|
||||
wireTopicCallbacks(me);
|
||||
return me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot per topic: subscribes just enough to learn its latest message for the chat-list preview.
|
||||
* Deliberately never left afterward (matches the old app) -- simpler than tracking teardown for a
|
||||
* subscription that's cheap to leave attached and likely to be reopened soon anyway.
|
||||
*/
|
||||
export function prefetchPreview(topicName: string): void {
|
||||
if (previewPrefetched.has(topicName)) return;
|
||||
previewPrefetched.add(topicName);
|
||||
const topic = getTinode().getTopic(topicName);
|
||||
wireTopicCallbacks(topic);
|
||||
topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {
|
||||
previewPrefetched.delete(topicName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a topic for full conversation view: fetches a page of history and wires live updates.
|
||||
* Uses withData(undefined, undefined, limit), NOT withLaterData(limit) -- if prefetchPreview already
|
||||
* nudged the topic's local _maxSeq up to just that one preview message, withLaterData would ask for
|
||||
* "newer than that" and the server would correctly report back empty.
|
||||
*
|
||||
* Known gap: for a brand-new P2P topic the peer has never messaged, the old app defers subscribing
|
||||
* at all until the user's first send (avoiding server-side subscription state for chats opened but
|
||||
* never used) -- not yet replicated here since the new-conversation picker flow doesn't exist yet.
|
||||
*/
|
||||
export async function openConversation(topicName: string): Promise<Topic> {
|
||||
const topic = getTinode().getTopic(topicName);
|
||||
wireTopicCallbacks(topic);
|
||||
if (topic.isSubscribed()) {
|
||||
await topic.leave(false);
|
||||
}
|
||||
const query = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux().withData(undefined, undefined, MESSAGES_PAGE);
|
||||
await topic.subscribe(query.build());
|
||||
return topic;
|
||||
}
|
||||
|
||||
/** Soft-leave (detach, not unsubscribe) -- cached messages stay in the SDK's local buffer. */
|
||||
export function closeConversation(topicName: string): void {
|
||||
getTinode().getTopic(topicName).leave(false).catch(() => {});
|
||||
}
|
||||
|
||||
let chatListSnapshot: Contact[] | null = null;
|
||||
|
||||
/**
|
||||
* Cached the same way getMessagesSnapshot is, but invalidated by ANY topic's change (a new message
|
||||
* on a contact that isn't currently open still needs to move that row and refresh its preview).
|
||||
* Triggers each contact's one-shot preview prefetch as a side effect of being read -- cheap and
|
||||
* idempotent (prefetchPreview no-ops after the first call per topic), so it's fine to do this on
|
||||
* every rebuild rather than needing a separate "wire up previews" pass.
|
||||
*/
|
||||
export function getChatListSnapshot(): Contact[] {
|
||||
if (chatListSnapshot) return chatListSnapshot;
|
||||
const byTopic = new Map<string, Contact>();
|
||||
getTinode()
|
||||
.getMeTopic()
|
||||
.contacts((contact) => {
|
||||
if (!contact.topic) return;
|
||||
prefetchPreview(contact.topic);
|
||||
byTopic.set(contact.topic, contact);
|
||||
});
|
||||
const rows = [...byTopic.values()];
|
||||
chatListSnapshot = rows;
|
||||
return rows;
|
||||
}
|
||||
|
||||
function invalidateChatListSnapshot() {
|
||||
chatListSnapshot = null;
|
||||
}
|
||||
anyTopicListeners.add(invalidateChatListSnapshot);
|
||||
Reference in New Issue
Block a user