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