Files
im_webapp/web/src/features/contacts/AddFriendModal.tsx
T
Your NameandClaude Sonnet 5 37d08e8989 feat(web): 用符合系统风格的组件替换全部 window.alert/confirm
全项目搜出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>
2026-09-11 00:08:15 +08:00

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}
</>
);
}