接入服务端消息草稿持久化,修复切换会话/刷新页面丢失未发送草稿(禅道#19)

协议文档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 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 21:55:55 +08:00
co-authored by Claude Sonnet 5
parent 4bf4e75cac
commit 3df61e9e59
2 changed files with 104 additions and 3 deletions
+76 -2
View File
@@ -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 { useQuery } from '@tanstack/react-query';
import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom'; 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 { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal'; import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
import { useBootstrap } from '@/lib/api/hooks'; 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 { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups';
import { getMe } from '@/lib/api/profile'; import { getMe } from '@/lib/api/profile';
import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { avatarBgClass, avatarLetter } from '@/lib/avatar';
@@ -79,6 +79,80 @@ export function ConversationView() {
const setDraft = (value: string) => { const setDraft = (value: string) => {
if (topicId) setDraftByTopic((prev) => ({ ...prev, [topicId]: value })); 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<Record<string, number>>({});
// 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<Record<string, string>>({});
// 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<Set<string>>(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<typeof setTimeout> } | 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 // 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 // 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 // before sending (buildMentionedContent would then splice a real MN entity for a name that's
+28 -1
View File
@@ -3,7 +3,7 @@
// calls this then hard-deletes the message from the local topic cache itself. // calls this then hard-deletes the message from the local topic cache itself.
import type { DraftyContent } from 'tinode-sdk'; import type { DraftyContent } from 'tinode-sdk';
import { imGet, imPost } from './client'; import { imGet, imPost, imPut } from './client';
export interface RecallResult { export interface RecallResult {
topic: string; 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()}`); 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<TopicDraft>(`/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<TopicDraft>(`/topics/${encodeURIComponent(topic)}/draft`, { content, entities: [], version });
}
/** /**
* A single message's "收藏" context-menu action is really just a one-item batch (docs/ * 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"}]}` -> * IM-前后端对接文档.md:1935): `POST /v1/im/favorites/batch {"messages":[{"topic","seq"}]}` ->