修复codereview复核确认的多选/转发/收藏页/资料页等前端bug
- 多选模式下Drafty链接、名片消息不再意外触发跳转/开新标签页 - 语音/视频消息在多选模式下点击可正常冒泡选中该行 - 转发排除未确认/发送失败消息,识别uncertain转发结果并单独提示 - 群文件"更多操作"菜单竞态、群成员移出状态竞态 - 收藏页链接卡片new URL()崩溃点加防御,并给登录后应用区域加错误边界 - 个人资料页乐观锁版本冲突时清空草稿避免静默覆盖并发修改 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e3eed8bdd4
commit
9abae5030b
+27
-1
@@ -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 /> },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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'}`}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
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 : '保存失败');
|
setError(err instanceof Error ? err.message : '保存失败');
|
||||||
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['my-profile'] });
|
queryClient.invalidateQueries({ queryKey: ['my-profile'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user