修复禅道Bug清单中的前端问题:好友申请source校验、群解散二次验证缺失、

群解散事件未处理、错误码翻译缺失、恢复登录页误导性提示

- 好友申请首次必失败30000(P0):AddFriendModal把手机号搜索单独标记为
  source:"phone"上报,但后端serveIMFriendRequestCreate的source校验只认
  search/qr/group,命中default分支返回"invalid friend request source"。
  改为手机号搜索也统一上报source:"search"(后端本就不区分具体搜索渠道),
  用两个全新、此前无任何关系的测试账号验证首次申请200成功
- 解散群聊需要短信二次验证但完整流程从未实现(P0):新增
  SecurityChallengeModal通用二次验证弹层(POST /security/challenges→
  验证码→verify→action_token)+ requestActionToken()(stores/
  securityChallenge.ts,与requestConfirm同一模式),接入解散群聊和转让
  群主(后者此前也因缺X-Action-Token必然404/400)。同时发现并修复
  imDelete对无body请求不显式传空对象体,导致携带Content-Type:
  application/json但请求体真正为空时后端JSON解码失败返回400——实测确认
  两个问题都解决后DELETE返回200
- 群聊被解散但当前正查看该群的用户界面不会跳转(P1):新增
  topics.ts的subscribeTopicGone()监听SDK自身的onDeleteTopic回调,
  ConversationView据此在当前会话被解散时弹出提示并跳转回消息列表
- P2P会话发起方标题栏显示原始topic id、对方明明在线却显示离线:确认
  为上一轮已修复的isCommType()根因问题的同一症状,本轮实测(新建从未
  聊过天的账号对)已不再复现;群内消息发送者显示原始uid的关联描述同样
  实测未复现
- 好友关系冲突(已是好友/申请正在处理中)报错英文原文透出:翻译表补充
  FRIEND_REQUEST_CONFLICT
- 深链/刷新页面时的"改为重新登录"链接此前在任何恢复登录过程中都会
  立即显示(不管是否超时),造成短暂白屏时的误导性提示:改为仅在真正
  超时后才显示该链接,与其上方的提示文案保持一致的显示条件
