diff --git a/web/src/App.tsx b/web/src/App.tsx
index bd7c99e1..1b3151e5 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -6,6 +6,7 @@ import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
import { GlobalToastRoot } from '@/app/GlobalToastRoot';
import { KickedNoticeRoot } from '@/app/KickedNoticeRoot';
import { RequireAuth } from '@/app/RequireAuth';
+import { SecurityChallengeModal } from '@/app/SecurityChallengeModal';
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
import { LoginPage } from '@/features/auth/LoginPage';
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage';
@@ -106,6 +107,7 @@ export default function App() {
+
);
}
diff --git a/web/src/app/RequireAuth.tsx b/web/src/app/RequireAuth.tsx
index 29da0a8a..2651de4a 100644
--- a/web/src/app/RequireAuth.tsx
+++ b/web/src/app/RequireAuth.tsx
@@ -106,9 +106,17 @@ export function RequireAuth() {
return (
{restoreTimedOut ? '连接时间较长,你可以继续等待,或改为重新登录' : '正在恢复登录状态…'}
-
- 改为重新登录
-
+ {/* 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
+ 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 ? (
+
+ 改为重新登录
+
+ ) : null}
);
}
diff --git a/web/src/app/SecurityChallengeModal.tsx b/web/src/app/SecurityChallengeModal.tsx
new file mode 100644
index 00000000..4835a661
--- /dev/null
+++ b/web/src/app/SecurityChallengeModal.tsx
@@ -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(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 (
+ respond(null)}>
+
e.stopPropagation()}>
+
安全验证
+
+ 即将{pending.actionDescription},请验证手机号{phoneMask ? ` ${phoneMask}` : ''} 收到的短信验证码
+
+
+ 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"
+ />
+ 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)` : '重新获取'}
+
+
+ {error ?
{error}
: null}
+
+ respond(null)}
+ className="grid h-9 place-items-center rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary"
+ >
+ 取消
+
+
+ {busy ? '验证中…' : '确定'}
+
+
+
+
+ );
+}
diff --git a/web/src/features/contacts/AddFriendModal.tsx b/web/src/features/contacts/AddFriendModal.tsx
index 38325b3a..45f20d31 100644
--- a/web/src/features/contacts/AddFriendModal.tsx
+++ b/web/src/features/contacts/AddFriendModal.tsx
@@ -47,7 +47,7 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
// 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
// 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 [error, setError] = useState('');
const [sent, setSent] = useState(false);
@@ -227,12 +227,19 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
type="button"
disabled={!selectable}
onClick={() => {
- // `searchType` (already computed above from the query itself) is the real
- // signal for which channel actually found this person -- NewFriendsView's
- // own SOURCE_LABEL table already has a `phone` entry ("通过手机号搜索")
- // that this path never reached because every search result was tagged
- // 'search' regardless of how it was found.
- setSelectedSource(searchType === 'phone' ? 'phone' : 'search');
+ // Real, live-confirmed backend validation (im_http.go's
+ // serveIMFriendRequestCreate): the `source` field's switch only recognizes
+ // `search`/`qr`/`group` -- there is no `phone` case, so it falls to
+ // `default` and 400s with "invalid friend request source". A prior pass
+ // here tried to report phone-originated searches as their own `phone`
+ // 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);
}}
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left disabled:opacity-70"
diff --git a/web/src/features/contacts/NewFriendsView.tsx b/web/src/features/contacts/NewFriendsView.tsx
index c04256d1..77ca2ebf 100644
--- a/web/src/features/contacts/NewFriendsView.tsx
+++ b/web/src/features/contacts/NewFriendsView.tsx
@@ -11,7 +11,12 @@ import { acceptFriendRequest, getFriendRequests, rejectFriendRequest, type Frien
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
-const SOURCE_LABEL: Record = { 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 = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加' };
/** README §5.3: both pending (拒绝/通过验证 buttons) and already-handled (灰字"已通过/已拒绝")
* inbound requests show together in one list -- `getFriendRequests('inbound', 'all')` matches
diff --git a/web/src/features/groups/GroupAdminModal.tsx b/web/src/features/groups/GroupAdminModal.tsx
index 77d4bff0..950f368c 100644
--- a/web/src/features/groups/GroupAdminModal.tsx
+++ b/web/src/features/groups/GroupAdminModal.tsx
@@ -24,6 +24,7 @@ import {
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { requestConfirm } from '@/stores/confirmDialog';
+import { requestActionToken } from '@/stores/securityChallenge';
import { useSessionStore } from '@/stores/session';
interface GroupAdminModalProps {
@@ -469,7 +470,8 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str
const queryClient = useQueryClient();
const [error, setError] = useState('');
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: () => {
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-settings', topicId] });
@@ -490,9 +492,10 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str
cacheKey="eligible-owner"
opts={{ eligibleRole: 'owner' }}
onPick={async (m) => {
- if (await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true })) {
- transferMutation.mutate(m.user_id);
- }
+ if (!(await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true }))) return;
+ const actionToken = await requestActionToken({ scene: 'group_owner_transfer', actionDescription: '转让群主' });
+ if (!actionToken) return;
+ transferMutation.mutate({ userId: m.user_id, actionToken });
}}
emptyLabel="没有可转让的成员"
disabled={transferMutation.isPending}
diff --git a/web/src/features/groups/GroupDetailColumn.tsx b/web/src/features/groups/GroupDetailColumn.tsx
index 8aafb690..f2744bf1 100644
--- a/web/src/features/groups/GroupDetailColumn.tsx
+++ b/web/src/features/groups/GroupDetailColumn.tsx
@@ -28,6 +28,7 @@ import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useTinode } from '@/lib/tinode/hooks';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
+import { requestActionToken } from '@/stores/securityChallenge';
import { useSessionStore } from '@/stores/session';
interface GroupDetailColumnProps {
@@ -157,7 +158,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
});
const disbandMutation = useMutation({
- mutationFn: () => disbandGroup(topicId),
+ mutationFn: (actionToken: string) => disbandGroup(topicId, actionToken),
onSuccess: () => navigate('/messages'),
onError: (err) => showError(err, '解散群聊失败'),
});
@@ -178,7 +179,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
const startLeaveOrDisband = async () => {
if (isOwner) {
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 {
if (!(await requestConfirm({ message: '确定退出本群聊吗?', danger: true }))) return;
leaveMutation.mutate();
diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx
index b87dfcba..f9348a28 100644
--- a/web/src/features/messages/ConversationView.tsx
+++ b/web/src/features/messages/ConversationView.tsx
@@ -29,10 +29,10 @@ import { buildMentionedContent, detectMentionTrigger, type PendingMention } from
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
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 { requestConfirm } from '@/stores/confirmDialog';
-import { notImplemented } from '@/stores/globalToast';
+import { notify, notImplemented } from '@/stores/globalToast';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
@@ -165,6 +165,20 @@ export function ConversationView() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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
// `: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
diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts
index d6609097..e9dd8e03 100644
--- a/web/src/lib/api/client.ts
+++ b/web/src/lib/api/client.ts
@@ -87,6 +87,9 @@ const REASON_MESSAGES: Record string | n
QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描',
QR_CODE_INVALID: '二维码无效,请重新扫描',
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: '未找到该用户',
REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态',
SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试',
diff --git a/web/src/lib/api/groups.ts b/web/src/lib/api/groups.ts
index c1f875df..b260dec9 100644
--- a/web/src/lib/api/groups.ts
+++ b/web/src/lib/api/groups.ts
@@ -130,9 +130,17 @@ export function leaveGroup(topic: string) {
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. */
-export function disbandGroup(topic: string) {
- return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`);
+/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.17) -- get one via
+ * `requestActionToken({scene:'group_dissolve', ...})` (stores/securityChallenge.ts) first. Live,
+ * 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';
@@ -264,14 +272,14 @@ export function unmuteGroupMember(topic: string, userId: string) {
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
- * wired since no risk-challenge flow is built anywhere in this app yet; calling this will 409 until
- * 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) {
- return imPost<{ owner: string; previous_owner: string; version: number }>(`/groups/${encodeURIComponent(topic)}/owner-transfer`, {
- target_user_id: targetUserId,
- version,
- });
+/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- get one via
+ * `requestActionToken({scene:'group_owner_transfer', ...})` (stores/securityChallenge.ts) first. */
+export function transferGroupOwner(topic: string, targetUserId: string, version: number, actionToken: string) {
+ return imPost<{ owner: string; previous_owner: string; version: number }>(
+ `/groups/${encodeURIComponent(topic)}/owner-transfer`,
+ { target_user_id: targetUserId, version },
+ { headers: { 'X-Action-Token': actionToken } },
+ );
}
export function getGroupGovernance(topic: string) {
diff --git a/web/src/lib/api/security.ts b/web/src/lib/api/security.ts
index 63f7d412..f178a625 100644
--- a/web/src/lib/api/security.ts
+++ b/web/src/lib/api/security.ts
@@ -15,3 +15,27 @@ export interface UrlSafetyResult {
export function checkUrlSafety(url: string, source: string) {
return imPost('/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('/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 },
+ );
+}
diff --git a/web/src/lib/tinode/tinode-sdk.d.ts b/web/src/lib/tinode/tinode-sdk.d.ts
index 3a37942b..172c5736 100644
--- a/web/src/lib/tinode/tinode-sdk.d.ts
+++ b/web/src/lib/tinode/tinode-sdk.d.ts
@@ -257,6 +257,9 @@ declare module 'tinode-sdk' {
onSubsUpdated?: (subs: string[], count?: number) => void;
onPres?: (pres: 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 {
diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts
index 4cf37d03..85bbf863 100644
--- a/web/src/lib/tinode/topics.ts
+++ b/web/src/lib/tinode/topics.ts
@@ -64,6 +64,19 @@ export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicI
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) {
if (wiredTopics.has(topic)) return;
const notify = () => notifyTopic(topic.name);
@@ -86,6 +99,10 @@ function wireTopicCallbacks(topic: Topic) {
topic.onPres = notify;
topic.onAuxUpdated = notify;
topic.onAllMessagesReceived = notify;
+ topic.onDeleteTopic = () => {
+ notify();
+ goneListeners.forEach((cb) => cb(topic.name));
+ };
wiredTopics.add(topic);
}
diff --git a/web/src/stores/securityChallenge.ts b/web/src/stores/securityChallenge.ts
new file mode 100644
index 00000000..2e39f77e
--- /dev/null
+++ b/web/src/stores/securityChallenge.ts
@@ -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((set, get) => ({
+ pending: null,
+ respond: (token) => {
+ get().pending?.resolve(token);
+ set({ pending: null });
+ },
+}));
+
+export function requestActionToken(options: SecurityChallengeOptions): Promise {
+ return new Promise((resolve) => {
+ useSecurityChallengeStore.setState({ pending: { ...options, resolve } });
+ });
+}