全项目搜出15处 window.confirm、9处 window.alert(其中3处是各文件
各自复制的 notImplemented() 辅助函数,文案还都一样)用在退出登录、
删除消息/收藏/好友、清空聊天记录、解散群聊、转让群主、危险链接
二次确认等场景——全是浏览器原生弹窗,样式和整个应用的设计语言完全
脱节。
新增两个全局UI基础设施(Zustand store + 挂载在App.tsx根节点的
渲染组件,参照stores/calls.ts/messageJump.ts已有的"任何组件都能调用"
惯例,不是每个弹窗各自管理一份本地状态):
- stores/confirmDialog.ts + app/ConfirmDialogRoot.tsx:替换
window.confirm。requestConfirm()返回Promise<boolean>,和
window.confirm的返回值语义一致,所以原来"if (!window.confirm(...))
return"这一行改成"if (!(await requestConfirm({message:...})))
return",外层函数标成async,不用大改周围逻辑。危险/不可恢复操作
(删除、解散群聊、注销账号等)传danger:true,确认按钮变红色,
弹窗视觉沿用转发弹窗已有的"描边取消+实心确认"按钮搭配。
- stores/globalToast.ts + app/GlobalToastRoot.tsx:替换window.alert,
复用应用里本来就有、每个页面各自本地状态管理的Toast组件。顺手把
3个文件里各自重复定义、文案完全一样的notImplemented()合并成
一个共享导出。
实机验证了非危险确认(清空缓存,蓝色确认按钮)、危险确认(删除
消息,红色确认按钮)、取消按钮、Escape键取消、全局提示(点"投诉
举报")——均正常工作,且原有页面各自的Toast错误提示不受影响。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
300 lines
16 KiB
TypeScript
300 lines
16 KiB
TypeScript
import { useState } from 'react';
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
import { Avatar } from '@/app/Avatar';
|
|
import { Icon } from '@/app/Icon';
|
|
import { Toast } from '@/app/Toast';
|
|
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
|
import { QRScannerModal } from '@/features/contacts/QRScannerModal';
|
|
import { searchUsers, sendFriendRequest, type UserSearchResult } from '@/lib/api/friends';
|
|
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;
|
|
|
|
interface AddFriendModalProps {
|
|
onClose: () => void;
|
|
onSent?: () => void;
|
|
/** FriendProfileView's "添加好友" button already knows exactly who -- skips straight past the
|
|
* search step to the verification-message step, rather than making the user re-search someone
|
|
* whose profile is already open. */
|
|
initialTarget?: { user_id: string; display_name: string; public_account?: string; avatar_file_id?: string };
|
|
}
|
|
|
|
/**
|
|
* README §5.4 (520px). 扫一扫/我的二维码 are real, confirmed-live features now (see
|
|
* lib/api/qrcodes.ts's header comment for the live-verification writeup) -- only "分享好友" stays
|
|
* the same "该功能暂未开放" placeholder GroupDetailColumn's own 投诉举报 button already uses, since
|
|
* sharing a friend's card into a conversation has no backend support anywhere. Search result rows
|
|
* drive their state from
|
|
* `relationship`/`can_add` (doc line 1423's confirmed response has no separate `action` field to
|
|
* read a label off of): already a friend or already pending are shown but not selectable, `can_add:
|
|
* false` shows a generic "无法添加" per the doc's own "不泄露具体关闭项" privacy principle, and only
|
|
* a genuinely addable result can be picked before writing the verification message.
|
|
*/
|
|
export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModalProps) {
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const [query, setQuery] = useState('');
|
|
const [searchedFor, setSearchedFor] = useState('');
|
|
const [selected, setSelected] = useState<UserSearchResult | null>(
|
|
initialTarget ? { ...initialTarget, relationship: 'none', can_add: true } : null,
|
|
);
|
|
// 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' | 'qr'>('search');
|
|
const [message, setMessage] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [sent, setSent] = useState(false);
|
|
const [showMyQrCode, setShowMyQrCode] = useState(false);
|
|
const [scanning, setScanning] = useState(false);
|
|
// A scanned `purpose:"join_group"` code -- a completely different target shape from `selected`
|
|
// (a person), so it gets its own state rather than being forced into UserSearchResult's fields.
|
|
const [groupJoinTarget, setGroupJoinTarget] = useState<(QRResolveGroup & { qrToken: string }) | null>(null);
|
|
useEscapeKey(onClose);
|
|
|
|
const handleScanResolved = (user: QRResolveUser) => {
|
|
setScanning(false);
|
|
setSelectedSource('qr');
|
|
setSelected({ user_id: user.user_id, display_name: user.display_name, public_account: user.public_account, relationship: 'none', can_add: true });
|
|
};
|
|
const handleScanAlreadyFriend = (user: QRResolveUser) => {
|
|
setScanning(false);
|
|
onClose();
|
|
navigate(`/contacts/${user.user_id}`);
|
|
};
|
|
const handleScanGroupResolved = (group: QRResolveGroup, qrToken: string) => {
|
|
setScanning(false);
|
|
setGroupJoinTarget({ ...group, qrToken });
|
|
};
|
|
const handleScanAlreadyMember = (group: QRResolveGroup) => {
|
|
setScanning(false);
|
|
onClose();
|
|
navigate(`/messages/${group.topic}`);
|
|
};
|
|
|
|
// docs/IM-前后端对接文档.md:1308: phone-number search only actually happens under `type=phone` --
|
|
// the default `type=account` never matches a bare phone number, so a search box that always sends
|
|
// `account` can never satisfy README §5.4's own placeholder implying phone-number search works.
|
|
const searchType = /^\d{11}$/.test(searchedFor) ? 'phone' : 'account';
|
|
const searchQuery = useQuery({
|
|
queryKey: ['user-search', searchedFor, searchType],
|
|
queryFn: () => searchUsers(searchedFor, searchType),
|
|
enabled: !!searchedFor,
|
|
});
|
|
|
|
const runSearch = () => {
|
|
setSelected(null);
|
|
setSearchedFor(query.trim());
|
|
};
|
|
|
|
const sendMutation = useMutation({
|
|
mutationFn: () => sendFriendRequest(selected?.user_id || '', message.trim() || undefined, selectedSource),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
|
|
setSent(true);
|
|
onSent?.();
|
|
},
|
|
onError: (err) => setError(err instanceof Error ? err.message : '发送申请失败'),
|
|
});
|
|
|
|
const groupJoinMutation = useMutation({
|
|
mutationFn: () =>
|
|
submitGroupJoinRequest(groupJoinTarget?.topic || '', { message: message.trim() || undefined, source: 'qr', qr_token: groupJoinTarget?.qrToken }),
|
|
onSuccess: () => setSent(true),
|
|
onError: (err) => setError(err instanceof Error ? err.message : '发送入群申请失败'),
|
|
});
|
|
|
|
const rowState = (item: UserSearchResult): { label: string; selectable: boolean } => {
|
|
if (item.relationship === 'friend') return { label: '已是好友', selectable: false };
|
|
// docs/IM-前后端对接文档.md:1289 -- the real enum is direction-specific, never a bare "pending"
|
|
// (comparing against that literal never matched either real value, so a search result with an
|
|
// outstanding request either direction fell through to the `can_add` check below and could look
|
|
// fully selectable, risking a duplicate friend-requests submit).
|
|
if (item.relationship === 'pending_outbound') return { label: '已发送申请', selectable: false };
|
|
if (item.relationship === 'pending_inbound') return { label: '对方已申请加你', selectable: false };
|
|
if (!item.can_add) return { label: '无法添加', selectable: false };
|
|
return { label: '', selectable: true };
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
|
<div className="flex max-h-[80vh] w-[520px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
|
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
|
<strong className="text-[15px] font-semibold text-text-heading">添加好友</strong>
|
|
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
|
<Icon name="close" />
|
|
</button>
|
|
</header>
|
|
{sent ? (
|
|
<div className="flex flex-col items-center gap-3 px-5 py-16">
|
|
<Icon name="check_circle" className="text-[40px] text-success" />
|
|
<span className="text-sm text-text-body">申请已发送</span>
|
|
<button type="button" onClick={onClose} className="h-9 rounded-lg bg-btn px-5 text-sm font-medium text-white">
|
|
完成
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
|
{groupJoinTarget ? (
|
|
<div className="flex items-center gap-3 rounded-lg bg-surface-input px-3 py-2.5">
|
|
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg bg-accent text-sm font-medium text-white">
|
|
{groupJoinTarget.name.slice(0, 1)}
|
|
</span>
|
|
<span className="flex min-w-0 flex-col">
|
|
<strong className="truncate text-sm font-medium text-text-body">{groupJoinTarget.name}</strong>
|
|
<span className="truncate text-xs text-text-tertiary-2">{groupJoinTarget.member_count} 人</span>
|
|
</span>
|
|
</div>
|
|
) : initialTarget ? (
|
|
<div className="flex items-center gap-3 rounded-lg bg-surface-input px-3 py-2.5">
|
|
<Avatar
|
|
id={initialTarget.user_id}
|
|
name={initialTarget.display_name}
|
|
avatarFileId={initialTarget.avatar_file_id}
|
|
className="h-10 w-10 flex-none rounded-lg text-sm"
|
|
/>
|
|
<span className="flex min-w-0 flex-col">
|
|
<strong className="truncate text-sm font-medium text-text-body">{initialTarget.display_name}</strong>
|
|
{initialTarget.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {initialTarget.public_account}</span> : null}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex h-10 flex-1 items-center rounded-lg border border-[#d5d9df] px-3 focus-within:border-accent focus-within:shadow-[0_0_0_3px_rgba(22,119,255,0.1)]">
|
|
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
|
<input
|
|
autoFocus
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && runSearch()}
|
|
placeholder="手机号/账号/昵称"
|
|
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
|
/>
|
|
</div>
|
|
<button type="button" onClick={runSearch} disabled={!query.trim()} className="flex-none text-sm text-accent disabled:opacity-40">
|
|
搜索
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
|
{[
|
|
{ icon: 'qr_code_scanner', label: '扫一扫', onClick: () => setScanning(true) },
|
|
{ icon: 'qr_code_2', label: '我的二维码', onClick: () => setShowMyQrCode(true) },
|
|
{ icon: 'share', label: '分享好友', onClick: notImplemented },
|
|
].map((action) => (
|
|
<button
|
|
key={action.label}
|
|
type="button"
|
|
onClick={action.onClick}
|
|
className="flex flex-col items-center gap-1.5 rounded-lg bg-surface-input py-3 text-xs text-text-secondary"
|
|
>
|
|
<Icon name={action.icon} className="text-xl text-text-tertiary-1" />
|
|
{action.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{!initialTarget && !groupJoinTarget && searchedFor ? (
|
|
<div className="mt-4 flex flex-col gap-2">
|
|
{searchQuery.isLoading ? (
|
|
<div className="py-6 text-center text-sm text-text-tertiary-4">搜索中…</div>
|
|
) : !searchQuery.data?.items.length ? (
|
|
<div className="py-6 text-center text-sm text-text-tertiary-4">未找到相关用户</div>
|
|
) : (
|
|
searchQuery.data.items.map((item) => {
|
|
const { label, selectable } = rowState(item);
|
|
const isSelected = selected?.user_id === item.user_id;
|
|
return (
|
|
<button
|
|
key={item.user_id}
|
|
type="button"
|
|
disabled={!selectable}
|
|
onClick={() => {
|
|
setSelectedSource('search');
|
|
setSelected(item);
|
|
}}
|
|
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left disabled:opacity-70"
|
|
>
|
|
<Avatar id={item.user_id} name={item.display_name} avatarFileId={item.avatar_file_id} className="h-10 w-10 flex-none rounded-lg text-sm" />
|
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
<strong className="truncate text-sm font-medium text-text-body">{item.display_name}</strong>
|
|
{item.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {item.public_account}</span> : null}
|
|
</span>
|
|
{selectable ? (
|
|
<span className={`flex-none rounded-full px-2.5 py-1 text-xs ${isSelected ? 'bg-accent-soft-bg text-accent' : 'bg-surface-input text-text-tertiary-2'}`}>
|
|
{isSelected ? '已选择' : '选择'}
|
|
</span>
|
|
) : (
|
|
<span className="flex-none text-xs text-text-tertiary-3">{label}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{selected || groupJoinTarget ? (
|
|
<div className="mt-4">
|
|
<div className="mb-1 text-sm text-text-body">{groupJoinTarget ? '申请留言' : '验证留言'}</div>
|
|
<div className="relative">
|
|
<textarea
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value.slice(0, MAX_MESSAGE_LENGTH))}
|
|
rows={3}
|
|
placeholder={groupJoinTarget ? '申请入群' : `我是${selected?.display_name}的朋友`}
|
|
className="w-full resize-none rounded-lg bg-surface-input p-3 text-sm outline-none"
|
|
/>
|
|
<span className="absolute bottom-2 right-3 text-xs text-text-tertiary-3">
|
|
{message.length}/{MAX_MESSAGE_LENGTH}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
{sent ? null : (
|
|
<footer className="flex flex-none items-center justify-end gap-2 border-t border-border-list-item px-5 py-3">
|
|
<button type="button" onClick={onClose} className="h-9 rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary">
|
|
取消
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => (groupJoinTarget ? groupJoinMutation.mutate() : sendMutation.mutate())}
|
|
disabled={(!selected && !groupJoinTarget) || sendMutation.isPending || groupJoinMutation.isPending}
|
|
className="h-9 rounded-lg bg-btn px-4 text-sm font-medium text-white disabled:bg-btn-disabled"
|
|
>
|
|
{sendMutation.isPending || groupJoinMutation.isPending ? '发送中…' : groupJoinTarget ? '申请加入' : '发送申请'}
|
|
</button>
|
|
</footer>
|
|
)}
|
|
<Toast message={error} variant="error" onDone={() => setError('')} />
|
|
</div>
|
|
</div>
|
|
{/* Siblings of the backdrop above, not nested inside it -- their own backdrop-click-to-close
|
|
would otherwise bubble up and close THIS modal too, since only the 520px card (not the
|
|
backdrop) stops propagation. */}
|
|
{showMyQrCode ? <QRCodeModal onClose={() => setShowMyQrCode(false)} /> : null}
|
|
{scanning ? (
|
|
<QRScannerModal
|
|
onClose={() => setScanning(false)}
|
|
onResolved={handleScanResolved}
|
|
onAlreadyFriend={handleScanAlreadyFriend}
|
|
onGroupResolved={handleScanGroupResolved}
|
|
onAlreadyMember={handleScanAlreadyMember}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|