feat(web): real chat list + P2P conversation view against live Tinode data

Phase 2 core loop: chat list and single-chat conversation now read/write real
Tinode topic data end to end, replacing the earlier hardcoded placeholders.

- lib/tinode/topics.ts: per-topic pub-sub layered on connection.ts's pattern
  (Topic.onData/onInfo/etc are single-slot callbacks, claimed once and fanned
  out). Implements the old app's subscribe lifecycle: `me` subscribed once
  per session (metadata only), each contact gets a one-shot preview
  subscription (withLaterData(1), never left), opening a conversation
  leaves-then-resubscribes with a full history page (withData, not
  withLaterData -- a preview prefetch already nudging _maxSeq would make
  withLaterData return empty).
- lib/tinode/hooks.ts: useChatList()/useTopicMessages() via
  useSyncExternalStore, snapshots cached and only invalidated on real
  topic-change notifications (not recomputed every render).
- lib/tinode/send.ts: sendText/retryMessage via createMessage+publishDraft
  (optimistic local echo), not the simpler publish() -- matches the old
  app's approach. retryMessage flushes the failed local entry and rebuilds
  before republishing, per the old app's context-menu retry handler.
- lib/messages/{time,avatar,attachments,status,preview,dispatch,drafty}:
  formatting utilities. Avatar color hash ports the old app's Java-style
  string hash but with modulus 11 (this design's palette) not the old app's
  16. @mention renders flat accent blue per the new design, not the old
  app's per-user hash color -- a deliberate simplification. File-type icons
  (PDF/Word/Excel/PPT) are new-design-only; the old app buckets all
  documents identically. Date/time formatting is written fresh against the
  design spec (today+time combined, weekday tier) since the old app never
  combines a relative-date word with a time in one string.
- ChatListColumn, MessageBubble, ConversationView: real components replacing
  the placeholder chat list and "占位" conversation view.
- tinode-sdk.d.ts: expanded with the real Topic/MetaGetBuilder/Drafty
  surface needed above, verified against the installed SDK source
  (createMessage/publishDraft/messages/msgStatus/noteRead/flushMessage/
  Drafty.format's exact treeBottomUp contract, MESSAGE_STATUS_* statics).

Verified end to end against the real backend: used the still-functional old
app (a second account, ixp355087) to create a real friendship and exchange
messages with the new app's test account, confirming the new chat list and
conversation view correctly render pre-existing real data, sending a new
message shows the right optimistic-then-delivered status transition, and
the old app receives it live in return.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-08 01:49:14 +08:00
co-authored by Claude Sonnet 5
parent aa1c46ff22
commit f549248c08
18 changed files with 968 additions and 68 deletions
+2
View File
@@ -7,6 +7,7 @@ import { PasswordInput } from '@/features/auth/components/PasswordInput';
import { BASE_APP_NAME } from '@/lib/config';
import { connect, getTinode } from '@/lib/tinode/connection';
import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
import { subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
export function LoginPage() {
@@ -48,6 +49,7 @@ export function LoginPage() {
const authToken = tinode.getAuthToken();
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
setMyUserId(tinode.getCurrentUserID() || null);
subscribeMeTopic().catch(() => {});
navigate('/messages');
} catch (err) {
setError(loginErrorMessage(err));
+2
View File
@@ -9,6 +9,7 @@ import { verifyRegisterCode } from '@/lib/api/auth';
import { ImApiError } from '@/lib/api/client';
import { getTinode, registerAccount } from '@/lib/tinode/connection';
import { registerErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
import { subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
const USERNAME_RE = /^[a-z0-9]{1,10}$/;
@@ -88,6 +89,7 @@ export function RegisterPage() {
const authToken = getTinode().getAuthToken();
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
setMyUserId((ctrl.params?.user as string | undefined) ?? null);
subscribeMeTopic().catch(() => {});
navigate('/messages');
} catch (err) {
setFormError(registerErrorMessage(err));
@@ -0,0 +1,73 @@
import { Link, useParams } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatListTime } from '@/lib/messages/time';
import { formatMessagePreview } from '@/lib/messages/preview';
import { useChatList } from '@/lib/tinode/hooks';
import { useSessionStore } from '@/stores/session';
/**
* Chat-list row sort: by `touched` (last activity) descending. Pinned-conversations-first isn't
* wired yet -- that's driven by the P2P conversation-settings API (GET/PATCH /conversations/{topic}/
* settings {pinned}), which the detail panel doesn't build until its "置顶聊天" toggle lands.
*/
export function ChatListColumn() {
const { topicId } = useParams();
const myUserId = useSessionStore((s) => s.myUserId);
const contacts = useChatList();
const sorted = [...contacts].sort((a, b) => (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0));
return (
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">消息</h1>
<Icon name="add_comment" className="text-text-heading" />
</header>
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
placeholder="搜索会话、联系人、消息"
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
/>
</div>
<div className="flex flex-1 flex-col overflow-y-auto">
{sorted.map((contact) => {
const name = contact.public?.fn || contact.topic;
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
const active = topicId === contact.topic;
return (
<Link
key={contact.topic}
to={`/messages/${contact.topic}`}
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item px-4 py-2.5 no-underline hover:bg-[#f6f8fb] ${
active ? 'bg-accent-selected-bg' : 'bg-white'
}`}
>
<span className={`relative grid h-11 w-11 place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(contact.topic || '')}`}>
{avatarLetter(name)}
{contact.online ? (
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-online" />
) : null}
</span>
<span className="flex min-w-0 flex-col gap-1">
<span className="flex items-center gap-1.5">
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
</span>
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
</span>
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
<time className="text-xs text-text-tertiary-3">{contact.touched ? formatListTime(contact.touched) : ''}</time>
{contact.unread ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{contact.unread}
</b>
) : null}
</span>
</Link>
);
})}
</div>
</aside>
);
}
+119 -14
View File
@@ -1,36 +1,141 @@
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}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1 md:bg-accent-soft-bg md:text-accent lg:bg-transparent lg:text-text-tertiary-1"
>
<Icon name="info" />
</button>
</div>
<button
type="button"
onClick={onToggleDetail}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1 md:bg-accent-soft-bg md:text-accent lg:bg-transparent lg:text-text-tertiary-1"
>
<Icon name="info" />
</button>
</header>
<div className="flex flex-1 items-center justify-center text-sm text-text-tertiary-4">
消息流占位(阶段1实现)
<div ref={streamRef} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
{messages.map((msg) => (
<MessageRow
key={msg.seq}
topic={topic}
msg={msg}
mine={!!myUserId && msg.from === myUserId}
isGroup={isGroup}
peerName={peerName}
/>
))}
</div>
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
<div className="flex items-center gap-1 px-1 pb-1">
<Icon name="sentiment_satisfied_alt" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="image" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="attach_file" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="mic_none" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
</div>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
className="min-h-[72px] w-full resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
/>
<div className="flex justify-end">
<button
type="button"
onClick={submit}
disabled={!draft.trim()}
className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
发送
</button>
</div>
</div>
</div>
);
@@ -0,0 +1,72 @@
import type { StoredMessage, Topic } from 'tinode-sdk';
import { Icon } from '@/app/Icon';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { isRecalled, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
import { retryMessage } from '@/lib/tinode/send';
interface MessageRowProps {
topic: Topic;
msg: StoredMessage;
mine: boolean;
isGroup: boolean;
peerName: string;
}
export function MessageRow({ topic, msg, mine, isGroup, peerName }: MessageRowProps) {
if (isServerDeleted(msg)) {
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
}
const noticeText = systemNoticeText(msg);
if (noticeText) {
return (
<div className="flex flex-col items-center gap-1 py-1">
<div className="text-xs text-text-tertiary-4">{noticeText}</div>
{msg.ts ? <div className="text-xs text-text-tertiary-4">{formatEventTimestamp(msg.ts)}</div> : null}
</div>
);
}
const failed = isFailedStatus(topic, msg);
const sending = isSendingStatus(topic, msg);
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
const retry = () => {
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq);
};
return (
<div className={`flex items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine ? (
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(msg.from || peerName)}`}
>
{avatarLetter(peerName)}
</span>
) : (
<span className="w-9 flex-none" />
)}
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
<div
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''}`}
>
{isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
</div>
{failed ? (
<button type="button" onClick={retry} className="flex items-center gap-1 text-xs text-danger-text">
<Icon name="error" className="text-[16px]" />
发送失败,点击重试
</button>
) : (
<span className="text-[11px] text-text-tertiary-3">{sending ? '发送中…' : statusLabel}</span>
)}
</div>
</div>
);
}
+1 -45
View File
@@ -3,51 +3,7 @@ import { useState } from 'react';
import { Outlet, useParams } from 'react-router-dom';
import { Icon } from '@/app/Icon';
const PLACEHOLDER_CHATS = [
{ id: 'grp1', name: '项目协作群', summary: '陈明远:@我 明天上午十点开项目会,请准时参加', time: '10:24', unread: 5 },
{ id: 'p1', name: '接语彤', summary: '我:收到,我会提前准备材料。', time: '10:12', unread: 0 },
{ id: 'grp2', name: '产品评审群', summary: '[图片]', time: '09:47', unread: 2 },
];
function ChatListColumn() {
const { topicId } = useParams();
return (
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">消息</h1>
<Icon name="add_comment" className="text-text-heading" />
</header>
<div className="flex flex-1 flex-col overflow-y-auto">
{PLACEHOLDER_CHATS.map((chat) => (
<a
key={chat.id}
href={`/messages/${chat.id}`}
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item px-4 py-2.5 no-underline ${
topicId === chat.id ? 'bg-accent-selected-bg' : 'bg-white'
}`}
>
<span className="grid h-11 w-11 place-items-center rounded-lg bg-avatar-1 text-base font-medium text-white">
{chat.name.slice(0, 1)}
</span>
<span className="flex min-w-0 flex-col gap-1">
<strong className="truncate text-[15px] font-medium text-[#272b31]">{chat.name}</strong>
<small className="truncate text-[13px] text-text-tertiary-2">{chat.summary}</small>
</span>
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
<time className="text-xs text-text-tertiary-3">{chat.time}</time>
{chat.unread ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{chat.unread}
</b>
) : null}
</span>
</a>
))}
</div>
</aside>
);
}
import { ChatListColumn } from '@/features/messages/ChatListColumn';
function DetailColumn({ onClose }: { onClose?: () => void }) {
return (