接入服务端消息草稿持久化,修复切换会话/刷新页面丢失未发送草稿(禅道#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:
co-authored by
Claude Sonnet 5
parent
4bf4e75cac
commit
3df61e9e59
@@ -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<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
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user