This commit is contained in:
Your Name
2026-09-16 15:42:41 +08:00
parent ff69e4548c
commit 8013c1675f
14 changed files with 313 additions and 30 deletions
+2
View File
@@ -6,6 +6,7 @@ import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
import { GlobalToastRoot } from '@/app/GlobalToastRoot'; import { GlobalToastRoot } from '@/app/GlobalToastRoot';
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 { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage'; import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
import { LoginPage } from '@/features/auth/LoginPage'; import { LoginPage } from '@/features/auth/LoginPage';
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage'; import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage';
@@ -106,6 +107,7 @@ export default function App() {
<ConfirmDialogRoot /> <ConfirmDialogRoot />
<GlobalToastRoot /> <GlobalToastRoot />
<KickedNoticeRoot /> <KickedNoticeRoot />
<SecurityChallengeModal />
</QueryClientProvider> </QueryClientProvider>
); );
} }
+11 -3
View File
@@ -106,9 +106,17 @@ export function RequireAuth() {
return ( return (
<div className="flex h-dvh flex-col items-center justify-center gap-3 bg-surface-page text-sm text-text-tertiary-3"> <div className="flex h-dvh flex-col items-center justify-center gap-3 bg-surface-page text-sm text-text-tertiary-3">
{restoreTimedOut ? '连接时间较长,你可以继续等待,或改为重新登录' : '正在恢复登录状态…'} {restoreTimedOut ? '连接时间较长,你可以继续等待,或改为重新登录' : '正在恢复登录状态…'}
<a href="/login" className="text-accent no-underline"> {/* Only past the same timeout the message text itself waits for -- a normal restore usually
改为重新登录 resolves in well under a second (real WS round trip), and this link showing unconditionally
</a> from the very first frame made every ordinary reload/deep-link flash a "重新登录" escape
hatch that had nothing to escape from (a real, reported false-alarm: "短暂白屏并误显改为
重新登录"). The escape hatch is only actually useful once restoreTimedOut has already told
the user this is taking longer than normal. */}
{restoreTimedOut ? (
<a href="/login" className="text-accent no-underline">
改为重新登录
</a>
) : null}
</div> </div>
); );
} }
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useRef, useState } from 'react';
import { useEscapeKey } from '@/lib/hooks';
import { ImApiError } from '@/lib/api/client';
import { requestSecurityChallenge, verifySecurityChallenge } from '@/lib/api/security';
import { useSecurityChallengeStore } from '@/stores/securityChallenge';
/** Mounted once at the app root (App.tsx), same placement/pattern as ConfirmDialogRoot -- renders
* whatever `requestActionToken()` (stores/securityChallenge.ts) most recently asked for: the
* protocol's sensitive-action SMS re-verification (docs/IM-前后端对接文档.md:762-766), needed before
* a group can actually be dissolved (or its ownership transferred) -- both require `X-Action-Token`,
* and there was previously no UI anywhere in this app that could produce one. */
export function SecurityChallengeModal() {
const pending = useSecurityChallengeStore((s) => s.pending);
const respond = useSecurityChallengeStore((s) => s.respond);
const [challengeId, setChallengeId] = useState('');
const [phoneMask, setPhoneMask] = useState('');
const [code, setCode] = useState('');
const [seconds, setSeconds] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const timerRef = useRef<number | undefined>(undefined);
useEscapeKey(() => respond(null));
const tick = (initial: number) => {
window.clearInterval(timerRef.current);
setSeconds(initial);
timerRef.current = window.setInterval(() => {
setSeconds((s) => {
if (s <= 1) {
window.clearInterval(timerRef.current);
return 0;
}
return s - 1;
});
}, 1000);
};
// Fires the first SMS the moment the modal opens -- the doc's own flow has no separate "get code"
// click for this endpoint (unlike VerificationCodeButton's register/reset-password scenes), since
// the phone number isn't something the user enters here at all (read server-side from the login
// identity), so there's nothing to wait on the user to fill in first.
useEffect(() => {
window.clearInterval(timerRef.current);
setChallengeId('');
setCode('');
setError('');
setSeconds(0);
if (!pending) return undefined;
let cancelled = false;
requestSecurityChallenge(pending.scene)
.then((data) => {
if (cancelled) return;
setChallengeId(data.challenge_id);
setPhoneMask(data.phone_mask);
tick(data.resend_after_seconds || 60);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试');
});
return () => {
cancelled = true;
window.clearInterval(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending?.scene]);
if (!pending) return null;
const resend = () => {
if (busy || seconds > 0) return;
setError('');
setBusy(true);
requestSecurityChallenge(pending.scene)
.then((data) => {
setChallengeId(data.challenge_id);
setPhoneMask(data.phone_mask);
tick(data.resend_after_seconds || 60);
})
.catch((err) => setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试'))
.finally(() => setBusy(false));
};
const submit = async () => {
if (!challengeId || !/^\d{6}$/.test(code)) return setError('请输入 6 位验证码');
setBusy(true);
setError('');
try {
const verified = await verifySecurityChallenge(challengeId, code);
respond(verified.action_token);
} catch (err) {
setError(err instanceof ImApiError ? err.message : '验证码错误或已过期');
setBusy(false);
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={() => respond(null)}>
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal" onClick={(e) => e.stopPropagation()}>
<strong className="mb-2 block text-base font-semibold text-text-heading">安全验证</strong>
<p className="m-0 text-sm leading-relaxed text-text-body">
即将{pending.actionDescription},请验证手机号{phoneMask ? ` ${phoneMask}` : ''} 收到的短信验证码
</p>
<div className="mt-4 flex items-center gap-2">
<input
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="请输入 6 位验证码"
inputMode="numeric"
autoFocus
className="h-9 min-w-0 flex-1 rounded-lg border border-border-panel px-3 text-sm outline-none focus:border-accent"
/>
<button
type="button"
onClick={resend}
disabled={busy || seconds > 0 || !challengeId}
className="h-9 w-28 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
>
{seconds > 0 ? `重新获取(${seconds}s)` : '重新获取'}
</button>
</div>
{error ? <p className="m-0 mt-2 text-xs text-danger-text">{error}</p> : null}
<div className="mt-5 flex justify-end gap-3">
<button
type="button"
onClick={() => respond(null)}
className="grid h-9 place-items-center rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary"
>
取消
</button>
<button
type="button"
onClick={submit}
disabled={busy || !challengeId || code.length !== 6}
className="grid h-9 place-items-center rounded-lg bg-btn px-4 text-sm font-medium text-white disabled:opacity-50"
>
{busy ? '验证中…' : '确定'}
</button>
</div>
</div>
</div>
);
}
+14 -7
View File
@@ -47,7 +47,7 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
// Tracks HOW `selected` was populated so the eventual sendFriendRequest can report the real // Tracks HOW `selected` was populated so the eventual sendFriendRequest can report the real
// provenance instead of always claiming "search" -- NewFriendsView's own SOURCE_LABEL map has a // provenance instead of always claiming "search" -- NewFriendsView's own SOURCE_LABEL map has a
// dedicated "扫码添加" entry for the qr case that was otherwise dead/unreachable. // dedicated "扫码添加" entry for the qr case that was otherwise dead/unreachable.
const [selectedSource, setSelectedSource] = useState<'search' | 'phone' | 'qr'>('search'); const [selectedSource, setSelectedSource] = useState<'search' | 'qr'>('search');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
@@ -227,12 +227,19 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
type="button" type="button"
disabled={!selectable} disabled={!selectable}
onClick={() => { onClick={() => {
// `searchType` (already computed above from the query itself) is the real // Real, live-confirmed backend validation (im_http.go's
// signal for which channel actually found this person -- NewFriendsView's // serveIMFriendRequestCreate): the `source` field's switch only recognizes
// own SOURCE_LABEL table already has a `phone` entry ("通过手机号搜索") // `search`/`qr`/`group` -- there is no `phone` case, so it falls to
// that this path never reached because every search result was tagged // `default` and 400s with "invalid friend request source". A prior pass
// 'search' regardless of how it was found. // here tried to report phone-originated searches as their own `phone`
setSelectedSource(searchType === 'phone' ? 'phone' : 'search'); // source (for NewFriendsView's SOURCE_LABEL table to show "通过手机号搜索"),
// which broke every phone-search-based friend request outright -- reproduced
// live with two brand-new accounts before this fix (first-ever request,
// never a retry/idempotency issue as it first looked). Phone and account
// search both go through the same `search` channel from the backend's own
// point of view; there is no server-side data to recover that distinction
// from, so `SOURCE_LABEL`'s dead `phone` entry is removed alongside this.
setSelectedSource('search');
setSelected(item); setSelected(item);
}} }}
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left disabled:opacity-70" className="flex items-center gap-3 rounded-lg px-2 py-2 text-left disabled:opacity-70"
+6 -1
View File
@@ -11,7 +11,12 @@ import { acceptFriendRequest, getFriendRequests, rejectFriendRequest, type Frien
import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time'; import { formatEventTimestamp } from '@/lib/messages/time';
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加', phone: '通过手机号搜索' }; // `phone` deliberately excluded -- the backend's own `source` validation (im_http.go's
// serveIMFriendRequestCreate) only accepts `search`/`qr`/`group`; a phone-number search reports the
// same `search` value an account-name search does, since the server has no separate channel for it
// (see AddFriendModal.tsx's own selection handler for the live-confirmed 400 this caused when it
// tried to report `phone` as its own source instead).
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加' };
/** README §5.3: both pending (拒绝/通过验证 buttons) and already-handled (灰字"已通过/已拒绝") /** README §5.3: both pending (拒绝/通过验证 buttons) and already-handled (灰字"已通过/已拒绝")
* inbound requests show together in one list -- `getFriendRequests('inbound', 'all')` matches * inbound requests show together in one list -- `getFriendRequests('inbound', 'all')` matches
+7 -4
View File
@@ -24,6 +24,7 @@ import {
import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks'; import { useEscapeKey } from '@/lib/hooks';
import { requestConfirm } from '@/stores/confirmDialog'; import { requestConfirm } from '@/stores/confirmDialog';
import { requestActionToken } from '@/stores/securityChallenge';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
interface GroupAdminModalProps { interface GroupAdminModalProps {
@@ -469,7 +470,8 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [error, setError] = useState(''); const [error, setError] = useState('');
const transferMutation = useMutation({ const transferMutation = useMutation({
mutationFn: (userId: string) => transferGroupOwner(topicId, userId, groupVersion ?? 1), mutationFn: ({ userId, actionToken }: { userId: string; actionToken: string }) =>
transferGroupOwner(topicId, userId, groupVersion ?? 1, actionToken),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group', topicId] }); queryClient.invalidateQueries({ queryKey: ['group', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-settings', topicId] }); queryClient.invalidateQueries({ queryKey: ['group-settings', topicId] });
@@ -490,9 +492,10 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str
cacheKey="eligible-owner" cacheKey="eligible-owner"
opts={{ eligibleRole: 'owner' }} opts={{ eligibleRole: 'owner' }}
onPick={async (m) => { onPick={async (m) => {
if (await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true })) { if (!(await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true }))) return;
transferMutation.mutate(m.user_id); const actionToken = await requestActionToken({ scene: 'group_owner_transfer', actionDescription: '转让群主' });
} if (!actionToken) return;
transferMutation.mutate({ userId: m.user_id, actionToken });
}} }}
emptyLabel="没有可转让的成员" emptyLabel="没有可转让的成员"
disabled={transferMutation.isPending} disabled={transferMutation.isPending}
@@ -28,6 +28,7 @@ import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useTinode } from '@/lib/tinode/hooks'; import { useTinode } from '@/lib/tinode/hooks';
import { requestConfirm } from '@/stores/confirmDialog'; import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast'; import { notify } from '@/stores/globalToast';
import { requestActionToken } from '@/stores/securityChallenge';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
interface GroupDetailColumnProps { interface GroupDetailColumnProps {
@@ -157,7 +158,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
}); });
const disbandMutation = useMutation({ const disbandMutation = useMutation({
mutationFn: () => disbandGroup(topicId), mutationFn: (actionToken: string) => disbandGroup(topicId, actionToken),
onSuccess: () => navigate('/messages'), onSuccess: () => navigate('/messages'),
onError: (err) => showError(err, '解散群聊失败'), onError: (err) => showError(err, '解散群聊失败'),
}); });
@@ -178,7 +179,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
const startLeaveOrDisband = async () => { const startLeaveOrDisband = async () => {
if (isOwner) { if (isOwner) {
if (!(await requestConfirm({ message: '解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?', danger: true }))) return; if (!(await requestConfirm({ message: '解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?', danger: true }))) return;
disbandMutation.mutate(); const actionToken = await requestActionToken({
scene: 'group_dissolve',
actionDescription: `解散群聊「${groupQuery.data?.name || peerName}」`,
});
if (!actionToken) return;
disbandMutation.mutate(actionToken);
} else { } else {
if (!(await requestConfirm({ message: '确定退出本群聊吗?', danger: true }))) return; if (!(await requestConfirm({ message: '确定退出本群聊吗?', danger: true }))) return;
leaveMutation.mutate(); leaveMutation.mutate();
+16 -2
View File
@@ -29,10 +29,10 @@ import { buildMentionedContent, detectMentionTrigger, type PendingMention } from
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments'; import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks'; import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send'; import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
import { closeConversation, ensureTopicAttached, openConversation } from '@/lib/tinode/topics'; import { closeConversation, ensureTopicAttached, openConversation, subscribeTopicGone } from '@/lib/tinode/topics';
import { useCallStore } from '@/stores/calls'; import { useCallStore } from '@/stores/calls';
import { requestConfirm } from '@/stores/confirmDialog'; import { requestConfirm } from '@/stores/confirmDialog';
import { notImplemented } from '@/stores/globalToast'; import { notify, notImplemented } from '@/stores/globalToast';
import { useMessageJumpStore } from '@/stores/messageJump'; import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
@@ -165,6 +165,20 @@ export function ConversationView() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [topicId]); }, [topicId]);
// Someone else dissolving the group currently open here (or this account being removed from it)
// fires the SDK's `onDeleteTopic`, not `onData`/`onPres` -- those only invalidate this screen's
// cached snapshots for re-render, they don't tell it the topic itself is gone. Left unhandled, the
// member who happened to be looking at that conversation just keeps staring at a now-defunct screen
// (an actual reported bug: staying on the dissolved group's view with no way back except a manual
// nav). `notify()` (the global toast, not this screen's own `errorToast`) survives the navigate.
useEffect(() => {
return subscribeTopicGone((goneTopicName) => {
if (goneTopicName !== topicId) return;
notify('该群聊已解散');
navigate('/messages', { replace: true });
});
}, [topicId, navigate]);
// useLayoutEffect, not useEffect -- this component never remounts on topicId change (route is // 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 // `: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 // PREVIOUS conversation's select-mode/menu/etc. still showing over the new conversation's message
+3
View File
@@ -87,6 +87,9 @@ const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | n
QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描', QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描',
QR_CODE_INVALID: '二维码无效,请重新扫描', QR_CODE_INVALID: '二维码无效,请重新扫描',
FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友', FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友',
// `relation.Relationship != RelationshipNone` (im_http.go's serveIMFriendRequestCreate) -- already
// friends, or a request between the two of you (either direction) is already pending.
FRIEND_REQUEST_CONFLICT: '你们已经是好友,或好友申请正在处理中',
USER_NOT_DISCOVERABLE: '未找到该用户', USER_NOT_DISCOVERABLE: '未找到该用户',
REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态', REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态',
SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试', SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试',
+19 -11
View File
@@ -130,9 +130,17 @@ export function leaveGroup(topic: string) {
return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {}); return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {});
} }
/** doc-flagged 待实现 (not yet implemented server-side) -- kept typed and ready for when it lands. */ /** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.17) -- get one via
export function disbandGroup(topic: string) { * `requestActionToken({scene:'group_dissolve', ...})` (stores/securityChallenge.ts) first. Live,
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`); * fully implemented server-side (a stale comment here previously claimed otherwise) -- confirmed by
* actually calling it. An explicit empty-object body is required: a bodiless DELETE still carries
* this client's own `Content-Type: application/json` header, and the server's JSON decoder treats
* a truly empty body under that header as malformed rather than "no optional fields provided". */
export function disbandGroup(topic: string, actionToken: string) {
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`, {
body: {},
headers: { 'X-Action-Token': actionToken },
});
} }
export type GroupRole = 'owner' | 'admin' | 'member'; export type GroupRole = 'owner' | 'admin' | 'member';
@@ -264,14 +272,14 @@ export function unmuteGroupMember(topic: string, userId: string) {
return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`); return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`);
} }
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- not yet /** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- get one via
* wired since no risk-challenge flow is built anywhere in this app yet; calling this will 409 until * `requestActionToken({scene:'group_owner_transfer', ...})` (stores/securityChallenge.ts) first. */
* that's added. Kept typed so the group-management UI can be built ahead of that. */ export function transferGroupOwner(topic: string, targetUserId: string, version: number, actionToken: string) {
export function transferGroupOwner(topic: string, targetUserId: string, version: number) { return imPost<{ owner: string; previous_owner: string; version: number }>(
return imPost<{ owner: string; previous_owner: string; version: number }>(`/groups/${encodeURIComponent(topic)}/owner-transfer`, { `/groups/${encodeURIComponent(topic)}/owner-transfer`,
target_user_id: targetUserId, { target_user_id: targetUserId, version },
version, { headers: { 'X-Action-Token': actionToken } },
}); );
} }
export function getGroupGovernance(topic: string) { export function getGroupGovernance(topic: string) {
+24
View File
@@ -15,3 +15,27 @@ export interface UrlSafetyResult {
export function checkUrlSafety(url: string, source: string) { export function checkUrlSafety(url: string, source: string) {
return imPost<UrlSafetyResult>('/security/urls/check', { url, source }); return imPost<UrlSafetyResult>('/security/urls/check', { url, source });
} }
/** Sensitive-action re-verification (docs/IM-前后端对接文档.md:762-766, §6.17's group-dissolve line).
* Confirmed by reading im_be's own `validIMSecurityChallengeScene` (server/im_http.go) -- the doc
* text only spells out the two youth-mode scenes by name, but the server also accepts
* `group_dissolve`/`group_owner_transfer`, which is what actually lets this flow work for either. */
export type SecurityChallengeScene = 'group_dissolve' | 'group_owner_transfer';
export interface SecurityChallenge {
challenge_id: string;
phone_mask: string;
expires_in_seconds: number;
resend_after_seconds: number;
}
export function requestSecurityChallenge(scene: SecurityChallengeScene) {
return imPost<SecurityChallenge>('/security/challenges', { scene, channel: 'sms' });
}
export function verifySecurityChallenge(challengeId: string, code: string) {
return imPost<{ action_token: string; action: string; expires_at: string }>(
`/security/challenges/${encodeURIComponent(challengeId)}/verify`,
{ code },
);
}
+3
View File
@@ -257,6 +257,9 @@ declare module 'tinode-sdk' {
onSubsUpdated?: (subs: string[], count?: number) => void; onSubsUpdated?: (subs: string[], count?: number) => void;
onPres?: (pres: unknown) => void; onPres?: (pres: unknown) => void;
onAuxUpdated?: (aux: unknown) => void; onAuxUpdated?: (aux: unknown) => void;
/** Fired by the SDK's own `Topic#_gone()` once this topic is confirmed deleted (group dissolved,
* or this account removed from it) -- clears `public`/`private`/messages just before firing. */
onDeleteTopic?: () => void;
} }
export class MeTopic extends Topic { export class MeTopic extends Topic {
+17
View File
@@ -64,6 +64,19 @@ export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicI
return () => infoListeners.delete(callback); return () => infoListeners.delete(callback);
} }
const goneListeners = new Set<(topicName: string) => void>();
/** Fires once a topic is confirmed gone (group dissolved by anyone, or removed from this account) --
* the SDK's own `Topic#_gone()` clears the topic's public/private/messages and calls
* `onDeleteTopic()` (confirmed reading tinode.dev.js). Without ConversationView reacting to this,
* a member who stays on a group's conversation screen while someone else dissolves it just keeps
* looking at a now-defunct view -- `onMetaDesc`/`onPres`/etc. above only invalidate cached snapshots
* for re-render, they don't tell the currently-open screen it needs to navigate away entirely. */
export function subscribeTopicGone(callback: (topicName: string) => void): () => void {
goneListeners.add(callback);
return () => goneListeners.delete(callback);
}
function wireTopicCallbacks(topic: Topic) { function wireTopicCallbacks(topic: Topic) {
if (wiredTopics.has(topic)) return; if (wiredTopics.has(topic)) return;
const notify = () => notifyTopic(topic.name); const notify = () => notifyTopic(topic.name);
@@ -86,6 +99,10 @@ function wireTopicCallbacks(topic: Topic) {
topic.onPres = notify; topic.onPres = notify;
topic.onAuxUpdated = notify; topic.onAuxUpdated = notify;
topic.onAllMessagesReceived = notify; topic.onAllMessagesReceived = notify;
topic.onDeleteTopic = () => {
notify();
goneListeners.forEach((cb) => cb(topic.name));
};
wiredTopics.add(topic); wiredTopics.add(topic);
} }
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand';
import type { SecurityChallengeScene } from '@/lib/api/security';
/**
* Prompts for the protocol's sensitive-action SMS re-verification (docs/IM-前后端对接文档.md:762-766)
* and resolves with the resulting `action_token`, or `null` if the user cancels -- same Promise
* shape/global-store-plus-single-root-component pattern as `confirmDialog.ts`'s `requestConfirm`, for
* the same reason: this needs to be callable from a mutation's `mutationFn` (GroupDetailColumn's
* dissolve flow, currently the only caller) with no natural "own a piece of JSX for this" home.
*/
export interface SecurityChallengeOptions {
scene: SecurityChallengeScene;
/** What's about to happen if the user completes verification -- e.g. "解散群聊「群名」". */
actionDescription: string;
}
interface PendingChallenge extends SecurityChallengeOptions {
resolve: (token: string | null) => void;
}
interface SecurityChallengeState {
pending: PendingChallenge | null;
respond: (token: string | null) => void;
}
export const useSecurityChallengeStore = create<SecurityChallengeState>((set, get) => ({
pending: null,
respond: (token) => {
get().pending?.resolve(token);
set({ pending: null });
},
}));
export function requestActionToken(options: SecurityChallengeOptions): Promise<string | null> {
return new Promise((resolve) => {
useSecurityChallengeStore.setState({ pending: { ...options, resolve } });
});
}