From 3df61e9e59c9701e7a4a36616310d84b795e95e9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 21:55:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E8=8D=89=E7=A8=BF=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=88=87=E6=8D=A2=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?/=E5=88=B7=E6=96=B0=E9=A1=B5=E9=9D=A2=E4=B8=A2=E5=A4=B1?= =?UTF-8?q?=E6=9C=AA=E5=8F=91=E9=80=81=E8=8D=89=E7=A8=BF=EF=BC=88=E7=A6=85?= =?UTF-8?q?=E9=81=93#19=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 协议文档docs/IM-前后端对接文档.md:1003/1247把`PUT /v1/im/topics/{topic}/draft` 标注成"待实现"、把本地保存写成默认方案——但读im_be自己的源码(server/im_http.go 的serveIMTopicDraft,接的是store.Conversations.GetDraft/PutDraft)确认这个 接口其实真实可用,文档只是没跟上后端进度。之前草稿只有draftByTopic这一份纯 内存状态,没有任何持久化,刷新页面或者(取决于路由是否把ConversationView 整个卸载)切换会话都可能把草稿丢光。 实现:打开一个会话时(每个topic每个会话只拉一次)用GET拉取服务端草稿,如果 本地这个session里还没有内容才用它填充(本地正在打的字优先,不会被服务端的 旧快照打断);输入停止1.5秒后用PUT保存(带乐观锁version),不是按键就存; 切换到别的会话或整个离开消息页面时立即flush掉还没来得及等到防抖的最新内容, 不会因为切太快而丢最后几个字。加了"最后一次确认同步的内容"记录,避免"打开 会话→加载已有草稿→内容变化触发保存"这个误判成"用户编辑了"从而立刻把同一份 内容又存一遍的多余请求。 实机验证(systest04账号,因为systest01/systest02当时正被用户在真实浏览器里 测试): - 刷新页面:输入框正确恢复之前的草稿内容 - 切到另一个具体会话再切回:正确恢复(走的是服务端往返,不是本地状态留存, 切换瞬间有个最多一两秒的短暂加载间隙,之后正确填充) - 输入后不到1.5秒就切走:flush机制确认立即保存,没有丢内容 - 加载已有草稿本身不会触发多余的重复保存(验证了修复前这个问题真实存在—— 版本号无缘无故多跳了一次) - 发送消息后:本地和服务端的草稿都正确清空 Co-Authored-By: Claude Sonnet 5 --- .../features/messages/ConversationView.tsx | 78 ++++++++++++++++++- web/src/lib/api/messages.ts | 29 ++++++- 2 files changed, 104 insertions(+), 3 deletions(-) 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"}]}` ->