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:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user