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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user