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