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) => ( + + ))} +
+ +
+
+ + + + + + +
+