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( 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 ( <>
e.stopPropagation()}>
添加好友
{sent ? (
申请已发送
) : (
{groupJoinTarget ? (
{groupJoinTarget.name.slice(0, 1)} {groupJoinTarget.name} {groupJoinTarget.member_count} 人
) : initialTarget ? (
{initialTarget.display_name} {initialTarget.public_account ? ID: {initialTarget.public_account} : null}
) : ( <>
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" />
{[ { icon: 'qr_code_scanner', label: '扫一扫', onClick: () => setScanning(true) }, { icon: 'qr_code_2', label: '我的二维码', onClick: () => setShowMyQrCode(true) }, { icon: 'share', label: '分享好友', onClick: notImplemented }, ].map((action) => ( ))}
)} {!initialTarget && !groupJoinTarget && searchedFor ? (
{searchQuery.isLoading ? (
搜索中…
) : !searchQuery.data?.items.length ? (
未找到相关用户
) : ( searchQuery.data.items.map((item) => { const { label, selectable } = rowState(item); const isSelected = selected?.user_id === item.user_id; return ( ); }) )}
) : null} {selected || groupJoinTarget ? (
{groupJoinTarget ? '申请留言' : '验证留言'}