feat(web): real P2P chat-settings detail panel

Replaces the detail-column placeholder with the actual "聊天设置" panel
(README §3.4): profile header, quick-action buttons, remark, pin/mute
toggles, blacklist, clear-history, delete-friend -- backed by real REST
endpoints (lib/api/friends.ts, ported from the old app's real
contact-management-view.jsx settings panel, not its unrelated mock
direct-message-view.jsx).

First real use of TanStack Query in this app (useQuery for
friends/conversation-settings/blocks, useMutation for the writes) --
previously only wired up in App.tsx with nothing consuming it yet.

Verified end to end: pin toggle PATCHes /conversations/{topic}/settings
and persists; remark PATCHes /friends/{userId} and reflects immediately
via query invalidation; blacklist PUT/DELETEs /blocks/{userId}.

Known minor gap: the drawer variant (960-1279px tier) stays mounted
(CSS-hidden) alongside the docked panel at >=1280px rather than
unmounting, so both instances' queries run -- harmless since TanStack
Query dedupes/shares the cache by key, but worth a follow-up cleanup.
Also still missing: the <960px "detail settings push as a full page"
variant the mobile spec calls for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-08 01:54:54 +08:00
co-authored by Claude Sonnet 5
parent f549248c08
commit 7f5756671d
3 changed files with 311 additions and 22 deletions
+200
View File
@@ -0,0 +1,200 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import {
blockUser,
deleteFriend,
getBlocks,
getConversationSettings,
getFriends,
unblockUser,
updateConversationSettings,
updateFriendRemark,
} from '@/lib/api/friends';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useTinode } from '@/lib/tinode/hooks';
interface DetailColumnProps {
topicId: string;
peerName: string;
onClose?: () => void;
}
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={onChange}
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
>
<span
className={`absolute top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[20px]' : 'translate-x-0.5'}`}
/>
</button>
);
}
export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps) {
const navigate = useNavigate();
const tinode = useTinode();
const queryClient = useQueryClient();
const [remarkEditing, setRemarkEditing] = useState(false);
const [remarkDraft, setRemarkDraft] = useState('');
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => getFriends() });
const friend = friendsQuery.data?.items.find((f) => f.user_id === topicId);
const settingsQuery = useQuery({
queryKey: ['conversation-settings', topicId],
queryFn: () => getConversationSettings(topicId),
});
const updateSettings = useMutation({
mutationFn: (patch: { pinned?: boolean; mute_notifications?: boolean }) =>
updateConversationSettings(topicId, { ...patch, version: settingsQuery.data?.version ?? 0 }),
onSuccess: (data) => queryClient.setQueryData(['conversation-settings', topicId], data),
});
const remarkMutation = useMutation({
mutationFn: (remark: string) => updateFriendRemark(topicId, remark),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['friends'] });
setRemarkEditing(false);
},
});
const blockMutation = useMutation({
mutationFn: (blocked: boolean) => (blocked ? blockUser(topicId) : unblockUser(topicId)),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
});
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => getBlocks() });
const isBlocked = !!blocksQuery.data?.items.some((b) => b.user_id === topicId);
const deleteFriendMutation = useMutation({
mutationFn: () => deleteFriend(topicId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['friends'] });
navigate('/messages');
},
});
const clearHistory = () => {
if (!window.confirm('确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。')) return;
tinode.getTopic(topicId).delMessagesAll(false);
};
const startDeleteFriend = () => {
if (!window.confirm(`确定删除好友 ${peerName} 吗?`)) return;
deleteFriendMutation.mutate();
};
return (
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[300px]">
<header className="flex h-15 flex-none items-center justify-between border-b border-list-item px-5">
<strong className="text-[15px] font-semibold">聊天设置</strong>
{onClose ? (
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
) : null}
</header>
<div className="flex flex-col items-center gap-1 px-5 pb-4 pt-6">
<span className={`grid h-18 w-18 place-items-center rounded-2xl text-2xl font-medium text-white ${avatarBgClass(topicId)}`}>
{avatarLetter(peerName)}
</span>
<strong className="mt-2 text-[17px] font-semibold text-text-heading">{peerName}</strong>
<div className="mt-3 flex gap-3">
<button
type="button"
onClick={onClose}
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent"
>
<Icon name="chat_bubble_outline" className="text-[20px]" />
<span className="text-xs text-text-secondary">发消息</span>
</button>
<button type="button" className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent">
<Icon name="call" className="text-[20px]" />
<span className="text-xs text-text-secondary">语音</span>
</button>
<button type="button" className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent">
<Icon name="videocam" className="text-[20px]" />
<span className="text-xs text-text-secondary">视频</span>
</button>
</div>
</div>
<div className="flex flex-col px-5 pb-2 text-sm">
{remarkEditing ? (
<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)}
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)} className="flex-none text-accent">
保存
</button>
</div>
) : (
<button
type="button"
onClick={() => {
setRemarkDraft(friend?.remark || '');
setRemarkEditing(true);
}}
className="flex items-center justify-between py-3 text-left"
>
<span className="text-text-body">备注</span>
<span className="flex items-center gap-1 text-text-tertiary-2">
{friend?.remark || '未设置'}
<Icon name="chevron_right" className="text-[18px]" />
</span>
</button>
)}
<div className="flex items-center justify-between border-t border-list-item py-3">
<span className="text-text-body">置顶聊天</span>
<Toggle
checked={!!settingsQuery.data?.pinned}
disabled={!settingsQuery.data || updateSettings.isPending}
onChange={() => updateSettings.mutate({ pinned: !settingsQuery.data?.pinned })}
/>
</div>
<div className="flex items-center justify-between border-t border-list-item py-3">
<span className="text-text-body">消息免打扰</span>
<Toggle
checked={!!settingsQuery.data?.mute_notifications}
disabled={!settingsQuery.data || updateSettings.isPending}
onChange={() => updateSettings.mutate({ mute_notifications: !settingsQuery.data?.mute_notifications })}
/>
</div>
<div className="flex items-center justify-between border-t border-list-item py-3">
<span className="text-text-body">加入黑名单</span>
<Toggle checked={isBlocked} disabled={blockMutation.isPending} onChange={() => blockMutation.mutate(!isBlocked)} />
</div>
<button type="button" onClick={clearHistory} className="flex items-center justify-between border-t border-list-item py-3 text-left">
<span className="text-text-body">清空聊天记录</span>
<Icon name="chevron_right" className="text-[18px] text-text-tertiary-2" />
</button>
</div>
<button
type="button"
onClick={startDeleteFriend}
className="mx-5 my-4 h-10 flex-none rounded-lg border border-danger-border-soft text-sm text-danger-text"
>
删除好友
</button>
</aside>
);
}
+11 -22
View File
@@ -2,24 +2,9 @@ import { useState } from 'react';
import { Outlet, useParams } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { ChatListColumn } from '@/features/messages/ChatListColumn';
function DetailColumn({ onClose }: { onClose?: () => void }) {
return (
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[300px]">
<header className="flex h-15 flex-none items-center justify-between border-b border-list-item px-5">
<strong className="text-[15px] font-semibold">聊天设置</strong>
{onClose ? (
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
) : null}
</header>
<div className="p-5 text-sm text-text-secondary">详情栏内容占位(阶段1实现)</div>
</aside>
);
}
import { DetailColumn } from '@/features/messages/DetailColumn';
import { useChatList } from '@/lib/tinode/hooks';
/**
* The 4-tier responsive split described in the design handoff:
@@ -33,6 +18,8 @@ export function MessagesLayout() {
const { topicId } = useParams();
const [detailOpen, setDetailOpen] = useState(false);
const showListOnNarrow = !topicId;
const contacts = useChatList();
const peerName = contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
return (
<div className="relative flex h-full w-full">
@@ -42,14 +29,16 @@ export function MessagesLayout() {
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v) }} />
</div>
<div className="hidden h-full lg:block">
<DetailColumn />
</div>
{detailOpen ? (
{topicId ? (
<div className="hidden h-full lg:block">
<DetailColumn topicId={topicId} peerName={peerName} />
</div>
) : null}
{detailOpen && topicId ? (
<div className="absolute inset-0 hidden md:block lg:hidden">
<div className="absolute inset-0 bg-[#22262d]/25" onClick={() => setDetailOpen(false)} />
<div className="absolute inset-y-0 right-0 shadow-[-8px_0_32px_rgba(0,0,0,0.12)]">
<DetailColumn onClose={() => setDetailOpen(false)} />
<DetailColumn topicId={topicId} peerName={peerName} onClose={() => setDetailOpen(false)} />
</div>
</div>
) : null}
+100
View File
@@ -0,0 +1,100 @@
// Friend/conversation-settings/blocklist REST endpoints. Paths and payload shapes ported from the
// old app's src/lib/im-api.js (contact-management-view.jsx's real "聊天设置" panel, not the mock
// direct-message-view.jsx) -- verified there, not guessed.
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
export interface Friend {
user_id: string;
display_name?: string;
remark?: string;
[key: string]: unknown;
}
export function getFriends(query = '', cursor = '') {
const qs = new URLSearchParams();
if (query) qs.set('query', query);
if (cursor) qs.set('cursor', cursor);
const suffix = qs.toString();
return imGet<{ items: Friend[]; next_cursor?: string }>(`/friends${suffix ? `?${suffix}` : ''}`);
}
export function deleteFriend(userId: string) {
return imDelete<void>(`/friends/${encodeURIComponent(userId)}`);
}
export function updateFriendRemark(userId: string, remark: string) {
return imPatch<void>(`/friends/${encodeURIComponent(userId)}`, { remark });
}
export interface UserSearchResult {
user_id: string;
display_name: string;
relationship: 'none' | 'friend' | 'pending' | string;
can_add: boolean;
action: string;
}
export function searchUsers(query: string, type: 'account' = 'account', cursor = '') {
const qs = new URLSearchParams({ q: query, type });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: UserSearchResult[]; next_cursor?: string }>(`/users/search?${qs.toString()}`);
}
export function sendFriendRequest(targetUserId: string, message?: string, source = 'search', sourceTopic?: string) {
return imPost<{ request_id: string; topic: string; status: string }>('/friend-requests', {
target_user_id: targetUserId,
message,
source,
source_topic: sourceTopic,
});
}
export interface FriendRequest {
request_id: string;
applicant_id: string;
target_user_id: string;
direction: 'inbound' | 'outbound';
status: string;
message?: string;
peer?: { user_id: string; display_name?: string };
topic: string;
}
export function getFriendRequests() {
return imGet<{ items: FriendRequest[]; next_cursor?: string }>('/friend-requests');
}
export function acceptFriendRequest(requestId: string, version?: number) {
return imPost<void>(`/friend-requests/${encodeURIComponent(requestId)}/accept`, version ? { version } : {});
}
export function rejectFriendRequest(requestId: string, version?: number) {
return imPost<void>(`/friend-requests/${encodeURIComponent(requestId)}/reject`, version ? { version } : {});
}
export interface ConversationSettings {
pinned: boolean;
mute_notifications: boolean;
version: number;
}
/** P2P-only -- group pin/mute goes through a different, group-specific mechanism (native Tinode private/tags). */
export function getConversationSettings(topic: string) {
return imGet<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`);
}
export function updateConversationSettings(topic: string, body: Partial<Pick<ConversationSettings, 'pinned' | 'mute_notifications'>> & { version: number }) {
return imPatch<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`, body);
}
export function blockUser(userId: string) {
return imPut<void>(`/blocks/${encodeURIComponent(userId)}`);
}
export function unblockUser(userId: string) {
return imDelete<void>(`/blocks/${encodeURIComponent(userId)}`);
}
export function getBlocks() {
return imGet<{ items: Friend[]; next_cursor?: string }>('/blocks');
}