diff --git a/web/src/App.tsx b/web/src/App.tsx index f883410d..dc4f2aba 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom'; import { AppShell } from '@/app/AppShell'; +import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot'; +import { GlobalToastRoot } from '@/app/GlobalToastRoot'; import { RequireAuth } from '@/app/RequireAuth'; import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage'; import { LoginPage } from '@/features/auth/LoginPage'; @@ -97,6 +99,11 @@ export default function App() { return ( + {/* Mounted above the router (not inside AppShell) -- window.confirm()/window.alert() + replacements need to work on unauthenticated pages too (e.g. TenantEntryPage's "switch + tenant" confirmation), not just the logged-in app tree. */} + + ); } diff --git a/web/src/app/ConfirmDialogRoot.tsx b/web/src/app/ConfirmDialogRoot.tsx new file mode 100644 index 00000000..b4b84b1a --- /dev/null +++ b/web/src/app/ConfirmDialogRoot.tsx @@ -0,0 +1,42 @@ +import { useEscapeKey } from '@/lib/hooks'; +import { useConfirmDialogStore } from '@/stores/confirmDialog'; + +/** Mounted once at the app root (App.tsx) -- renders whatever `requestConfirm()` (confirmDialog.ts) + * most recently asked for. Visual language matches this app's other small action dialogs (e.g. the + * forward picker's outlined-gray "取消" + solid colored confirm button, `02-messages.dc.html`'s own + * spec for that pairing) rather than inventing a new button treatment just for this. */ +export function ConfirmDialogRoot() { + const pending = useConfirmDialogStore((s) => s.pending); + const respond = useConfirmDialogStore((s) => s.respond); + // Always called (hooks can't be conditional) -- a no-op onEscape while nothing is pending is + // harmless, matching how every other modal in this app registers unconditionally too. + useEscapeKey(() => respond(false)); + + if (!pending) return null; + const { title, message, confirmLabel = '确定', cancelLabel = '取消', danger } = pending; + + return ( +
respond(false)}> +
e.stopPropagation()}> + {title ? {title} : null} +

{message}

