diff --git a/web/src/features/messages/DetailColumn.tsx b/web/src/features/messages/DetailColumn.tsx
new file mode 100644
index 00000000..2cca0411
--- /dev/null
+++ b/web/src/features/messages/DetailColumn.tsx
@@ -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 (
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/web/src/features/messages/MessagesLayout.tsx b/web/src/features/messages/MessagesLayout.tsx
index 7c8bee06..f4c24df3 100644
--- a/web/src/features/messages/MessagesLayout.tsx
+++ b/web/src/features/messages/MessagesLayout.tsx
@@ -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 (
-
- );
-}
+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 (
@@ -42,14 +29,16 @@ export function MessagesLayout() {
setDetailOpen((v) => !v) }} />
-
-
-
- {detailOpen ? (
+ {topicId ? (
+
+
+
+ ) : null}
+ {detailOpen && topicId ? (
setDetailOpen(false)} />
- setDetailOpen(false)} />
+ setDetailOpen(false)} />
) : null}
diff --git a/web/src/lib/api/friends.ts b/web/src/lib/api/friends.ts
new file mode 100644
index 00000000..f507988c
--- /dev/null
+++ b/web/src/lib/api/friends.ts
@@ -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
(`/friends/${encodeURIComponent(userId)}`);
+}
+
+export function updateFriendRemark(userId: string, remark: string) {
+ return imPatch(`/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(`/friend-requests/${encodeURIComponent(requestId)}/accept`, version ? { version } : {});
+}
+
+export function rejectFriendRequest(requestId: string, version?: number) {
+ return imPost(`/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(`/conversations/${encodeURIComponent(topic)}/settings`);
+}
+
+export function updateConversationSettings(topic: string, body: Partial> & { version: number }) {
+ return imPatch(`/conversations/${encodeURIComponent(topic)}/settings`, body);
+}
+
+export function blockUser(userId: string) {
+ return imPut(`/blocks/${encodeURIComponent(userId)}`);
+}
+
+export function unblockUser(userId: string) {
+ return imDelete(`/blocks/${encodeURIComponent(userId)}`);
+}
+
+export function getBlocks() {
+ return imGet<{ items: Friend[]; next_cursor?: string }>('/blocks');
+}