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
+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');
}