(null);
const autoScrollRef = useRef(true);
- const topicIdRef = useRef(topicId);
- useEffect(() => {
- topicIdRef.current = topicId;
- });
+ // Bumped every time a conversation is (re)opened below -- NOT the same as comparing topicId by
+ // value, which would false-positive if the user opens A, then B, then A again within a single
+ // loadOlder() round trip: topicId ends up back at "A" even though the DOM has since been torn
+ // down and rebuilt by the intervening open/close cycle, so a same-topicId check alone would wrongly
+ // treat that as "still the same open" and apply a scroll-restore computed against content that
+ // isn't there anymore.
+ const openGenerationRef = useRef(0);
// Every "fire and forget" send/recall/favorite/forward action below used to swallow its own
// failure with an empty `.catch(() => {})` (or, for forward, no catch at all) -- e.g. an
@@ -91,7 +94,12 @@ export function ConversationView() {
};
}, [topicId]);
- useEffect(() => {
+ // useLayoutEffect, not useEffect -- this component never remounts on topicId change (route is
+ // `:topicId`), so a plain useEffect (which fires after paint) can let one frame render with the
+ // PREVIOUS conversation's select-mode/menu/etc. still showing over the new conversation's message
+ // list underneath, self-correcting only on the next tick.
+ useLayoutEffect(() => {
+ openGenerationRef.current += 1;
setSelectMode(false);
setSelectedSeqs(new Set());
setMenu(null);
@@ -110,6 +118,9 @@ export function ConversationView() {
// NEW conversation and silently skips scrolling it to the bottom on open.
setLoadingOlder(false);
autoScrollRef.current = true;
+ // An error toast from the previous conversation (e.g. "图片发送失败") would otherwise keep
+ // showing over whichever conversation is now open, misattributing the failure.
+ setErrorToast('');
}, [topicId]);
useEffect(() => {
@@ -139,7 +150,7 @@ export function ConversationView() {
*/
const loadOlder = async () => {
if (loadingOlder) return;
- const calledForTopicId = topicId;
+ const calledForGeneration = openGenerationRef.current;
const el = streamRef.current;
const min = topic.minMsgSeq();
const max = topic.maxMsgSeq();
@@ -151,12 +162,13 @@ export function ConversationView() {
try {
await topic.getMessagesPage(MESSAGES_PAGE, gaps, min, max);
} finally {
- // If the user switched to a different conversation while this was in flight, `el` is now
- // showing that OTHER topic's messages -- prevTop/prevHeight were measured against content
- // that isn't there anymore, so applying them would jump the NEW conversation's scroll
- // position to a meaningless spot. Only the topic that actually asked for more history should
- // ever clear its own loading flag or touch the scroll position.
- if (topicIdRef.current === calledForTopicId) {
+ // If the conversation was closed and reopened (even back to this SAME topicId) while this was
+ // in flight, `el` now shows content from a different open/close cycle -- prevTop/prevHeight
+ // were measured against content that isn't there anymore, so applying them would jump the
+ // current conversation's scroll to a meaningless spot. Comparing topicId alone would miss an
+ // A->B->A round trip (same string, different generation); only the specific open this request
+ // was made for should ever clear its own loading flag or touch the scroll position.
+ if (openGenerationRef.current === calledForGeneration) {
setLoadingOlder(false);
requestAnimationFrame(() => {
if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
@@ -446,7 +458,7 @@ export function ConversationView() {
type="button"
onClick={submit}
disabled={!draft.trim()}
- className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
+ className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
>
发送
diff --git a/web/src/features/messages/DetailColumn.tsx b/web/src/features/messages/DetailColumn.tsx
index 0f8b6f74..f90ffe83 100644
--- a/web/src/features/messages/DetailColumn.tsx
+++ b/web/src/features/messages/DetailColumn.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useLayoutEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
@@ -56,10 +56,14 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
// DetailColumn is reused across every contact when docked (no `key={topicId}`, same instance),
// so without this an in-progress remark edit for the PREVIOUS contact stays open and could be
- // saved against the NEW one.
- useEffect(() => {
+ // saved against the NEW one, and a leftover error toast would keep showing over the new contact
+ // misattributing the failure. useLayoutEffect (not useEffect) so this resolves before paint --
+ // this component never remounts on topicId change, so a post-paint effect can let one frame
+ // render with the previous contact's stale edit box still open.
+ useLayoutEffect(() => {
setRemarkEditing(false);
setRemarkDraft('');
+ setErrorToast('');
}, [topicId]);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
@@ -98,7 +102,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
});
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
- const isBlocked = !!blocksQuery.data?.some((b) => b.user_id === topicId);
+ const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === topicId);
const deleteFriendMutation = useMutation({
mutationFn: () => deleteFriend(topicId),
diff --git a/web/src/features/messages/MessagesLayout.tsx b/web/src/features/messages/MessagesLayout.tsx
index e9928635..a5b4f96e 100644
--- a/web/src/features/messages/MessagesLayout.tsx
+++ b/web/src/features/messages/MessagesLayout.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useLayoutEffect, useState } from 'react';
import { Outlet, useParams } from 'react-router-dom';
@@ -37,7 +37,7 @@ export function MessagesLayout() {
// MessagesLayout is one long-lived instance reused across every conversation (route is
// `:topicId`) -- a history-search modal left open while switching topics would otherwise keep
// showing results for whichever topic it was opened against, not the one now on screen.
- useEffect(() => setHistoryTab(null), [topicId]);
+ useLayoutEffect(() => setHistoryTab(null), [topicId]);
return (
diff --git a/web/src/features/messages/VoiceRecorder.tsx b/web/src/features/messages/VoiceRecorder.tsx
index 0b1088a7..40703b8f 100644
--- a/web/src/features/messages/VoiceRecorder.tsx
+++ b/web/src/features/messages/VoiceRecorder.tsx
@@ -74,6 +74,16 @@ export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
+ // Without this, a mid-recording failure (mic unplugged, OS audio error) auto-transitions
+ // the recorder to 'inactive' with nothing telling the UI -- the "录音中" timer just keeps
+ // ticking forever with no way to know the recording already died until the user manually
+ // hits 取消/发送 and finish()'s own `recorder.state === 'inactive'` check silently no-ops.
+ recorder.onerror = () => {
+ if (finishedRef.current) return;
+ finishedRef.current = true;
+ stopStream();
+ setError('录音失败,请重试');
+ };
recorder.start();
recorderRef.current = recorder;
startedAtRef.current = Date.now();
@@ -125,7 +135,7 @@ export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
type="button"
onClick={() => finish(true)}
disabled={!ready}
- className="flex h-8 items-center gap-1 rounded-lg bg-btn px-4 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled"
+ className="flex h-8 items-center gap-1 rounded-lg bg-btn px-4 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled"
>
发送
diff --git a/web/src/index.css b/web/src/index.css
index b60f73e1..5858b77e 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -103,10 +103,14 @@
/* Shadows */
--shadow-card: 0 8px 32px rgba(35, 74, 125, 0.10);
- --shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.18);
+ --shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.2);
--shadow-menu: 0 12px 36px rgba(0, 0, 0, 0.16);
--shadow-bubble: 0 1px 2px rgba(0, 0, 0, 0.06);
+ /* screens/01-account.dc.html's 50px auth CTAs (登录/注册/提交...). */
--shadow-btn: 0 6px 16px rgba(54, 139, 250, 0.2);
+ /* screens/02-messages.dc.html's 36px 发送 button (ConversationView/VoiceRecorder) -- a smaller
+ button gets a smaller shadow, not the same token as the 50px auth buttons above. */
+ --shadow-btn-sm: 0 4px 12px rgba(54, 139, 250, 0.2);
--shadow-focus-ring: 0 0 0 3px rgba(22, 119, 255, 0.1);
}
diff --git a/web/src/lib/hooks.ts b/web/src/lib/hooks.ts
index 88dc9ddb..9f1f2f6f 100644
--- a/web/src/lib/hooks.ts
+++ b/web/src/lib/hooks.ts
@@ -9,8 +9,9 @@ import { fileAuthHeaders, fileDownloadUrl } from '@/lib/api/upload';
* into a blob URL rather than a direct URL. `ref` is anything fileDownloadUrl accepts: a full
* "/v0/file/s/xxx" path (Drafty `refurl`), or an absolute URL.
*/
-export function useAuthedBlobUrl(ref: string | undefined): { url: string | null; loading: boolean; error: boolean } {
+export function useAuthedBlobUrl(ref: string | undefined): { url: string | null; loading: boolean; error: boolean; retry: () => void } {
const [state, setState] = useState<{ url: string | null; loading: boolean; error: boolean }>({ url: null, loading: !!ref, error: false });
+ const [retryCount, setRetryCount] = useState(0);
useEffect(() => {
if (!ref) return undefined;
@@ -34,9 +35,9 @@ export function useAuthedBlobUrl(ref: string | undefined): { url: string | null;
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
- }, [ref]);
+ }, [ref, retryCount]);
- return state;
+ return { ...state, retry: () => setRetryCount((n) => n + 1) };
}
/** Ported from the old app's use-cached-data.js -- debounces a fast-changing value (e.g. search input). */