+
+ + +
+
+
+ ); +} diff --git a/web/src/app/GlobalToastRoot.tsx b/web/src/app/GlobalToastRoot.tsx new file mode 100644 index 00000000..14e0afdc --- /dev/null +++ b/web/src/app/GlobalToastRoot.tsx @@ -0,0 +1,14 @@ +import { Toast } from '@/app/Toast'; +import { useGlobalToastStore } from '@/stores/globalToast'; + +/** Mounted once at the app root (App.tsx) -- renders whatever `notify()` (globalToast.ts) most + * recently set. `key={token}` forces Toast to remount (not just re-render) so its own auto-dismiss + * timer restarts even when the exact same message is fired twice in a row, which a plain `message` + * string prop wouldn't detect as a change. */ +export function GlobalToastRoot() { + const message = useGlobalToastStore((s) => s.message); + const variant = useGlobalToastStore((s) => s.variant); + const token = useGlobalToastStore((s) => s.token); + if (!message) return null; + return useGlobalToastStore.setState({ message: '' })} />; +} diff --git a/web/src/features/auth/TenantEntryPage.tsx b/web/src/features/auth/TenantEntryPage.tsx index 3b63eec1..fa814ce7 100644 --- a/web/src/features/auth/TenantEntryPage.tsx +++ b/web/src/features/auth/TenantEntryPage.tsx @@ -7,6 +7,7 @@ import { Icon } from '@/app/Icon'; import { ImApiError } from '@/lib/api/client'; import { resolveTenant } from '@/lib/api/tenant'; import { resetTinode } from '@/lib/tinode/connection'; +import { requestConfirm } from '@/stores/confirmDialog'; import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session'; export function TenantEntryPage() { @@ -25,7 +26,7 @@ export function TenantEntryPage() { // Reachable by navigating back here while already logged in (no route guard keeps an // authenticated user out of /tenant) -- resetTinode() below would otherwise silently tear down // that live session with no warning at all. - if (myUserId && !window.confirm('切换企业将退出当前登录,确定继续吗?')) return; + if (myUserId && !(await requestConfirm({ message: '切换企业将退出当前登录,确定继续吗?' }))) return; setSubmitting(true); setError(''); try { diff --git a/web/src/features/contacts/AddFriendModal.tsx b/web/src/features/contacts/AddFriendModal.tsx index 2d8dbbb0..3a58ba96 100644 --- a/web/src/features/contacts/AddFriendModal.tsx +++ b/web/src/features/contacts/AddFriendModal.tsx @@ -12,13 +12,10 @@ import { searchUsers, sendFriendRequest, type UserSearchResult } from '@/lib/api import { submitGroupJoinRequest } from '@/lib/api/groups'; import type { QRResolveGroup, QRResolveUser } from '@/lib/api/qrcodes'; import { useEscapeKey } from '@/lib/hooks'; +import { notImplemented } from '@/stores/globalToast'; const MAX_MESSAGE_LENGTH = 50; -function notImplemented() { - window.alert('该功能暂未开放,如需联系客服。'); -} - interface AddFriendModalProps { onClose: () => void; onSent?: () => void; diff --git a/web/src/features/favorites/FavoritesLayout.tsx b/web/src/features/favorites/FavoritesLayout.tsx index cd31a5c3..b43bb114 100644 --- a/web/src/features/favorites/FavoritesLayout.tsx +++ b/web/src/features/favorites/FavoritesLayout.tsx @@ -21,6 +21,7 @@ import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks'; import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments'; import { formatEventTimestamp } from '@/lib/messages/time'; import { useChatList } from '@/lib/tinode/hooks'; +import { requestConfirm } from '@/stores/confirmDialog'; const CATEGORIES: { key: FavoriteType | 'all'; label: string; icon: string }[] = [ { key: 'all', label: '全部', icon: 'all_inbox' }, @@ -320,9 +321,9 @@ export function FavoritesLayout() { setSelectMode(false); setSelected(new Set()); }; - const bulkDelete = () => { + const bulkDelete = async () => { if (selected.size === 0) return; - if (!window.confirm(`删除已选择的 ${selected.size} 项收藏?`)) return; + if (!(await requestConfirm({ message: `删除已选择的 ${selected.size} 项收藏?`, danger: true }))) return; deleteMutation.mutate([...selected]); }; diff --git a/web/src/features/groups/GroupAdminModal.tsx b/web/src/features/groups/GroupAdminModal.tsx index 030eee07..01865078 100644 --- a/web/src/features/groups/GroupAdminModal.tsx +++ b/web/src/features/groups/GroupAdminModal.tsx @@ -23,6 +23,7 @@ import { } from '@/lib/api/groups'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useEscapeKey } from '@/lib/hooks'; +import { requestConfirm } from '@/stores/confirmDialog'; import { useSessionStore } from '@/stores/session'; interface GroupAdminModalProps { @@ -478,8 +479,10 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str topicId={topicId} cacheKey="eligible-owner" opts={{ eligibleRole: 'owner' }} - onPick={(m) => { - if (window.confirm(`确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`)) transferMutation.mutate(m.user_id); + onPick={async (m) => { + if (await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true })) { + transferMutation.mutate(m.user_id); + } }} emptyLabel="没有可转让的成员" disabled={transferMutation.isPending} diff --git a/web/src/features/groups/GroupDetailColumn.tsx b/web/src/features/groups/GroupDetailColumn.tsx index ed9add1d..f271fe2f 100644 --- a/web/src/features/groups/GroupDetailColumn.tsx +++ b/web/src/features/groups/GroupDetailColumn.tsx @@ -26,6 +26,8 @@ import { import type { MediaType } from '@/lib/api/messages'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useTinode } from '@/lib/tinode/hooks'; +import { requestConfirm } from '@/stores/confirmDialog'; +import { notify } from '@/stores/globalToast'; import { useSessionStore } from '@/stores/session'; interface GroupDetailColumnProps { @@ -155,21 +157,21 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }: onError: (err) => showError(err, '解散群聊失败'), }); - const clearHistory = () => { - if (!window.confirm('确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。')) return; + const clearHistory = async () => { + if (!(await requestConfirm({ message: '确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。', danger: true }))) return; tinode.getTopic(topicId).delMessagesAll(false); }; const reportAbuse = () => { - window.alert('该功能暂未开放,如需投诉举报请联系客服。'); + notify('该功能暂未开放,如需投诉举报请联系客服。'); }; - const startLeaveOrDisband = () => { + const startLeaveOrDisband = async () => { if (isOwner) { - if (!window.confirm('解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?')) return; + if (!(await requestConfirm({ message: '解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?', danger: true }))) return; disbandMutation.mutate(); } else { - if (!window.confirm('确定退出本群聊吗?')) return; + if (!(await requestConfirm({ message: '确定退出本群聊吗?', danger: true }))) return; leaveMutation.mutate(); } }; diff --git a/web/src/features/groups/GroupFilesModal.tsx b/web/src/features/groups/GroupFilesModal.tsx index af1e77d9..9cadb67d 100644 --- a/web/src/features/groups/GroupFilesModal.tsx +++ b/web/src/features/groups/GroupFilesModal.tsx @@ -9,6 +9,7 @@ import { fileAuthHeaders, fileDownloadUrl, uploadFile } from '@/lib/api/upload'; import { useClickOutside, useDebouncedValue, useEscapeKey } from '@/lib/hooks'; import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments'; import { formatEventTimestamp } from '@/lib/messages/time'; +import { requestConfirm } from '@/stores/confirmDialog'; import { useSessionStore } from '@/stores/session'; function isPdf(f: GroupFile): boolean { @@ -211,9 +212,11 @@ export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalPr onClose={() => setOpenMenuId(null)} onDownload={() => downloadFile(f)} onOpen={() => openFileInNewTab(f)} - onDelete={() => { + onDelete={async () => { setOpenMenuId(null); - if (window.confirm(`确定删除文件"${f.file_name}"吗?`)) deleteMutation.mutate(f.group_file_id); + if (await requestConfirm({ message: `确定删除文件"${f.file_name}"吗?`, danger: true })) { + deleteMutation.mutate(f.group_file_id); + } }} deleting={deleteMutation.isPending} /> diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 72513a7c..7eb0c6a4 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -30,6 +30,8 @@ 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 { useCallStore } from '@/stores/calls'; +import { requestConfirm } from '@/stores/confirmDialog'; +import { notImplemented } from '@/stores/globalToast'; import { useMessageJumpStore } from '@/stores/messageJump'; import { useSessionStore } from '@/stores/session'; @@ -50,10 +52,6 @@ interface ContextMenuState { msg: StoredMessage; } -function notImplemented() { - window.alert('该功能暂未开放,如需联系客服。'); -} - export function ConversationView() { const { topicId } = useParams(); const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext(); @@ -443,9 +441,9 @@ export function ConversationView() { setSelectedSeqs(new Set()); }; - const bulkDelete = () => { + const bulkDelete = async () => { if (selectedSeqs.size === 0) return; - if (!window.confirm(`删除已选择的 ${selectedSeqs.size} 条消息?`)) return; + if (!(await requestConfirm({ message: `删除已选择的 ${selectedSeqs.size} 条消息?`, danger: true }))) return; topic.delMessagesList([...selectedSeqs], false); exitSelectMode(); }; diff --git a/web/src/features/messages/DetailColumn.tsx b/web/src/features/messages/DetailColumn.tsx index 5751ae20..4bb04ea4 100644 --- a/web/src/features/messages/DetailColumn.tsx +++ b/web/src/features/messages/DetailColumn.tsx @@ -21,6 +21,8 @@ import type { MediaType } from '@/lib/api/messages'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useTinode } from '@/lib/tinode/hooks'; import { useCallStore } from '@/stores/calls'; +import { requestConfirm } from '@/stores/confirmDialog'; +import { notify } from '@/stores/globalToast'; interface DetailColumnProps { topicId: string; @@ -126,18 +128,18 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta onError: (err) => showError(err, '删除好友失败'), }); - const clearHistory = () => { - if (!window.confirm('确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。')) return; + const clearHistory = async () => { + if (!(await requestConfirm({ message: '确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。', danger: true }))) return; tinode.getTopic(topicId).delMessagesAll(false); }; - const startDeleteFriend = () => { - if (!window.confirm(`确定删除好友 ${peerName} 吗?`)) return; + const startDeleteFriend = async () => { + if (!(await requestConfirm({ message: `确定删除好友 ${peerName} 吗?`, danger: true }))) return; deleteFriendMutation.mutate(); }; const reportAbuse = () => { - window.alert('该功能暂未开放,如需投诉举报请联系客服。'); + notify('该功能暂未开放,如需投诉举报请联系客服。'); }; return ( diff --git a/web/src/features/messages/HistorySearchModal.tsx b/web/src/features/messages/HistorySearchModal.tsx index 9bd38512..035928c9 100644 --- a/web/src/features/messages/HistorySearchModal.tsx +++ b/web/src/features/messages/HistorySearchModal.tsx @@ -10,6 +10,8 @@ import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks'; import { renderDrafty } from '@/lib/messages/drafty'; import { formatEventTimestamp } from '@/lib/messages/time'; +import { requestConfirm } from '@/stores/confirmDialog'; +import { notify } from '@/stores/globalToast'; import { useMessageJumpStore } from '@/stores/messageJump'; import { useSessionStore } from '@/stores/session'; @@ -90,14 +92,14 @@ function LinkResult({ item }: { item: SearchHit & { url?: string; title?: string try { result = await safetyCheck.mutateAsync(item.url!); } catch { - window.alert('链接安全检查失败,请稍后重试。'); + notify('链接安全检查失败,请稍后重试。'); return; } if (result.decision === 'block') { - window.alert('该链接存在安全风险,已被拦截。'); + notify('该链接存在安全风险,已被拦截。'); return; } - if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return; + if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return; window.open(item.url, '_blank', 'noopener,noreferrer'); }; diff --git a/web/src/features/messages/SafeLink.tsx b/web/src/features/messages/SafeLink.tsx index c77f4bc8..f69d26a2 100644 --- a/web/src/features/messages/SafeLink.tsx +++ b/web/src/features/messages/SafeLink.tsx @@ -3,6 +3,8 @@ import type { ReactNode } from 'react'; import { useMutation } from '@tanstack/react-query'; import { checkUrlSafety } from '@/lib/api/security'; +import { requestConfirm } from '@/stores/confirmDialog'; +import { notify } from '@/stores/globalToast'; /** * docs/IM-前后端对接文档.md:1822,1898 -- links found in chat content must pass a server-side risk @@ -22,14 +24,14 @@ export function SafeLink({ href, children }: { href: string | undefined; childre try { result = await safetyCheck.mutateAsync(href); } catch { - window.alert('链接安全检查失败,请稍后重试。'); + notify('链接安全检查失败,请稍后重试。'); return; } if (result.decision === 'block') { - window.alert('该链接存在安全风险,已被拦截。'); + notify('该链接存在安全风险,已被拦截。'); return; } - if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return; + if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return; window.open(href, '_blank', 'noopener,noreferrer'); }; diff --git a/web/src/features/settings/GeneralSettingsPage.tsx b/web/src/features/settings/GeneralSettingsPage.tsx index 69cda745..ab142da9 100644 --- a/web/src/features/settings/GeneralSettingsPage.tsx +++ b/web/src/features/settings/GeneralSettingsPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Toast } from '@/app/Toast'; +import { requestConfirm } from '@/stores/confirmDialog'; /** * README §8.7 mentions a "清空缓存「126 MB」" row -- confirmed no server endpoint touches cache at @@ -16,7 +17,7 @@ export function GeneralSettingsPage() { const [error, setError] = useState(''); const clearCache = async () => { - if (!window.confirm('确定清除本地缓存吗?不会影响你的账号数据。')) return; + if (!(await requestConfirm({ message: '确定清除本地缓存吗?不会影响你的账号数据。' }))) return; setStatus('clearing'); try { if (typeof caches === 'undefined') throw new Error('此浏览器不支持缓存清除'); diff --git a/web/src/features/settings/SecuritySettingsPage.tsx b/web/src/features/settings/SecuritySettingsPage.tsx index b60b87b4..51cb4608 100644 --- a/web/src/features/settings/SecuritySettingsPage.tsx +++ b/web/src/features/settings/SecuritySettingsPage.tsx @@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom'; import { Icon } from '@/app/Icon'; import { Toast } from '@/app/Toast'; import { getTinode, resetTinode } from '@/lib/tinode/connection'; +import { requestConfirm } from '@/stores/confirmDialog'; import { useSessionStore } from '@/stores/session'; /** @@ -66,8 +67,8 @@ export function SecuritySettingsPage() { const deleteAccount = async () => { if (deletingAccount) return; - if (!window.confirm('注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?')) return; - if (!window.confirm('请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。')) return; + if (!(await requestConfirm({ message: '注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?', danger: true }))) return; + if (!(await requestConfirm({ message: '请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。', danger: true }))) return; setDeletingAccount(true); try { await getTinode().delCurrentUser(true); diff --git a/web/src/features/settings/SettingsLayout.tsx b/web/src/features/settings/SettingsLayout.tsx index 05b34849..6bc9eb96 100644 --- a/web/src/features/settings/SettingsLayout.tsx +++ b/web/src/features/settings/SettingsLayout.tsx @@ -7,6 +7,8 @@ import { Avatar } from '@/app/Avatar'; import { Icon } from '@/app/Icon'; import { getMe } from '@/lib/api/profile'; import { resetTinode } from '@/lib/tinode/connection'; +import { requestConfirm } from '@/stores/confirmDialog'; +import { notImplemented } from '@/stores/globalToast'; import { useSessionStore } from '@/stores/session'; const DIRECTORY = [ @@ -39,10 +41,6 @@ const MOBILE_SHORTCUTS = [ * endpoints 404, matching the old app's own real implementation (tinode-web.jsx:1995-2038), which * never calls a server logout endpoint at all, just clears local token/session and disconnects. */ -function notImplemented() { - window.alert('该功能暂未开放,如需联系客服。'); -} - export function SettingsLayout() { const location = useLocation(); const navigate = useNavigate(); @@ -55,8 +53,8 @@ export function SettingsLayout() { // currently shows. Irrelevant at `md`+ (desktop always shows the one real sidebar). const [mobileSubView, setMobileSubView] = useState(false); - const logout = () => { - if (!window.confirm('确定退出登录吗?')) return; + const logout = async () => { + if (!(await requestConfirm({ message: '确定退出登录吗?' }))) return; setLoggingOut(true); resetTinode(); sessionStorage.removeItem('im-session-token'); diff --git a/web/src/stores/confirmDialog.ts b/web/src/stores/confirmDialog.ts new file mode 100644 index 00000000..c43339d9 --- /dev/null +++ b/web/src/stores/confirmDialog.ts @@ -0,0 +1,48 @@ +import { create } from 'zustand'; + +/** + * Replaces `window.confirm()` app-wide (README's own design language never intended a native + * browser dialog to show up inside this UI -- it was just the fastest way to gate a destructive + * action while the real dialog didn't exist yet). A global store + a single `` + * mounted once at the app root (see App.tsx), not a local `useState` per call site: this needs to be + * callable from any event handler, including ones with no natural "own a piece of JSX for this" + * home (e.g. GroupFilesModal's per-row delete button), the same reasoning `stores/calls.ts`/ + * `stores/messageJump.ts` already established for other "any component, any time" actions. + * + * `requestConfirm()` returns a Promise the same shape `window.confirm()`'s return value has (resolves + * true/false, never rejects) so every existing `if (!window.confirm(...)) return;` call site becomes + * `if (!(await requestConfirm({ message: ... }))) return;` with the enclosing function marked async -- + * a mechanical, low-risk edit, not a rewrite of the surrounding logic. + */ +export interface ConfirmOptions { + title?: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + /** Red confirm button for irreversible/destructive actions (delete, disband, deregister) -- + * omitted (default blue) for merely consequential ones (log out, switch tenant). */ + danger?: boolean; +} + +interface PendingConfirm extends ConfirmOptions { + resolve: (value: boolean) => void; +} + +interface ConfirmDialogState { + pending: PendingConfirm | null; + respond: (value: boolean) => void; +} + +export const useConfirmDialogStore = create((set, get) => ({ + pending: null, + respond: (value) => { + get().pending?.resolve(value); + set({ pending: null }); + }, +})); + +export function requestConfirm(options: ConfirmOptions): Promise { + return new Promise((resolve) => { + useConfirmDialogStore.setState({ pending: { ...options, resolve } }); + }); +} diff --git a/web/src/stores/globalToast.ts b/web/src/stores/globalToast.ts new file mode 100644 index 00000000..e2a0d9ea --- /dev/null +++ b/web/src/stores/globalToast.ts @@ -0,0 +1,37 @@ +import { create } from 'zustand'; + +/** + * Replaces `window.alert()` app-wide, same rationale as `stores/confirmDialog.ts`'s replacement of + * `window.confirm()`: a global store + a single `` mounted once at the app root + * renders the app's own existing `Toast` component (already used everywhere for a mutation's error + * state) instead of the browser's native dialog. `notify()` is deliberately fire-and-forget (no + * Promise, no return value) -- an alert-style message never blocked on a user response the way + * confirm did, so there's nothing to await. + */ +interface GlobalToastState { + message: string; + variant: 'success' | 'error'; + token: number; +} + +export const useGlobalToastStore = create(() => ({ + message: '', + variant: 'error', + token: 0, +})); + +export function notify(message: string, variant: 'success' | 'error' = 'error') { + // `token` (not just `message`) is what Toast's own auto-dismiss effect keys off of via the caller + // -- see GlobalToastRoot -- so firing the SAME message twice in a row (e.g. two clicks on a + // still-disabled "该功能暂未开放" button before the first toast dismisses) restarts the timer + // instead of the second call being a no-op change that React skips re-rendering for. + useGlobalToastStore.setState((s) => ({ message, variant, token: s.token + 1 })); +} + +/** Every existing `window.alert('该功能暂未开放,如需联系客服。')` call site (settings, composer + * toolbar, add-friend, group/contact detail panels) used the identical hand-typed Chinese string -- + * one shared helper instead of that string living independently in five files that could each drift + * from the others if the wording ever needs to change. */ +export function notImplemented() { + notify('该功能暂未开放,如需联系客服。'); +}