diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index fbb2c319..84492327 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom'; @@ -16,7 +16,7 @@ import { VoiceRecorder } from '@/features/messages/VoiceRecorder'; import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal'; import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal'; import { useBootstrap } from '@/lib/api/hooks'; -import { favoriteMessages, type MediaType, recallMessage } from '@/lib/api/messages'; +import { favoriteMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages'; import { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups'; import { getMe } from '@/lib/api/profile'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; @@ -79,6 +79,80 @@ export function ConversationView() { const setDraft = (value: string) => { if (topicId) setDraftByTopic((prev) => ({ ...prev, [topicId]: value })); }; + + // Server-side draft persistence (Zentao #19: draft cleared on conversation switch/page refresh -- + // draftByTopic above only survives WITHIN this one mounted session, never a refresh or a different + // device/tab). `getTopicDraft`/`putTopicDraft`'s own comment covers the real, live `PUT /v1/im/ + // topics/{topic}/draft` endpoint this hits (the protocol doc marks it "待实现", which is stale). + // + // draftVersionRef, not state -- purely an implementation detail this optimistic-lock write needs to + // send back, never rendered, so it doesn't need to trigger a re-render when it changes. + const draftVersionRef = useRef>({}); + // The content this app most recently confirmed IS what the server has (from either a completed load + // or a completed save) -- lets the debounce-save effect below tell "the user actually typed + // something new" apart from "draft just changed because the load effect populated it with what the + // server already had," which would otherwise queue an entirely redundant save of identical content + // the moment every conversation with an existing draft is opened. + const lastSyncedContentRef = useRef>({}); + // Tracks which topics' server draft has already been fetched THIS session -- fetching again every + // time the user merely glances back at an already-open conversation would risk clobbering local text + // already being typed with whatever stale snapshot the server still has from before this session. + const draftLoadedRef = useRef>(new Set()); + useEffect(() => { + if (!topicId || draftLoadedRef.current.has(topicId)) return; + draftLoadedRef.current.add(topicId); + const forTopic = topicId; + getTopicDraft(forTopic) + .then((d) => { + draftVersionRef.current[forTopic] = d.version; + lastSyncedContentRef.current[forTopic] = d.content; + if (!d.content) return; + // Local (this-session) text always wins over the server snapshot -- the whole point of + // fetching is recovering a draft AFTER a refresh/new session, not overwriting an edit already + // in progress if this same topic was somehow already typed into before this fetch resolved. + setDraftByTopic((prev) => (prev[forTopic] ? prev : { ...prev, [forTopic]: d.content })); + }) + .catch(() => {}); + }, [topicId]); + + // Debounced (1.5s after the user stops typing) rather than on every keystroke -- draft content + // isn't urgent enough to warrant a network write per character. `pendingDraftRef` (not just the + // timer) is what the flush effect below actually sends, kept up to date on every keystroke + // regardless of whether ITS OWN cleanup below beat the timer to canceling it. + const pendingDraftRef = useRef<{ topic: string; content: string; timer: ReturnType } | null>(null); + const flushDraftSave = useCallback(() => { + const pending = pendingDraftRef.current; + if (!pending) return; + clearTimeout(pending.timer); + pendingDraftRef.current = null; + const version = draftVersionRef.current[pending.topic] ?? 0; + putTopicDraft(pending.topic, pending.content, version) + .then((d) => { + draftVersionRef.current[pending.topic] = d.version; + lastSyncedContentRef.current[pending.topic] = pending.content; + }) + .catch(() => { + // A stale optimistic-lock version (edited on another device/tab in between) or a transient + // network failure -- a personal, single-user, ephemeral draft doesn't warrant a refetch-and- + // retry dance over losing the very latest keystrokes here; the next successful save (the next + // debounce tick, or the next time this conversation is opened) catches back up regardless. + }); + }, []); + useEffect(() => { + if (!topicId || !draftLoadedRef.current.has(topicId)) return undefined; + if (draft === lastSyncedContentRef.current[topicId]) return undefined; // nothing actually changed + if (pendingDraftRef.current) clearTimeout(pendingDraftRef.current.timer); + const timer = setTimeout(flushDraftSave, 1500); + pendingDraftRef.current = { topic: topicId, content: draft, timer }; + return () => clearTimeout(timer); + }, [topicId, draft, flushDraftSave]); + // Separate from the effect above on purpose: THIS one's cleanup fires flushDraftSave() itself (not + // just clearTimeout) so switching to a different conversation -- or closing/navigating away + // entirely -- saves whatever's pending immediately, instead of abandoning it to a 1.5s timer that + // may never get the chance to fire once its own component instance's timers are gone. + useEffect(() => { + return () => flushDraftSave(); + }, [topicId, flushDraftSave]); // Same per-topic keying as draftByTopic, and for the same reason -- a mention picked while // composing in group A must not still be "pending" if the user switches to group B and back // before sending (buildMentionedContent would then splice a real MN entity for a name that's diff --git a/web/src/lib/api/messages.ts b/web/src/lib/api/messages.ts index 0edb47ea..dbb152d5 100644 --- a/web/src/lib/api/messages.ts +++ b/web/src/lib/api/messages.ts @@ -3,7 +3,7 @@ // calls this then hard-deletes the message from the local topic cache itself. import type { DraftyContent } from 'tinode-sdk'; -import { imGet, imPost } from './client'; +import { imGet, imPost, imPut } from './client'; export interface RecallResult { topic: string; @@ -107,6 +107,33 @@ export function getTopicMedia(topic: string, type: MediaFetchType, cursor = '') return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`); } +export interface TopicDraft { + topic: string; + content: string; + entities: unknown[]; + version: number; + updated_at?: string; +} + +/** + * README §3.2's unsent-message-draft persistence (Zentao #19: input clears on conversation switch/ + * page refresh). docs/IM-前后端对接文档.md:1003/1247 marks `PUT /v1/im/topics/{topic}/draft` as + * "待实现" (not yet implemented) and describes local-only storage as the baseline -- but confirmed by + * reading im_be's own source (server/im_http.go's serveIMTopicDraft, wired to store.Conversations. + * GetDraft/PutDraft), this endpoint is REAL and already live, the doc is just stale. A draft that's + * never been saved comes back as a 200 (not 404) with `content:""`/`version:0`, so GET never needs + * special-casing a not-found error -- confirmed in the same source. + */ +export function getTopicDraft(topic: string) { + return imGet(`/topics/${encodeURIComponent(topic)}/draft`); +} + +/** `entities` intentionally always `[]` for now -- this app's draft state is plain text only (no + * in-progress @mention/attachment entities tracked yet), and the field is optional server-side. */ +export function putTopicDraft(topic: string, content: string, version: number) { + return imPut(`/topics/${encodeURIComponent(topic)}/draft`, { content, entities: [], version }); +} + /** * A single message's "收藏" context-menu action is really just a one-item batch (docs/ * IM-前后端对接文档.md:1935): `POST /v1/im/favorites/batch {"messages":[{"topic","seq"}]}` ->