feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes

Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
@@ -0,0 +1,302 @@
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';
const MAX_MESSAGE_LENGTH = 50;
function notImplemented() {
window.alert('该功能暂未开放,如需联系客服。');
}
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}
</>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import { batchUnblockUsers, getBlocks, unblockUser } from '@/lib/api/friends';
import { formatEventTimestamp } from '@/lib/messages/time';
/** README §5.6. `getBlocks` nests the user under `.user` (see friends.ts's own comment on that
* endpoint's real shape, a doc-drift bug caught during Phase 1-2's review).
*
* Multi-select + batch-unblock has no dedicated mock anywhere in this design handoff (04-contacts.dc.html's
* own #1g only ever shows a single row, one 移出 button each) -- rather than invent a new interaction
* pattern from nothing, this reuses the SAME 多选/已选择N项/取消 header convention FavoritesLayout.tsx
* already established for its own multi-select, so it's consistent with something this app already does,
* not a one-off design.
*/
export function BlocklistView() {
const queryClient = useQueryClient();
const [error, setError] = useState('');
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const query = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const unblockMutation = useMutation({
mutationFn: (userId: string) => unblockUser(userId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
onError: (err) => setError(err instanceof Error ? err.message : '移出黑名单失败'),
});
const exitSelectMode = () => {
setSelectMode(false);
setSelected(new Set());
};
const bulkUnblockMutation = useMutation({
mutationFn: (userIds: string[]) => batchUnblockUsers(userIds),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
// A 200 here only means the REQUEST was accepted -- the doc's own worked example returns a
// per-item `failed[]` array alongside `items[]`, so some of the selected users can fail to
// unblock while the rest succeed. Silently treating this as full success (the same pattern
// already fixed for favorites/GroupAdminModal elsewhere in this app) would leave a user still
// on the blocklist with no indication anything went wrong.
if (data.failed?.length) setError(`${data.failed.length} 人移出失败,请重试`);
exitSelectMode();
},
onError: (err) => setError(err instanceof Error ? err.message : '批量移出失败'),
});
const toggleSelected = (userId: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(userId)) next.delete(userId);
else next.add(userId);
return next;
});
const items = query.data || [];
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
{selectMode ? (
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
<span className="text-sm text-text-secondary">
已选择 <strong className="font-semibold text-accent">{selected.size}</strong> 项
</span>
<span className="flex items-center gap-2">
<button
type="button"
onClick={() => bulkUnblockMutation.mutate(Array.from(selected))}
disabled={selected.size === 0 || bulkUnblockMutation.isPending}
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-tertiary-1 disabled:opacity-40"
>
<Icon name="remove_circle_outline" className="text-[18px]" />
移出
</button>
<button type="button" onClick={exitSelectMode} className="grid h-8.5 place-items-center px-3.5 text-[13px] text-text-tertiary-1">
取消
</button>
</span>
</header>
) : (
<header className="flex h-15 flex-none items-center gap-1 border-b border-border-panel bg-white px-6">
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<strong className="flex-1 text-base font-semibold text-text-heading">
黑名单{items.length ? ` ${items.length}` : ''}
</strong>
{items.length > 1 ? (
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
多选
</button>
) : null}
</header>
)}
<div className="flex flex-col gap-2.5 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">黑名单是空的</div>
) : (
items.map((b) => (
<div
key={b.user.user_id}
role={selectMode ? 'button' : undefined}
tabIndex={selectMode ? 0 : undefined}
onClick={selectMode ? () => toggleSelected(b.user.user_id) : undefined}
onKeyDown={selectMode ? (e) => e.key === 'Enter' && toggleSelected(b.user.user_id) : undefined}
className={`flex items-center gap-3 rounded-xl bg-white px-4 py-3 shadow-bubble ${selectMode ? 'cursor-pointer' : ''}`}
>
{selectMode ? (
<span
className={`grid h-5 w-5 flex-none place-items-center rounded-full border text-xs text-white ${
selected.has(b.user.user_id) ? 'border-accent bg-accent' : 'border-[#d5d9df] bg-white'
}`}
>
{selected.has(b.user.user_id) ? <Icon name="check" className="text-[14px]" /> : null}
</span>
) : null}
<Avatar
id={b.user.user_id}
name={b.user.display_name}
avatarFileId={b.user.avatar_file_id}
className="h-11 w-11 flex-none rounded-lg text-base grayscale"
/>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<strong className="truncate text-[15px] font-medium text-text-body">{b.user.display_name || b.user.user_id}</strong>
<span className="truncate text-xs text-text-tertiary-2">
拉黑于 {formatEventTimestamp(new Date(b.blocked_at))} · 对方无法向你发送消息或发起通话
</span>
</span>
{selectMode ? null : (
<button
type="button"
onClick={() => unblockMutation.mutate(b.user.user_id)}
disabled={unblockMutation.isPending}
className="h-8 flex-none rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
>
移出
</button>
)}
</div>
))
)}
{items.length ? (
<p className="m-0 mt-2 text-xs text-text-tertiary-3">移出黑名单后恢复通信权限,但不会自动恢复已删除的好友关系。</p>
) : null}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
@@ -0,0 +1,29 @@
import { Outlet, useLocation } from 'react-router-dom';
import { ContactsListColumn } from '@/features/contacts/ContactsListColumn';
/**
* README §5.1's 3-column split (NavRail is AppShell's own, rendered above this): list column always
* docked (unlike messages, contacts has no detail-panel breakpoint to collapse), main area is
* whatever `/contacts/*` sub-route is active. Mirrors MessagesLayout's ChatListColumn+Outlet shape,
* minus the detail-panel/history-modal machinery messages needs and contacts doesn't.
*
* `showListOnNarrow` can't key off a route param the way MessagesLayout keys off `topicId` --
* `/contacts/requests|groups|blocklist` are sibling routes with no param at all, not variants of
* `:userId`, so this checks the exact index path instead.
*/
export function ContactsLayout() {
const location = useLocation();
const showListOnNarrow = location.pathname === '/contacts';
return (
<div className="flex h-full w-full">
<div className={`h-full w-full md:w-auto md:shrink-0 ${showListOnNarrow ? '' : 'hidden md:flex'}`}>
<ContactsListColumn />
</div>
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
<Outlet />
</div>
</div>
);
}
@@ -0,0 +1,173 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
import { fetchAllPages } from '@/lib/api/client';
import { getBlocks, getFriendRequests, getFriends } from '@/lib/api/friends';
const FIXED_ENTRIES = [
{ key: 'requests', route: '/contacts/requests', icon: 'person_add', label: '新朋友', colorClass: 'bg-warn-bg text-warn' },
{ key: 'groups', route: '/contacts/groups', icon: 'groups', label: '群聊', colorClass: 'bg-accent-soft-bg text-accent' },
{ key: 'files', route: '/files', icon: 'folder', label: '文件助手', colorClass: 'bg-success-bg text-success' },
{ key: 'blocklist', route: '/contacts/blocklist', icon: 'block', label: '黑名单', colorClass: 'bg-[#f3efff] text-[#7c5cf6]' },
// README §5.7: mobile-only -- desktop already reaches this via ChatList's own pinned row (§2.1),
// §5.1's desktop entry list doesn't include it.
{ key: 'system', route: '/messages/system', icon: 'notifications', label: '系统消息', colorClass: 'bg-warn-bg text-warn', mobileOnly: true },
] as const;
/** Server-computed `initial` (doc line 1417) sorts CJK names correctly without a client-side pinyin
* library; falls back to the display name's own first character for the rare item missing one. */
function groupInitial(name: string, initial: string | undefined): string {
const raw = (initial || name || '').trim().charAt(0).toUpperCase();
return /[A-Z]/.test(raw) ? raw : '#';
}
/**
* README §5.1 (320px): search + 4 fixed entries (新朋友/群聊/文件助手/黑名单) + letter-grouped
* friend list + a right-side letter index. Friend list is fetched once (fetchAllPages) and both
* grouped and searched client-side, same convention ChatListColumn already uses for conversations
* -- friend lists are the same small-to-medium scale, no reason to hit the server per keystroke.
*/
export function ContactsListColumn() {
const { userId } = useParams();
const [query, setQuery] = useState('');
const [addingFriend, setAddingFriend] = useState(false);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
// Unlike friends/blocks (both already fetchAllPages), this used to read only page 1 -- the request
// COUNT BADGE would silently undercount for any account with more than one page of pending requests.
const requestsQuery = useQuery({
queryKey: ['friend-requests', 'inbound', 'pending'],
queryFn: () => fetchAllPages((cursor) => getFriendRequests('inbound', 'pending', cursor)),
});
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const trimmedQuery = query.trim().toLowerCase();
const friends = friendsQuery.data || [];
const filtered = trimmedQuery ? friends.filter((f) => (f.remark || f.display_name || f.user_id).toLowerCase().includes(trimmedQuery)) : friends;
const groups = useMemo(() => {
const byLetter = new Map<string, typeof filtered>();
for (const f of [...filtered].sort((a, b) => (a.remark || a.display_name || '').localeCompare(b.remark || b.display_name || '', 'zh'))) {
const letter = groupInitial(f.remark || f.display_name || f.user_id, f.initial);
byLetter.set(letter, [...(byLetter.get(letter) || []), f]);
}
return [...byLetter.entries()].sort(([a], [b]) => (a === '#' ? 1 : b === '#' ? -1 : a.localeCompare(b)));
}, [filtered]);
const pendingRequestCount = requestsQuery.data?.length ?? 0;
return (
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">通讯录</h1>
<button type="button" onClick={() => setAddingFriend(true)} className="grid h-8 w-8 place-items-center text-text-heading">
<Icon name="person_add_alt" />
</button>
</header>
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索联系人"
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
/>
</div>
<div className="relative flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto">
{trimmedQuery ? null : (
<>
{FIXED_ENTRIES.map((entry) => {
const badge =
entry.key === 'requests'
? pendingRequestCount
: entry.key === 'blocklist'
? (blocksQuery.data?.length ?? 0)
: 0;
return (
<Link
key={entry.key}
to={entry.route}
className={`flex h-13 flex-none items-center gap-3 border-b border-border-list-item px-4 no-underline hover:bg-[#f6f8fb] ${
'mobileOnly' in entry && entry.mobileOnly ? 'md:hidden' : ''
}`}
>
{/* README §5.7: mobile fixed-entry icons/text are one size up from desktop's
36px/15px (§5.1) -- unconditional desktop sizing before this fix. */}
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg md:h-9 md:w-9 ${entry.colorClass}`}>
<Icon name={entry.icon} className="text-xl" />
</span>
<strong className="flex-1 truncate text-base font-medium text-[#272b31] md:text-[15px]">{entry.label}</strong>
{entry.key === 'requests' && badge ? (
<b className="grid h-[18px] min-w-[18px] flex-none place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{badge}
</b>
) : entry.key === 'groups' ? (
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
) : entry.key === 'blocklist' && badge ? (
<span className="flex-none text-xs text-text-tertiary-3">{badge}</span>
) : null}
</Link>
);
})}
</>
)}
{friendsQuery.isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
) : filtered.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
<Icon name={trimmedQuery ? 'search_off' : 'people_outline'} className="!text-4xl" />
<span className="text-sm">{trimmedQuery ? '未找到相关联系人' : '暂无联系人'}</span>
</div>
) : (
groups.map(([letter, items]) => (
<div key={letter} id={`contact-letter-${letter}`}>
<div className="bg-[#fafbfc] px-4 pb-1 pt-3.5 text-xs text-text-tertiary-2">{letter}</div>
{items.map((f) => {
const name = f.remark || f.display_name || f.user_id;
const active = userId === f.user_id;
return (
<Link
key={f.user_id}
to={`/contacts/${f.user_id}`}
className={`flex h-13.5 flex-none items-center gap-3 px-4 no-underline ${active ? 'bg-accent-selected-bg' : 'hover:bg-[#f6f8fb]'}`}
>
{/* README §5.7: mobile friend-row avatar/text are 44px/16px, one size up from
desktop's 40px/15px (§5.1) -- unconditional desktop sizing before this fix. */}
<Avatar id={f.user_id} name={name} avatarFileId={f.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base md:h-10 md:w-10 md:text-sm" />
<span className="truncate text-base text-[#272b31] md:text-[15px]">{name}</span>
</Link>
);
})}
</div>
))
)}
</div>
{trimmedQuery || groups.length === 0 ? null : (
<div className="flex w-4 flex-none flex-col items-center justify-center gap-0.5 py-2">
{groups.map(([letter]) => (
<a
key={letter}
href={`#contact-letter-${letter}`}
onClick={(e) => {
e.preventDefault();
document.getElementById(`contact-letter-${letter}`)?.scrollIntoView({ block: 'start' });
}}
className="text-[10px] leading-[14px] text-text-tertiary-2 hover:font-semibold hover:text-accent"
>
{letter}
</a>
))}
</div>
)}
</div>
{addingFriend ? <AddFriendModal onClose={() => setAddingFriend(false)} /> : null}
</aside>
);
}
@@ -0,0 +1,10 @@
import { Icon } from '@/app/Icon';
export function EmptyContactsMain() {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-text-tertiary-4">
<Icon name="person_search" className="!text-5xl" />
<span className="text-sm">选择一位联系人查看资料</span>
</div>
);
}
@@ -0,0 +1,249 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
import { ImApiError } from '@/lib/api/client';
import { getUserProfile, updateFriendRemark } from '@/lib/api/friends';
import { useCallStore } from '@/stores/calls';
const GENDER_LABEL: Record<string, string> = { male: '男', female: '女' };
/**
* README §5.2 (440px centered card). `getUserProfile` (doc line 1328/1425) has no field describing
* HOW two people became friends -- the design's "来源:通过群聊「项目协作群」添加" row has nothing
* to read it off of, so it's left out rather than fabricated; every other row maps to a real field.
*/
export function FriendProfileView() {
const { userId } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [editingRemark, setEditingRemark] = useState(false);
const [remarkDraft, setRemarkDraft] = useState('');
const [error, setError] = useState('');
const [addingFriend, setAddingFriend] = useState(false);
const [showQrCode, setShowQrCode] = useState(false);
const query = useQuery({
queryKey: ['user-profile', userId],
queryFn: () => getUserProfile(userId || ''),
enabled: !!userId,
});
const remarkMutation = useMutation({
mutationFn: (remark: string) => updateFriendRemark(userId || '', remark),
onSuccess: (_data, remark) => {
queryClient.setQueryData(['user-profile', userId], (prev: typeof query.data) => (prev ? { ...prev, remark } : prev));
queryClient.invalidateQueries({ queryKey: ['friends'] });
setEditingRemark(false);
},
onError: (err) => setError(err instanceof Error ? err.message : '备注保存失败'),
});
// `/contacts/:userId` is a route only reachable via a route change (list item, search result),
// never a param variant of the index -- so ContactsLayout's showListOnNarrow hides the list here
// just like every other narrow-viewport sub-route, and this page needs the same back affordance
// GroupListView/NewFriendsView/BlocklistView have in their own header. This one has no header bar
// at any state (loading/not-found/loaded), so the link is repeated across all three returns.
const backLink = (
<Link to="/contacts" className="absolute left-4 top-4 z-10 grid h-9.5 w-9.5 place-items-center rounded-full bg-white/70 text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
);
if (!userId) return null;
if (query.isLoading) {
return (
<div className="relative flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
{backLink}
加载中…
</div>
);
}
const profile = query.data;
if (!profile) {
// Confirmed live 2026-09-09: a 403 here comes back with reason PERMISSION_DENIED and message
// "profile is private" -- a real privacy setting on the other side, not a missing/deleted
// account. Collapsing it into "未找到该用户" would misrepresent an intentional privacy choice
// as if the user doesn't exist; every other failure (404, network error) keeps that fallback.
const isPrivate = query.error instanceof ImApiError && query.error.status === 403;
return (
<div className="relative flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
{backLink}
{isPrivate ? '对方设置了资料保护,暂时无法查看' : '未找到该用户'}
</div>
);
}
const isFriend = profile.relationship === 'friend';
// docs/IM-前后端对接文档.md:1289's real enum is direction-specific -- `isFriend` alone used to
// collapse BOTH pending states into "not a friend yet", which fell through to the generic "添加
// 好友" button below and let the user re-open AddFriendModal and resubmit a request that's already
// outstanding either direction.
const isPendingOutbound = profile.relationship === 'pending_outbound';
const isPendingInbound = profile.relationship === 'pending_inbound';
const name = profile.remark || profile.display_name;
const metaParts = [profile.display_name ? `昵称:${profile.display_name}` : null, profile.gender ? GENDER_LABEL[profile.gender] || profile.gender : null, profile.region]
.filter(Boolean)
.join(' · ');
// README §5.2 (desktop, 44px) vs §5.7 (mobile, 48px) want the SAME three actions at two different
// sizes in two different places (inline in the card vs a fixed bottom bar, see below) -- one
// function instead of duplicating the branching/handlers so there's exactly one behavior to get
// right, not two copies that can drift.
const actionButtons = (squareBtnClass: string, iconSizeClass: string) =>
isFriend ? (
<>
<button
type="button"
// `profile.user_id`, not `profile.topic` -- confirmed live (see favorites.ts's own
// finding) that this API's `topic` field for a P2P relationship is the server's
// internal `p2p...`-hashed form, which this app's chat list/router never uses;
// every P2P conversation elsewhere in this app is addressed by the peer's own
// `usr...` id, which Tinode resolves to the same real topic on the wire.
onClick={() => navigate(`/messages/${profile.user_id}`)}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm ${squareBtnClass.split(' ')[0]}`}
>
<Icon name="chat_bubble" className={iconSizeClass} />
发消息
</button>
<button
type="button"
disabled={profile.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(profile.user_id, name, 'audio');
}}
className={`grid flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50 ${squareBtnClass}`}
>
<Icon name="call" />
</button>
<button
type="button"
disabled={profile.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(profile.user_id, name, 'video');
}}
className={`grid flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50 ${squareBtnClass}`}
>
<Icon name="videocam" />
</button>
</>
) : isPendingOutbound ? (
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
等待验证
</button>
) : isPendingInbound ? (
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
对方已申请加你
</button>
) : profile.capabilities?.can_add_friend === false ? (
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
对方暂不可添加
</button>
) : (
<button
type="button"
onClick={() => setAddingFriend(true)}
className={`flex-1 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm ${squareBtnClass.split(' ')[0]}`}
>
添加好友
</button>
);
return (
<div className="relative flex h-full w-full items-center-safe justify-center overflow-y-auto bg-surface-content md:py-12">
{backLink}
<div className="w-full flex-none overflow-hidden bg-white shadow-[0_8px_32px_rgba(35,74,125,0.08)] md:w-[440px] md:rounded-2xl">
<div className="h-24 bg-gradient-to-br from-[#e8f1fe] to-[#f3efff]" />
<div className="flex flex-col items-center px-6 pb-24 md:pb-6">
<Avatar
id={profile.user_id}
name={name}
avatarFileId={profile.avatar_file_id}
className="-mt-11 h-22 w-22 flex-none rounded-[20px] border-4 border-white text-2xl"
/>
<strong className="mt-3 text-xl font-semibold text-text-heading">{name}</strong>
{metaParts ? <span className="mt-1 text-[13px] text-text-tertiary-2">{metaParts}</span> : null}
{profile.public_account ? <span className="text-[13px] text-text-tertiary-2">ID: {profile.public_account}</span> : null}
{profile.signature ? <p className="m-0 mt-2 text-sm text-text-secondary">{profile.signature}</p> : null}
{/* README §5.7: mobile pins this row OUTSIDE the scroll area (bottom-fixed), so it stays
reachable regardless of profile length -- this hidden copy keeps the original inline
desktop placement exactly as before; the real mobile copy is the fixed bar rendered
after the card below. Both call the same handlers, so there is exactly one behavior. */}
<div className="mt-5 hidden w-full items-center gap-2 md:flex">{actionButtons('h-11 w-11', 'text-[18px]')}</div>
{isFriend ? (
<div className="mt-4 flex w-full flex-col divide-y divide-border-list-item border-t border-border-list-item text-sm">
{editingRemark ? (
<div className="flex items-center gap-2 py-3">
<span className="flex-none text-text-body">备注</span>
<input
autoFocus
value={remarkDraft}
onChange={(e) => setRemarkDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && remarkMutation.mutate(remarkDraft.trim())}
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
/>
<button type="button" onClick={() => remarkMutation.mutate(remarkDraft.trim())} className="flex-none text-accent">
保存
</button>
</div>
) : (
<button
type="button"
onClick={() => {
setRemarkDraft(profile.remark || '');
setEditingRemark(true);
}}
className="flex justify-between py-3 text-left"
>
<span className="text-text-body">备注</span>
<span className="flex items-center gap-0.5 text-text-tertiary-2">
{profile.remark || '未设置'}
<Icon name="chevron_right" className="text-[18px]" />
</span>
</button>
)}
<button type="button" onClick={() => setShowQrCode(true)} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">二维码名片</span>
<Icon name="qr_code_2" className="text-[20px] text-text-tertiary-2" />
</button>
</div>
) : null}
</div>
</div>
{/* Mobile-only fixed bottom bar (mock `04/1e`: `padding:12px 16px 34px`, 48px buttons) -- the
real mobile counterpart to the hidden desktop copy above, pinned to the viewport bottom
(not the card) so it stays reachable no matter how far the profile content scrolls.
`bottom-[82px] sm:bottom-0`: `position:fixed` resolves against the viewport, not this
route's own Outlet box, and below 640px the viewport bottom is where AppShell's persistent
BottomTabBar (82px) already lives -- an unqualified `bottom-0` sat directly on top of it,
hiding the tab bar entirely while this page was open. `sm:bottom-0` restores flush-bottom
once the tab bar is gone (>=640px, NavRail shown on the side instead). */}
<div className="fixed inset-x-0 bottom-[82px] z-10 flex gap-3 bg-white px-4 pb-[34px] pt-3 sm:bottom-0 md:hidden">
{actionButtons('h-12 w-12', 'text-xl')}
</div>
{showQrCode && userId ? <QRCodeModal identifier={userId} onClose={() => setShowQrCode(false)} /> : null}
{addingFriend ? (
<AddFriendModal
onClose={() => setAddingFriend(false)}
initialTarget={{
user_id: profile.user_id,
display_name: profile.display_name,
public_account: profile.public_account,
avatar_file_id: profile.avatar_file_id,
}}
/>
) : null}
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
import { fetchAllPages } from '@/lib/api/client';
import { getGroups, type GroupRole } from '@/lib/api/groups';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
type Tab = 'all' | 'owner' | 'admin' | 'member';
const TABS: { key: Tab; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'owner', label: '我创建的' },
{ key: 'admin', label: '我管理的' },
{ key: 'member', label: '我加入的' },
];
/** README §5.5. Fetched once (category='', i.e. every group) and tabbed client-side by `my_role`
* rather than hitting `getGroups`'s own owned/managed/joined category filters three more times --
* same total data, one request instead of four. */
export function GroupListView() {
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>('all');
const [creatingGroup, setCreatingGroup] = useState(false);
// Only page 1 used to be fetched -- an account with more than one page of groups would silently
// undercount every tab (including "全部") and never list the rest.
const query = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) });
const groups = query.data || [];
const counts: Record<Tab, number> = {
all: groups.length,
owner: groups.filter((g) => g.my_role === 'owner').length,
admin: groups.filter((g) => g.my_role === 'admin').length,
member: groups.filter((g) => g.my_role === 'member').length,
};
const filtered = tab === 'all' ? groups : groups.filter((g) => g.my_role === tab);
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
<span className="flex items-center gap-1">
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<strong className="text-base font-semibold text-text-heading">群聊</strong>
</span>
<button type="button" onClick={() => setCreatingGroup(true)} className="flex items-center gap-1 text-sm text-accent">
<Icon name="add" className="text-[18px]" />
创建群聊
</button>
</header>
<div className="flex flex-none items-center gap-6 border-b border-border-panel bg-white px-6">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`h-11 border-b-2 text-sm ${tab === t.key ? 'border-accent font-medium text-accent' : 'border-transparent text-text-secondary'}`}
>
{t.label} {counts[t.key]}
</button>
))}
</div>
<div className="flex flex-col gap-2.5 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : filtered.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群聊</div>
) : (
filtered.map((g) => (
<button
key={g.topic}
type="button"
onClick={() => navigate(`/messages/${g.topic}`)}
className="flex items-center gap-3 rounded-xl bg-white px-4 py-3 text-left shadow-bubble"
>
<Avatar id={g.topic} name={g.name} avatarFileId={g.avatar_file_id} className="h-11 w-11 flex-none rounded-[10px] text-base" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center gap-1.5">
<strong className="truncate text-[15px] font-medium text-text-body">{g.name}</strong>
{g.my_role !== 'member' ? (
<span
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${g.my_role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'}`}
>
{ROLE_LABEL[g.my_role]}
</span>
) : null}
</span>
<span className="truncate text-xs text-text-tertiary-2">
{g.member_count} 人 · 群号 {g.topic}
</span>
</span>
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
</button>
))
)}
</div>
{creatingGroup ? (
<CreateGroupModal
onClose={() => setCreatingGroup(false)}
onCreated={(topic) => {
setCreatingGroup(false);
navigate(`/messages/${topic}`);
}}
/>
) : null}
</div>
);
}
@@ -0,0 +1,122 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
import { fetchAllPages } from '@/lib/api/client';
import { acceptFriendRequest, getFriendRequests, rejectFriendRequest, type FriendRequest } from '@/lib/api/friends';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加', phone: '通过手机号搜索' };
/** README §5.3: both pending (拒绝/通过验证 buttons) and already-handled (灰字"已通过/已拒绝")
* inbound requests show together in one list -- `getFriendRequests('inbound', 'all')` matches
* that, not a pending-only fetch. */
export function NewFriendsView() {
const queryClient = useQueryClient();
const [addingFriend, setAddingFriend] = useState(false);
const [error, setError] = useState('');
const [handled, setHandled] = useState<Record<string, 'accepted' | 'rejected'>>({});
// Only page 1 used to be fetched -- an account with more than one page of historical requests
// would silently never show its older ones.
const query = useQuery({
queryKey: ['friend-requests', 'all'],
queryFn: () => fetchAllPages((cursor) => getFriendRequests('inbound', 'all', cursor)),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
queryClient.invalidateQueries({ queryKey: ['friends'] });
};
const acceptMutation = useMutation({
mutationFn: (req: FriendRequest) => acceptFriendRequest(req.request_id),
onSuccess: (_data, req) => {
setHandled((prev) => ({ ...prev, [req.request_id]: 'accepted' }));
invalidate();
},
onError: (err) => setError(err instanceof Error ? err.message : '操作失败'),
});
const rejectMutation = useMutation({
mutationFn: (req: FriendRequest) => rejectFriendRequest(req.request_id),
onSuccess: (_data, req) => {
setHandled((prev) => ({ ...prev, [req.request_id]: 'rejected' }));
invalidate();
},
onError: (err) => setError(err instanceof Error ? err.message : '操作失败'),
});
const items = query.data || [];
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
<span className="flex items-center gap-1">
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<strong className="text-base font-semibold text-text-heading">新朋友</strong>
</span>
<button type="button" onClick={() => setAddingFriend(true)} className="text-sm text-accent">
添加好友
</button>
</header>
<div className="flex flex-col gap-3 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无新朋友申请</div>
) : (
items.map((req) => {
const status = handled[req.request_id] || (req.status !== 'pending' ? (req.status === 'accepted' ? 'accepted' : 'rejected') : null);
const name = req.peer?.display_name || req.peer?.user_id || req.applicant_id;
return (
<div key={req.request_id} className="flex items-center gap-3 rounded-xl bg-white px-4.5 py-3.5 shadow-bubble">
<span className={`grid h-12 w-12 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(req.applicant_id)}`}>
{avatarLetter(name)}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<strong className="truncate text-[15px] font-medium text-text-body">{name}</strong>
{req.message ? <span className="truncate text-[13px] text-text-secondary">{req.message}</span> : null}
<span className="truncate text-xs text-text-tertiary-3">
{formatEventTimestamp(new Date(req.created_at))}
{req.source ? ` · ${SOURCE_LABEL[req.source] || req.source}` : ''}
</span>
</span>
{status ? (
<span className="flex-none text-xs text-text-tertiary-3">{status === 'accepted' ? '已通过' : '已拒绝'}</span>
) : (
<span className="flex flex-none items-center gap-2">
<button
type="button"
onClick={() => rejectMutation.mutate(req)}
disabled={acceptMutation.isPending || rejectMutation.isPending}
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
>
拒绝
</button>
<button
type="button"
onClick={() => acceptMutation.mutate(req)}
disabled={acceptMutation.isPending || rejectMutation.isPending}
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
>
通过验证
</button>
</span>
)}
</div>
);
})
)}
</div>
{addingFriend ? <AddFriendModal onClose={() => setAddingFriend(false)} /> : null}
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { createGroupQRCode, createMyQRCode, createUserQRCode, type GroupQRCode, type MyQRCode } from '@/lib/api/qrcodes';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
interface QRCodeModalProps {
/** Omit both to show the CURRENT user's own code; pass a friend's user_id/account to show theirs
* (README's "二维码名片" row on a friend's profile). */
identifier?: string;
/** 03-group.dc.html #1a's `qr_code_2` icon on the group-info card (admin/owner-visible group-join
* code) -- mutually exclusive with `identifier`, a group is never also a user. */
groupTopic?: string;
onClose: () => void;
}
/**
* README §5.4/§8.2's "我的二维码"/"二维码名片", plus the group-join code (03-group.dc.html #1a). The
* QR image encodes `qr_payload` (`im://qr?token=...&purpose=add_friend|join_group`), never a raw
* user/group id -- confirmed live for the personal-code path that this is a real, working backend
* feature (`POST /me/qrcodes` / `POST /users/{id}/qrcodes`), not a documented-but-unbuilt one; the
* group-code path (`POST /groups/{topic}/qrcodes`) is the same documented shape (docs/IM-前后端对接
* 文档.md:1590,1737) but not yet live-verified against a real deployment. Re-mints a fresh token on
* every mount (POST semantics, not cached) since each one is short-lived (~24h) and single-purpose.
*/
export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProps) {
useEscapeKey(onClose);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [drawError, setDrawError] = useState('');
const query = useQuery<MyQRCode | GroupQRCode>({
queryKey: ['qrcode', groupTopic ? `group:${groupTopic}` : identifier || 'me'],
queryFn: (): Promise<MyQRCode | GroupQRCode> => (groupTopic ? createGroupQRCode(groupTopic) : identifier ? createUserQRCode(identifier) : createMyQRCode()),
});
useEffect(() => {
if (!query.data || !canvasRef.current) return;
const canvas = canvasRef.current;
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-generating library
// isn't in every visitor's main bundle when most sessions never open this modal.
import('qrcode')
.then(({ default: QRCode }) => QRCode.toCanvas(canvas, query.data.qr_payload, { width: 200, margin: 1, color: { dark: '#22262d', light: '#ffffff' } }))
.catch(() => setDrawError('二维码生成失败'));
}, [query.data]);
const profile = query.data && 'display_profile' in query.data ? query.data.display_profile : undefined;
const group = query.data && 'group_snapshot' in query.data ? query.data.group_snapshot : undefined;
const title = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
const fileBaseName = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
const caption = groupTopic ? '扫一扫上面的二维码,申请加入群聊' : '扫一扫上面的二维码,加我为好友';
const canvasToBlob = (): Promise<Blob | null> => new Promise((resolve) => canvasRef.current?.toBlob((b) => resolve(b), 'image/png'));
const saveToAlbum = async () => {
const blob = await canvasToBlob();
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${fileBaseName}.png`;
link.click();
URL.revokeObjectURL(url);
};
const shareToFriend = async () => {
const blob = await canvasToBlob();
if (!blob) return;
const file = new File([blob], 'qrcode.png', { type: 'image/png' });
// Real capability check, not just feature-detecting `navigator.share` itself -- some browsers
// implement `share()` for URLs/text only and reject a `files` payload at call time, so
// `canShare({ files })` is the one call that actually answers "can this share an image".
if (navigator.canShare?.({ files: [file] })) {
try {
await navigator.share({ files: [file], title: fileBaseName });
} catch {
// AbortError (user cancelled the native share sheet) or any other failure -- either way
// there's nothing useful to surface, the share sheet's own UI already communicated the outcome.
}
} else {
await saveToAlbum();
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex w-[360px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-13 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">{title}</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex flex-col items-center gap-3 px-6 py-7">
{profile ? (
<>
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(profile.user_id)}`}>
{avatarLetter(profile.display_name)}
</span>
<strong className="text-base font-semibold text-text-heading">{profile.display_name}</strong>
{profile.public_account ? <span className="text-xs text-text-tertiary-2">ID:{profile.public_account}</span> : null}
</>
) : group ? (
<>
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(group.topic)}`}>
{avatarLetter(group.name)}
</span>
<strong className="text-base font-semibold text-text-heading">{group.name}</strong>
<span className="text-xs text-text-tertiary-2">{group.member_count} 人</span>
</>
) : null}
<div className="mt-2 grid h-[220px] w-[220px] place-items-center rounded-lg border border-border-panel">
{query.isLoading ? (
<span className="text-sm text-text-tertiary-4">生成中…</span>
) : query.isError || drawError ? (
<div className="flex flex-col items-center gap-2 px-4 text-center">
<Icon name="error_outline" className="text-2xl text-danger-text" />
<span className="text-xs text-text-tertiary-4">{drawError || '二维码生成失败,请重试'}</span>
</div>
) : (
<canvas ref={canvasRef} />
)}
</div>
<span className="text-xs text-text-tertiary-3">{caption}</span>
{/* No mock anywhere in this design handoff draws these two buttons (grepped every
screens/*.dc.html) -- added on the strength of a near-universal QR-display convention
(save/share), not a documented spec. Reuses the `share` icon the add-friend chip
(04-contacts.dc.html:101) already uses for the same verb elsewhere in this app. */}
{query.data && !drawError ? (
<div className="mt-1 flex gap-2">
<button
type="button"
onClick={saveToAlbum}
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
>
<Icon name="download" className="text-[16px]" />
保存到相册
</button>
<button
type="button"
onClick={shareToFriend}
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
>
<Icon name="share" className="text-[16px]" />
分享好友
</button>
</div>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,229 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '@/app/Icon';
import { resolveQRCode, type QRResolveGroup, type QRResolveUser } from '@/lib/api/qrcodes';
import { useEscapeKey } from '@/lib/hooks';
interface QRScannerModalProps {
onClose: () => void;
/** A resolved, genuinely-addable ('none' relationship, can_add) scanned user. */
onResolved: (user: QRResolveUser) => void;
/** Scanned code belongs to someone already a friend -- caller decides what "already added"
* should do (e.g. navigate to their profile) instead of re-showing an add-friend form. */
onAlreadyFriend: (user: QRResolveUser) => void;
/** A `purpose:"join_group"` code resolved successfully and the scanning account is NOT already a
* member -- optional so a caller that only ever scans for the add-friend flow (there is none
* currently, but the type shouldn't force every caller to handle a case it can't reach) doesn't
* have to wire a no-op. `qrToken` is threaded through because the join-request submission itself
* needs to carry it (docs/IM-前后端对接文档.md:1591). */
onGroupResolved?: (group: QRResolveGroup, qrToken: string) => void;
/** Mirrors `onAlreadyFriend` for the group case -- confirmed live that resolving a code for a
* group you're already in comes back `status:"joined"` rather than something requiring a join
* request at all (see qrcodes.ts's QRResolveResult comment). */
onAlreadyMember?: (group: QRResolveGroup) => void;
}
const SCAN_INTERVAL_MS = 250;
/** Old app's own real, working scanner (im_webapp/src/widgets/qr-scanner.jsx) downscales to this
* max edge before decoding -- jsQR's cost scales with pixel count, and a raw 1080p+ camera frame
* decoded every 250ms would be needlessly expensive when a QR code only needs a few hundred px
* per side to read reliably. */
const MAX_FRAME_EDGE = 480;
type ScanState = 'requesting' | 'scanning' | 'resolving' | 'camera-error' | 'resolve-error';
/**
* README §5.4's "扫一扫" -- full-screen camera viewfinder. Ported cognition (not code) from the old
* app's real, working `qr-scanner.jsx` (getUserMedia -> draw each frame to an offscreen canvas ->
* jsQR) and `tinode-web.jsx`'s `handleQrDetected` (reject anything not matching the app's own
* `im://qr?token=...&purpose=...` scheme client-side before ever sending it to `resolve`, so a
* generic URL/WiFi/whatever QR code someone points this at is never even round-tripped).
*/
export function QRScannerModal({ onClose, onResolved, onAlreadyFriend, onGroupResolved, onAlreadyMember }: QRScannerModalProps) {
useEscapeKey(onClose);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const resolvingRef = useRef(false);
const mountedRef = useRef(true);
const [state, setState] = useState<ScanState>('requesting');
const [errorMessage, setErrorMessage] = useState('');
const stopCamera = () => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
useEffect(
() => () => {
mountedRef.current = false;
},
[],
);
const handleDetected = async (text: string) => {
if (resolvingRef.current) return;
// Only this app's own generated codes resolve to anything -- a generic URL/WiFi/contact QR is
// rejected client-side rather than round-tripped to the server for a guaranteed-useless lookup.
if (!text.startsWith('im://qr?')) return;
const params = new URLSearchParams(text.slice('im://qr?'.length));
const token = params.get('token');
const purpose = params.get('purpose') || 'add_friend';
if (!token) return;
resolvingRef.current = true;
setState('resolving');
try {
const result = await resolveQRCode(token, purpose);
// The modal may have been closed (or the user's intent otherwise moved on) while this request
// was in flight -- applying its result now would redirect/populate state the user never asked
// for after they believed they'd already cancelled.
if (!mountedRef.current) return;
if (purpose === 'join_group') {
// See qrcodes.ts's own QRResolveGroup comment -- confirmed live this nests under `group`
// (group_snapshot checked too as a defensive fallback, not because it's expected to fire).
const group = result.group || result.group_snapshot;
if (!group || !onGroupResolved) {
// Same leak as the two other resolve-error branches below: without this, the scan loop and
// camera stream both kept running underneath the full-screen error card, so the SAME
// unsupported code re-triggered `resolveQRCode` every ~250ms indefinitely, and clicking
// "重试" then started a second stream/interval on top without ever stopping this one.
stopCamera();
setErrorMessage('暂不支持此类二维码');
setState('resolve-error');
resolvingRef.current = false;
return;
}
stopCamera();
if (result.status === 'joined' && onAlreadyMember) onAlreadyMember(group);
else onGroupResolved(group, token);
return;
}
if (purpose !== 'add_friend' || !result.user) {
stopCamera();
setErrorMessage('暂不支持此类二维码');
setState('resolve-error');
resolvingRef.current = false;
return;
}
stopCamera();
if (result.user.relationship === 'friend') {
onAlreadyFriend(result.user);
} else if (!result.user.can_add) {
setErrorMessage('无法添加该用户为好友');
setState('resolve-error');
resolvingRef.current = false;
} else {
onResolved(result.user);
}
} catch (err) {
if (!mountedRef.current) return;
stopCamera();
setErrorMessage(err instanceof Error ? err.message : '二维码已失效');
setState('resolve-error');
resolvingRef.current = false;
}
};
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-decoding library isn't
// in every visitor's main bundle when most sessions never open the scanner.
const startScanLoop = async () => {
const canvas = canvasRef.current;
const video = videoRef.current;
if (!canvas || !video) return;
const { default: jsQR } = await import('jsqr');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
timerRef.current = window.setInterval(() => {
if (!ctx || video.readyState !== video.HAVE_ENOUGH_DATA || resolvingRef.current) return;
const scale = Math.min(1, MAX_FRAME_EDGE / Math.max(video.videoWidth, video.videoHeight));
const w = Math.max(1, Math.round(video.videoWidth * scale));
const h = Math.max(1, Math.round(video.videoHeight * scale));
canvas.width = w;
canvas.height = h;
ctx.drawImage(video, 0, 0, w, h);
const frame = ctx.getImageData(0, 0, w, h);
const code = jsQR(frame.data, w, h, { inversionAttempts: 'dontInvert' });
if (code?.data) handleDetected(code.data);
}, SCAN_INTERVAL_MS);
};
// React 18 StrictMode (dev only) runs mount -> cleanup -> mount synchronously, which starts TWO
// overlapping getUserMedia() calls; without this generation token, whichever resolves last wins
// and clobbers the other's streamRef/timerRef, leaking the loser's MediaStream (never .stop()'d)
// and its setInterval. Matches the same-class guard already used in tinode/connection.ts/topics.ts.
const requestGenerationRef = useRef(0);
const requestCamera = async () => {
const myGeneration = ++requestGenerationRef.current;
setState('requesting');
setErrorMessage('');
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false });
if (myGeneration !== requestGenerationRef.current) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setState('scanning');
startScanLoop();
} catch (err) {
if (myGeneration !== requestGenerationRef.current) return;
setErrorMessage(err instanceof Error ? err.message : '无法访问摄像头');
setState('camera-error');
}
};
useEffect(() => {
requestCamera();
return stopCamera;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const retry = () => {
resolvingRef.current = false;
requestCamera();
};
return (
<div className="fixed inset-0 z-50 flex flex-col bg-black">
<header className="flex h-13 flex-none items-center justify-between px-4 text-white">
<strong className="text-[15px] font-semibold">扫一扫</strong>
<button type="button" onClick={onClose} className="grid h-9 w-9 place-items-center">
<Icon name="close" />
</button>
</header>
<div className="relative flex-1">
<video ref={videoRef} playsInline muted className="h-full w-full object-cover" />
<canvas ref={canvasRef} className="hidden" />
{state === 'scanning' ? (
<div className="absolute inset-0 grid place-items-center">
<div className="h-56 w-56 rounded-2xl border-2 border-white/80" />
</div>
) : null}
{state === 'requesting' || state === 'resolving' ? (
<div className="absolute inset-0 grid place-items-center bg-black/40 text-sm text-white">
{state === 'requesting' ? '正在请求摄像头权限…' : '正在识别…'}
</div>
) : null}
{state === 'camera-error' || state === 'resolve-error' ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/70 px-8 text-center text-white">
<Icon name={state === 'camera-error' ? 'videocam_off' : 'error_outline'} className="text-4xl" />
<span className="text-sm">{state === 'camera-error' ? `无法访问摄像头:${errorMessage}` : errorMessage}</span>
<button type="button" onClick={retry} className="h-9 rounded-lg bg-btn px-5 text-sm font-medium text-white">
重试
</button>
</div>
) : null}
</div>
<p className="flex-none py-4 text-center text-sm text-white/70">将二维码放入框内,即可自动扫描</p>
</div>
);
}