diff --git a/web/src/features/calls/CallHistoryView.tsx b/web/src/features/calls/CallHistoryView.tsx index a1911dc8..2597c908 100644 --- a/web/src/features/calls/CallHistoryView.tsx +++ b/web/src/features/calls/CallHistoryView.tsx @@ -1,5 +1,5 @@ import { useInfiniteQuery } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { Icon } from '@/app/Icon'; import { getCallHistory, type CallHistoryItem } from '@/lib/api/calls'; @@ -45,12 +45,30 @@ export function CallHistoryView() { return (
-
+ {/* `/calls`, like `/favorites`, is only reachable on a narrow viewport via 设置(我的)>通话记录 + (SettingsLayout.tsx's MOBILE_SHORTCUTS) -- same "back returns there" convention + FavoritesLayout's own mobile header already uses, and the same gap this file didn't have + until now (there was no way back from here at all short of a system back gesture). */} +
+ + + + 通话记录 + +
+
通话记录
{query.isLoading ? (
加载中…
+ ) : query.isError ? ( +
+ 加载失败,请重试 + +
) : items.length === 0 ? (
暂无通话记录
) : ( diff --git a/web/src/features/calls/CallRoot.tsx b/web/src/features/calls/CallRoot.tsx index 17bafa3f..a8443f56 100644 --- a/web/src/features/calls/CallRoot.tsx +++ b/web/src/features/calls/CallRoot.tsx @@ -375,8 +375,11 @@ function EndedCallOverlay() { return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false }; case 'missed': return { label: wasCaller ? '对方未接听,已取消' : '未接听', danger: true }; + // `wasCaller` here is `false` exactly when this device is the one that just clicked + // "拒绝" (rejectCall() only runs during `phase==='incoming'`) -- same distinction `missed` + // right above already makes, "对方已拒绝" is backwards on the decliner's own screen. case 'declined': - return { label: '对方已拒绝', danger: true }; + return { label: wasCaller ? '对方已拒绝' : '已拒绝', danger: true }; case 'disconnected': return { label: '通话连接异常,已结束', danger: true }; case 'busy': diff --git a/web/src/features/favorites/FavoritesLayout.tsx b/web/src/features/favorites/FavoritesLayout.tsx index b43bb114..4f484ed3 100644 --- a/web/src/features/favorites/FavoritesLayout.tsx +++ b/web/src/features/favorites/FavoritesLayout.tsx @@ -193,7 +193,7 @@ function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite {body} {selectMode ? : null}
-
+
{formatEventTimestamp(new Date(item.collected_at))}
@@ -271,8 +271,16 @@ export function FavoritesLayout() { }, onSuccess: (data) => { invalidateAll(); - exitSelectMode(); - if (data?.failed?.length) setError(`${data.failed.length} 项删除失败,请重试`); + // Exiting select mode unconditionally used to clear `selected` even on a PARTIAL failure -- + // the error toast named a count ("N项删除失败") but gave no way to act on it, so retrying + // meant re-selecting from scratch by memory. Only actually-failed ids stay selected instead, + // primed for an immediate retry. + if (data?.failed?.length) { + setSelected(new Set(data.failed.map((f) => f.favorite_id))); + setError(`${data.failed.length} 项删除失败,请重试`); + } else { + exitSelectMode(); + } }, onError: (err) => setError(err instanceof Error ? err.message : '删除失败'), }); @@ -284,10 +292,13 @@ export function FavoritesLayout() { forwardFavorites([...selected], targetTopics, comment), onSuccess: (data) => { setForwarding(false); - exitSelectMode(); // Same `failed[]` gap as deleteMutation above (favorites.ts:101) -- forwarding to 3 targets can - // succeed for 2 and fail for 1, and silently reporting "success" either way hides that. + // succeed for 2 and fail for 1, and silently reporting "success" either way (including + // clearing the favorites selection) hid that AND left nothing to retry from. The picker + // itself still closes (which of several targets failed isn't tracked here) but the source + // favorites stay selected so re-opening "转发" immediately can retry against the same set. if (data.failed?.length) setError(`${data.failed.length} 项转发失败,请重试`); + else exitSelectMode(); }, onError: (err) => setError(err instanceof Error ? err.message : '转发失败'), }); @@ -335,7 +346,7 @@ export function FavoritesLayout() { type="button" onClick={() => switchCategory(c.key)} className={`flex-none whitespace-nowrap rounded-2xl px-3.5 py-1.5 text-[13px] ${ - category === c.key ? 'bg-accent text-white' : 'bg-[#f1f2f4] text-text-body' + category === c.key ? 'bg-accent text-white' : 'bg-[#f1f2f4] text-text-tertiary-1' }`} > {c.label} @@ -379,7 +390,7 @@ export function FavoritesLayout() { category === c.key ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]' }`} > - + {c.label} ))} diff --git a/web/src/features/system/SystemMessagesView.tsx b/web/src/features/system/SystemMessagesView.tsx index 67b32a31..2aa83d49 100644 --- a/web/src/features/system/SystemMessagesView.tsx +++ b/web/src/features/system/SystemMessagesView.tsx @@ -117,10 +117,22 @@ export function SystemMessagesView() { // one page of notifications (a real, confirmed-live-possible infinite hang, not just a stale // comment claiming otherwise). Re-reading the cache's own `next_cursor` each iteration instead // of trusting a captured object's field is what actually terminates. + // `fetchNextPage()` swallows its own request failures (TanStack Query v5's QueryObserver + // catches internally rather than rejecting) -- a page that fails to load leaves `next_cursor` + // exactly where it was, so a plain `while (cursor)` would retry the SAME stuck page forever on + // any transient failure (expired token, network blip), leaving `markingAll` stuck true and + // this button permanently showing "标记中…" with nothing telling the user why. Capped so a + // failure surfaces as an error instead of hanging. let cursor = queryClient.getQueryData(['system-notifications'])?.pages.at(-1)?.next_cursor; - while (cursor) { + let guard = 0; + while (cursor && guard++ < 200) { await query.fetchNextPage(); - cursor = queryClient.getQueryData(['system-notifications'])?.pages.at(-1)?.next_cursor; + const next = queryClient.getQueryData(['system-notifications'])?.pages.at(-1)?.next_cursor; + if (next === cursor) { + setError('翻页失败,请重试'); + return; + } + cursor = next; } const allItems = queryClient.getQueryData(['system-notifications'])?.pages.flatMap((p) => p.items) ?? []; // Each item's own failure already surfaces via readMutation's onError above (mutateAsync fires @@ -155,7 +167,11 @@ export function SystemMessagesView() { all-pages total NavRail's own badge already trusts (and now invalidated by readMutation's onSuccess below, so it won't go stale either) -- preferred over the loaded-only count whenever it's available. */} - {(bootstrap.data?.entry_badges?.system_unread_count ?? unread.length) ? ( + {/* `??` only guards against a MISSING bootstrap count, not a STALE one -- bootstrap is + `staleTime: Infinity` and only ever invalidated in the "count goes down" direction + (readMutation's onSuccess below), so a cached `0` from before any new notification + arrived would otherwise out-vote a real non-empty `unread` this render actually has. */} + {Math.max(bootstrap.data?.entry_badges?.system_unread_count ?? 0, unread.length) ? ( diff --git a/web/src/stores/calls.ts b/web/src/stores/calls.ts index 4fc3f913..e102db5b 100644 --- a/web/src/stores/calls.ts +++ b/web/src/stores/calls.ts @@ -152,7 +152,12 @@ export const useCallStore = create((set, get) => ({ hangUp: () => { const { topic, seq, callId, connectedAt } = get(); - if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId); + // `rejectCall()` right above already covers the callee-declines-before-accepting case, so + // `hangUp()` seeing `!connectedAt` only ever means the CALLER is giving up on their own + // still-ringing outgoing call (manual cancel, or the 60s ring-timeout below firing this same + // function) -- IM-前后端对接文档.md's own wire example for exactly that case is + // `{note event:"hang-up", payload:{reason:"cancelled"}}`, which this never actually sent. + if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId, connectedAt ? undefined : 'cancelled'); leaveCall(); get()._endWithReason(connectedAt ? 'finished' : 'missed'); },