修复codereview复核确认的多选/转发/收藏页/资料页等前端bug

- 多选模式下Drafty链接、名片消息不再意外触发跳转/开新标签页
- 语音/视频消息在多选模式下点击可正常冒泡选中该行
- 转发排除未确认/发送失败消息,识别uncertain转发结果并单独提示
- 群文件"更多操作"菜单竞态、群成员移出状态竞态
- 收藏页链接卡片new URL()崩溃点加防御,并给登录后应用区域加错误边界
- 个人资料页乐观锁版本冲突时清空草稿避免静默覆盖并发修改

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-27 16:47:32 +08:00
co-authored by Claude Sonnet 5
parent e3eed8bdd4
commit 9abae5030b
10 changed files with 142 additions and 27 deletions
+27 -1
View File
@@ -1,9 +1,10 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom'; import { Navigate, createBrowserRouter, RouterProvider, useRouteError } from 'react-router-dom';
import { AppShell } from '@/app/AppShell'; import { AppShell } from '@/app/AppShell';
import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot'; import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
import { GlobalToastRoot } from '@/app/GlobalToastRoot'; import { GlobalToastRoot } from '@/app/GlobalToastRoot';
import { Icon } from '@/app/Icon';
import { KickedNoticeRoot } from '@/app/KickedNoticeRoot'; import { KickedNoticeRoot } from '@/app/KickedNoticeRoot';
import { RequireAuth } from '@/app/RequireAuth'; import { RequireAuth } from '@/app/RequireAuth';
import { SecurityChallengeModal } from '@/app/SecurityChallengeModal'; import { SecurityChallengeModal } from '@/app/SecurityChallengeModal';
@@ -34,6 +35,30 @@ import { SystemMessagesView } from '@/features/system/SystemMessagesView';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
/**
* Without this, an uncaught render-time exception ANYWHERE under `<AppShell>` (any page, any one
* bad field in server data) falls through to react-router's own built-in default error boundary --
* real, not hypothetical (confirmed live: `node_modules/react-router` DOES implicitly wrap the
* outermost matched route the way this codebase never configured one to). That default shows
* "Unexpected Application Error!" plus the raw error message/stack to the end user and replaces the
* WHOLE logged-in shell, sidebar included, with no way back except a full page reload. Attached to
* AppShell (not each leaf route) so it covers every authenticated page with one component, and low
* enough that `RequireAuth`/login/tenant-entry are never affected by a bug in, say, 收藏.
*/
function RouteErrorBoundary() {
const error = useRouteError();
if (import.meta.env.DEV) console.error(error);
return (
<div className="flex h-dvh w-full flex-col items-center justify-center gap-3 bg-surface-page px-6 text-center">
<Icon name="error_outline" className="text-[40px] text-text-tertiary-3" />
<p className="text-sm text-text-secondary">页面出现异常,请刷新重试</p>
<button type="button" onClick={() => window.location.reload()} className="rounded-lg bg-accent px-4 py-2 text-sm text-white">
刷新页面
</button>
</div>
);
}
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: 'tenant', element: <TenantEntryPage /> }, { path: 'tenant', element: <TenantEntryPage /> },
{ path: 'login', element: <LoginPage /> }, { path: 'login', element: <LoginPage /> },
@@ -45,6 +70,7 @@ const router = createBrowserRouter([
children: [ children: [
{ {
element: <AppShell />, element: <AppShell />,
errorElement: <RouteErrorBoundary />,
children: [ children: [
{ index: true, element: <Navigate to="/messages" replace /> }, { index: true, element: <Navigate to="/messages" replace /> },
{ {
+13 -1
View File
@@ -144,7 +144,19 @@ function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite
// Only show a second line when it says something the title line doesn't already -- a // Only show a second line when it says something the title line doesn't already -- a
// hostname alongside a real title/url is useful, but repeating `text` under itself when // hostname alongside a real title/url is useful, but repeating `text` under itself when
// that's ALSO what the title line fell back to would just be the same line twice. // that's ALSO what the title line fell back to would just be the same line twice.
const subtitle = item.snapshot.url ? new URL(item.snapshot.url).hostname : title === text ? '' : text; // `new URL()` throws on anything that isn't a well-formed absolute URL -- `url` can come from
// another client's own Drafty implementation or the backend's own extraction logic (docs/
// IM-前后端对接文档.md §7.6), neither of which this codebase controls the shape of. A single
// malformed value here must not crash the whole render (there's no error boundary around this
// list short of react-router's own default one, which would replace the entire logged-in UI).
let subtitle = title === text ? '' : text;
if (item.snapshot.url) {
try {
subtitle = new URL(item.snapshot.url).hostname;
} catch {
// fall through to the plain-text fallback computed above
}
}
return ( return (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{item.snapshot.thumbnail_file_id ? ( {item.snapshot.thumbnail_file_id ? (
+7 -1
View File
@@ -219,7 +219,13 @@ export function GroupFilesModal({ topicId, isAdmin, canUpload, onClose }: GroupF
isPdf={pdf} isPdf={pdf}
open={openMenuId === f.group_file_id} open={openMenuId === f.group_file_id}
onToggle={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : f.group_file_id))} onToggle={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : f.group_file_id))}
onClose={() => setOpenMenuId(null)} // Functional update, not a plain `setOpenMenuId(null)` -- this fires from
// useClickOutside's window-level listener, which runs AFTER another row's own
// onToggle already ran in the same click (React's synthetic onToggle always
// precedes a window listener in the bubble order). An unconditional null would
// clobber that other row's freshly-opened state; only clear if this row is
// still the one actually open.
onClose={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : cur))}
onDownload={() => downloadFile(f)} onDownload={() => downloadFile(f)}
onOpen={() => openFileInNewTab(f)} onOpen={() => openFileInNewTab(f)}
onDelete={async () => { onDelete={async () => {
+16 -3
View File
@@ -72,6 +72,11 @@ export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initia
const [inviting, setInviting] = useState(initialView === 'invite'); const [inviting, setInviting] = useState(initialView === 'invite');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [outcomeByUser, setOutcomeByUser] = useState<Record<string, InviteOutcome>>({}); const [outcomeByUser, setOutcomeByUser] = useState<Record<string, InviteOutcome>>({});
// Per-row "移出中" tracking -- `removeMutation` is one shared mutation instance, so its own
// `.variables`/`.isPending` only ever reflects the MOST RECENT call. Removing member A then quickly
// also removing member B would make A's button-disabled/"移出中…" state resolve back to normal the
// moment B's click fires, even though A's own request may still be in flight.
const [removingIds, setRemovingIds] = useState<Set<string>>(new Set());
useEscapeKey(onClose); useEscapeKey(onClose);
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) }); const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
@@ -201,16 +206,24 @@ export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initia
{canRemoveMember(myUserId, myRole, m) ? ( {canRemoveMember(myUserId, myRole, m) ? (
<button <button
type="button" type="button"
disabled={removeMutation.isPending && removeMutation.variables === m.user_id} disabled={removingIds.has(m.user_id)}
onClick={async () => { onClick={async () => {
const label = m.display_name || m.user_id; const label = m.display_name || m.user_id;
if (await requestConfirm({ message: `确定将"${label}"移出群聊吗?`, danger: true })) { if (await requestConfirm({ message: `确定将"${label}"移出群聊吗?`, danger: true })) {
removeMutation.mutate(m.user_id); setRemovingIds((prev) => new Set(prev).add(m.user_id));
removeMutation.mutate(m.user_id, {
onSettled: () =>
setRemovingIds((prev) => {
const next = new Set(prev);
next.delete(m.user_id);
return next;
}),
});
} }
}} }}
className="flex flex-none cursor-pointer items-center gap-0.5 text-xs text-danger-text hover:underline disabled:pointer-events-none disabled:cursor-default disabled:opacity-60 disabled:hover:no-underline" className="flex flex-none cursor-pointer items-center gap-0.5 text-xs text-danger-text hover:underline disabled:pointer-events-none disabled:cursor-default disabled:opacity-60 disabled:hover:no-underline"
> >
{removeMutation.isPending && removeMutation.variables === m.user_id ? ( {removingIds.has(m.user_id) ? (
'移出中…' '移出中…'
) : ( ) : (
<> <>
@@ -222,8 +222,9 @@ function formatDuration(ms: number | undefined): string {
* confirmed by its base64 signature) instead. Tapping swaps the poster for a real `<video>` playing * confirmed by its base64 signature) instead. Tapping swaps the poster for a real `<video>` playing
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality. * from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality.
* *
* `locked`: see ImageAttachment's own comment -- reuses the existing `disabled` (already there for * `locked`: see ImageAttachment's own comment -- gated in `onClick`, not folded into `disabled`,
* a not-yet-loaded video) rather than a second, parallel gating mechanism. */ * since a disabled <button> never dispatches `click` at all and would swallow the row-toggle this
* click is supposed to bubble up to (unlike FileAttachment's plain anchor, this is a real button). */
export function VideoAttachment({ data, locked }: { data: VideoData; locked?: boolean }) { export function VideoAttachment({ data, locked }: { data: VideoData; locked?: boolean }) {
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null; const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
@@ -245,8 +246,11 @@ export function VideoAttachment({ data, locked }: { data: VideoData; locked?: bo
return ( return (
<button <button
type="button" type="button"
onClick={() => setPlaying(true)} onClick={() => {
disabled={!videoSrc || locked} if (locked) return;
setPlaying(true);
}}
disabled={!videoSrc}
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail" className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
> >
{posterUrl ? ( {posterUrl ? (
@@ -271,9 +275,10 @@ interface AudioData {
duration?: number; duration?: number;
} }
/** `locked`: see ImageAttachment's own comment -- reuses the existing `disabled` (already there for a /** `locked`: see VideoAttachment's own comment -- gated in `toggle()`, not folded into `disabled`,
* not-yet-loaded voice message) rather than a second, parallel gating mechanism. Ongoing playback * so the click still bubbles to the row toggle instead of being swallowed by the native button.
* started BEFORE selectMode was entered is left alone -- this only blocks a NEW toggle action. */ * Ongoing playback started BEFORE selectMode was entered is left alone -- this only blocks a NEW
* toggle action. */
export function AudioAttachment({ data, locked }: { data: AudioData; locked?: boolean }) { export function AudioAttachment({ data, locked }: { data: AudioData; locked?: boolean }) {
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null; const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data.ref); const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
@@ -295,6 +300,7 @@ export function AudioAttachment({ data, locked }: { data: AudioData; locked?: bo
}, [audioEl]); }, [audioEl]);
const toggle = () => { const toggle = () => {
if (locked) return;
if (!audioEl || !src) return; if (!audioEl || !src) return;
if (playing) { if (playing) {
audioEl.pause(); audioEl.pause();
@@ -310,7 +316,7 @@ export function AudioAttachment({ data, locked }: { data: AudioData; locked?: bo
}; };
return ( return (
<button type="button" onClick={toggle} disabled={!src || locked} className="flex w-[200px] items-center gap-2"> <button type="button" onClick={toggle} disabled={!src} className="flex w-[200px] items-center gap-2">
<Icon <Icon
name={playError ? 'error_outline' : playing ? 'pause_circle' : 'play_circle'} name={playError ? 'error_outline' : playing ? 'pause_circle' : 'play_circle'}
className={`text-[24px] ${playError ? 'text-danger-text' : 'text-accent'}`} className={`text-[24px] ${playError ? 'text-danger-text' : 'text-accent'}`}
+14 -5
View File
@@ -23,7 +23,7 @@ import { getConversationContext, getUserProfile } from '@/lib/api/friends';
import { getMe } from '@/lib/api/profile'; import { getMe } from '@/lib/api/profile';
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config'; import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
import { useClickOutside } from '@/lib/hooks'; import { useClickOutside } from '@/lib/hooks';
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions'; import { availableActions, isConfirmedSeq, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
import { flatTextContentOf, isRecalled } from '@/lib/messages/dispatch'; import { flatTextContentOf, isRecalled } from '@/lib/messages/dispatch';
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions'; import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
import { buildReplyInfo, replySnapshotText } from '@/lib/messages/reply'; import { buildReplyInfo, replySnapshotText } from '@/lib/messages/reply';
@@ -821,10 +821,12 @@ export function ConversationView() {
// A message can be recalled by its sender AFTER it was already selected (the checkbox itself // A message can be recalled by its sender AFTER it was already selected (the checkbox itself
// refuses a NEW selection of a recalled message, but can't retroactively un-select one recalled // refuses a NEW selection of a recalled message, but can't retroactively un-select one recalled
// mid-session) -- forwarding null content would silently send an empty bubble with a misleading // mid-session) -- forwarding null content would silently send an empty bubble with a misleading
// "forwarded from" tag rather than the message the user actually picked. // "forwarded from" tag rather than the message the user actually picked. Checkbox selection also
// doesn't refuse a still-sending/failed message (its placeholder seq is a plain `number`, same as
// a real one) -- that message has no server-side (topic, seq) yet for forwardMessages() to find.
const sources = orderedSeqs const sources = orderedSeqs
.map((seq) => topic.findMessage(seq)) .map((seq) => topic.findMessage(seq))
.filter((msg): msg is StoredMessage => !!msg && !isRecalled(msg)) .filter((msg): msg is StoredMessage => !!msg && !isRecalled(msg) && isConfirmedSeq(msg))
.map((msg) => ({ topic: topicId, seq: msg.seq as number })); .map((msg) => ({ topic: topicId, seq: msg.seq as number }));
// One REST call now covers every (source, target) pair -- forwardMessages()'s own comment // One REST call now covers every (source, target) pair -- forwardMessages()'s own comment
// explains why this replaced per-target `ensureTopicAttached()` + per-message client-side // explains why this replaced per-target `ensureTopicAttached()` + per-message client-side
@@ -848,6 +850,11 @@ export function ConversationView() {
? forwardMessages(sources, targetTopics) ? forwardMessages(sources, targetTopics)
.then((res) => { .then((res) => {
if (res.failed.length > 0) showError(undefined, `${res.failed.length} 条消息转发失败`); if (res.failed.length > 0) showError(undefined, `${res.failed.length} 条消息转发失败`);
// 'sent'/'pending' both mean the server has it; 'uncertain' (e.g. a timeout mid-write)
// does not -- silently lumping it in with success would leave the user with no signal
// that this particular copy may never actually land in the target conversation.
const uncertain = res.items.filter((item) => item.status === 'uncertain').length;
if (uncertain > 0) notify(`${uncertain} 条转发结果暂不确定,请稍后在目标会话中确认`);
}) })
.catch((err) => showError(err, '转发失败')) .catch((err) => showError(err, '转发失败'))
: Promise.resolve(); : Promise.resolve();
@@ -1247,10 +1254,12 @@ export function ConversationView() {
// "pending" persistence, landing the comment BEFORE the forwarded content it's meant to // "pending" persistence, landing the comment BEFORE the forwarded content it's meant to
// follow). // follow).
const forwardDone = const forwardDone =
typeof forwardMsg.seq === 'number' isConfirmedSeq(forwardMsg)
? forwardMessages([{ topic: topicId, seq: forwardMsg.seq }], targetTopics) ? forwardMessages([{ topic: topicId, seq: forwardMsg.seq as number }], targetTopics)
.then((res) => { .then((res) => {
if (res.failed.length > 0) showError(undefined, `${res.failed.length} 个目标转发失败`); if (res.failed.length > 0) showError(undefined, `${res.failed.length} 个目标转发失败`);
const uncertain = res.items.filter((item) => item.status === 'uncertain').length;
if (uncertain > 0) notify(`${uncertain} 个目标转发结果暂不确定,请稍后在目标会话中确认`);
}) })
.catch((err) => showError(err, '转发失败')) .catch((err) => showError(err, '转发失败'))
: Promise.resolve(); : Promise.resolve();
+14 -4
View File
@@ -31,15 +31,25 @@ import { openUserCard } from '@/stores/userCard';
/** Distinct from a regular Drafty-rendered bubble -- see `contactCardContentOf`'s own doc comment /** Distinct from a regular Drafty-rendered bubble -- see `contactCardContentOf`'s own doc comment
* for why this is dispatched by `head['x-im-type']` rather than a Drafty style tag. Tapping opens * for why this is dispatched by `head['x-im-type']` rather than a Drafty style tag. Tapping opens
* the real profile page, the one thing a contact-card message is fundamentally for. */ * the real profile page, the one thing a contact-card message is fundamentally for. */
function ContactCardBubble({ content }: { content: ContactCardContent }) { function ContactCardBubble({ content, locked }: { content: ContactCardContent; locked?: boolean }) {
const name = content.display_name || content.public_account || content.user_id; const name = content.display_name || content.public_account || content.user_id;
return ( const inner = (
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline"> <>
<Avatar id={content.user_id} name={name} avatarFileId={content.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base" /> <Avatar id={content.user_id} name={name} avatarFileId={content.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base" />
<span className="flex min-w-0 flex-col"> <span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{name}</span> <span className="truncate text-sm font-medium text-text-body">{name}</span>
{content.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {content.public_account}</span> : null} {content.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {content.public_account}</span> : null}
</span> </span>
</>
);
// `locked` (selectMode): same reasoning as the avatar/@mention buttons elsewhere in this file --
// a tap must only toggle the row, not navigate away and lose whatever's already been picked.
if (locked) {
return <div className="flex w-[220px] items-center gap-3">{inner}</div>;
}
return (
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
{inner}
</Link> </Link>
); );
} }
@@ -236,7 +246,7 @@ export function MessageRow({
{callResult.text} {callResult.text}
</span> </span>
) : contactCard ? ( ) : contactCard ? (
<ContactCardBubble content={contactCard} /> <ContactCardBubble content={contactCard} locked={selectMode} />
) : mobileAttachment ? ( ) : mobileAttachment ? (
mobileAttachment.kind === 'image' ? ( mobileAttachment.kind === 'image' ? (
<ImageAttachment data={mobileAttachment} locked={selectMode} /> <ImageAttachment data={mobileAttachment} locked={selectMode} />
@@ -6,6 +6,7 @@ import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon'; import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast'; import { Toast } from '@/app/Toast';
import { QRCodeModal } from '@/features/contacts/QRCodeModal'; import { QRCodeModal } from '@/features/contacts/QRCodeModal';
import { ImApiError } from '@/lib/api/client';
import { getMyProfile, updateMyProfile } from '@/lib/api/profile'; import { getMyProfile, updateMyProfile } from '@/lib/api/profile';
import { uploadFile } from '@/lib/api/upload'; import { uploadFile } from '@/lib/api/upload';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
@@ -87,9 +88,20 @@ export function ProfileSettingsPage() {
// IM-前后端对接文档.md 10.3 (版本冲突先拉最新资料再由用户确认): a stale `version` (e.g. edited // IM-前后端对接文档.md 10.3 (版本冲突先拉最新资料再由用户确认): a stale `version` (e.g. edited
// on another tab/device) 400s here, but the cached `query.data.version` used to just sit there // on another tab/device) 400s here, but the cached `query.data.version` used to just sit there
// unchanged -- every retry would resend the exact same stale version and 400 again forever, the // unchanged -- every retry would resend the exact same stale version and 400 again forever, the
// toast's own "请刷新后重试" copy notwithstanding, since nothing actually refetched. // toast's own "请刷新后重试" copy notwithstanding, since nothing actually refetched. Worse: even
// once `query.data` DOES refetch fresh, `draft` (what's actually on screen and what a follow-up
// save would resubmit) was never resynced -- a second "保存" click would then silently succeed
// and overwrite whichever OTHER fields changed elsewhere with this tab's own stale values.
onError: (err) => { onError: (err) => {
setError(err instanceof Error ? err.message : '保存失败'); if (err instanceof ImApiError && err.reason === 'VERSION_CONFLICT') {
setError('资料已在其他设备更新,你的修改未保存,请重新编辑');
// Resets to null (not the fresh data directly, which isn't available here yet) so the
// effect above repopulates `draft` once the invalidated query below refetches -- same path
// as the initial load, guaranteeing `draft` never observably lags `query.data`'s version.
setDraft(null);
} else {
setError(err instanceof Error ? err.message : '保存失败');
}
queryClient.invalidateQueries({ queryKey: ['my-profile'] }); queryClient.invalidateQueries({ queryKey: ['my-profile'] });
}, },
}); });
+13 -2
View File
@@ -18,13 +18,21 @@ export type MessageAction = 'copy' | 'quote' | 'favorite' | 'forward' | 'recall'
*/ */
export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): number | null { export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): number | null {
const mine = !!myUserId && msg.from === myUserId; const mine = !!myUserId && msg.from === myUserId;
const isConfirmed = typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
const recallWindow = messagePolicy?.recall_window_seconds ?? 0; const recallWindow = messagePolicy?.recall_window_seconds ?? 0;
if (!mine || !isConfirmed || recallWindow <= 0 || !msg.ts) return null; if (!mine || !isConfirmedSeq(msg) || recallWindow <= 0 || !msg.ts) return null;
const remaining = recallWindow - Math.floor((Date.now() - msg.ts.getTime()) / 1000); const remaining = recallWindow - Math.floor((Date.now() - msg.ts.getTime()) / 1000);
return remaining > 0 ? remaining : null; return remaining > 0 ? remaining : null;
} }
/** True once the server has actually confirmed this message's `seq` -- false for a still-sending or
* failed-to-send message (the SDK's local placeholder seq is a `number`, so a plain `typeof` check
* isn't enough). Shared by recall (above) and forward (below): both need the server to already know
* about the message -- there's nothing yet at (topic, seq) for a recall or a forwardMessages() call
* to locate. */
export function isConfirmedSeq(msg: StoredMessage): boolean {
return typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
}
/** /**
* Which context-menu actions apply to one message. Scoped to P2P for now (no group-admin recall, * Which context-menu actions apply to one message. Scoped to P2P for now (no group-admin recall,
* no self-topic hard-delete-always -- those need group role/topic-type knowledge Phase 3 will add). * no self-topic hard-delete-always -- those need group role/topic-type knowledge Phase 3 will add).
@@ -62,6 +70,9 @@ export function availableActions(msg: StoredMessage, myUserId: string | null, me
// system notices, positive confirmed seq (buildReplyInfo returns null otherwise, and the server // system notices, positive confirmed seq (buildReplyInfo returns null otherwise, and the server
// would reject a malformed x-im-reply head outright). // would reject a malformed x-im-reply head outright).
if (!canQuote(msg)) actions = actions.filter((action) => action !== 'quote'); if (!canQuote(msg)) actions = actions.filter((action) => action !== 'quote');
// A still-sending/failed message has no server-side (topic, seq) yet for forwardMessages() to
// locate -- same reasoning as recall's isConfirmedSeq gate above.
if (!isConfirmedSeq(msg)) actions = actions.filter((action) => action !== 'forward');
// Defer to the server's own supported_actions list when it's present, so a server-side policy // Defer to the server's own supported_actions list when it's present, so a server-side policy
// change (recall disabled tenant-wide, say) doesn't leave the client offering an action that // change (recall disabled tenant-wide, say) doesn't leave the client offering an action that
// will just 400 when tapped -- the time-window check below still applies on top of this. // will just 400 when tapped -- the time-window check below still applies on top of this.
+10
View File
@@ -110,6 +110,16 @@ function makeFormatNode(highlightQuery: string | undefined, interactive: boolean
); );
case 'LN': { case 'LN': {
const url = typeof data?.url === 'string' ? data.url : undefined; const url = typeof data?.url === 'string' ? data.url : undefined;
// Same interactive gating as 'MN' above -- a tap during bulk-select must only toggle the
// row, not open a link (SafeLink's <button> doesn't stopPropagation, so an active link would
// both open a tab AND toggle the row's checkbox).
if (!interactive) {
return (
<span key={key} className="text-accent underline">
{children}
</span>
);
}
return ( return (
<SafeLink key={key} href={url}> <SafeLink key={key} href={url}>
{children} {children}