From f549248c08e5650fec649b285b7e15f9b59e7f3a Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 8 Sep 2026 01:49:14 +0800 Subject: [PATCH] feat(web): real chat list + P2P conversation view against live Tinode data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/features/auth/LoginPage.tsx | 2 + web/src/features/auth/RegisterPage.tsx | 2 + web/src/features/messages/ChatListColumn.tsx | 73 +++++++ .../features/messages/ConversationView.tsx | 133 ++++++++++-- web/src/features/messages/MessageBubble.tsx | 72 +++++++ web/src/features/messages/MessagesLayout.tsx | 46 +--- web/src/lib/avatar.ts | 32 +++ web/src/lib/config.ts | 4 + web/src/lib/messages/attachments.ts | 42 ++++ web/src/lib/messages/dispatch.ts | 37 ++++ web/src/lib/messages/drafty.tsx | 68 ++++++ web/src/lib/messages/preview.ts | 37 ++++ web/src/lib/messages/status.ts | 36 ++++ web/src/lib/messages/time.ts | 48 +++++ web/src/lib/tinode/hooks.ts | 20 +- web/src/lib/tinode/send.ts | 32 +++ web/src/lib/tinode/tinode-sdk.d.ts | 203 +++++++++++++++++- web/src/lib/tinode/topics.ts | 149 +++++++++++++ 18 files changed, 968 insertions(+), 68 deletions(-) create mode 100644 web/src/features/messages/ChatListColumn.tsx create mode 100644 web/src/features/messages/MessageBubble.tsx create mode 100644 web/src/lib/avatar.ts create mode 100644 web/src/lib/messages/attachments.ts create mode 100644 web/src/lib/messages/dispatch.ts create mode 100644 web/src/lib/messages/drafty.tsx create mode 100644 web/src/lib/messages/preview.ts create mode 100644 web/src/lib/messages/status.ts create mode 100644 web/src/lib/messages/time.ts create mode 100644 web/src/lib/tinode/send.ts create mode 100644 web/src/lib/tinode/topics.ts diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx index 36fe2bb6..429a8820 100644 --- a/web/src/features/auth/LoginPage.tsx +++ b/web/src/features/auth/LoginPage.tsx @@ -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)); diff --git a/web/src/features/auth/RegisterPage.tsx b/web/src/features/auth/RegisterPage.tsx index f74fe835..09dbf8cd 100644 --- a/web/src/features/auth/RegisterPage.tsx +++ b/web/src/features/auth/RegisterPage.tsx @@ -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)); diff --git a/web/src/features/messages/ChatListColumn.tsx b/web/src/features/messages/ChatListColumn.tsx new file mode 100644 index 00000000..24b0b9ef --- /dev/null +++ b/web/src/features/messages/ChatListColumn.tsx @@ -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 ( + + ); +} diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index bc277547..1639fa60 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -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(); + 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(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) => { + 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 (
-
- {/* 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) => ( + + ))} +
+ +
+
+ + + + + + +
+