feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group management (admin panel, mute, files, announcements, QR-based invite/join), contacts (friend profile, add-friend, blocklist, new-friends), favorites (real media rendering across text/image/video/voice/file/link), settings (profile/security/privacy/general/about), calls (Agora-backed audio/video signaling + call history), and system messages/file assistant. Also folds in a large batch of live-verified fixes surfaced by repeated fresh code-review passes and hands-on E2E testing against the real backend, including: WS reconnect not resubscribing any topic after a disconnect, missing x-im-client-mid on every send path (protocol compliance for retry dedup), group mute/member-settings/self-profile endpoint corrections, relationship-state type fixes across search/QR/ friend-profile, tenant-switch logout gap, password-policy now read from the real per-tenant config instead of guessed, NavRail highlight/ aria-current desync, several call-state-machine races, and responsive layout fixes across all four breakpoints (mobile/tablet/laptop/desktop) including a full mobile rebuild of favorites, call UI, and the settings home/sub-list split. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -1,13 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { getAllGroupMembers } from '@/lib/api/groups';
|
||||
import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
|
||||
import { checkUrlSafety } from '@/lib/api/security';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useMessageJumpStore } from '@/stores/messageJump';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
type Tab = 'text' | MediaType;
|
||||
|
||||
@@ -55,22 +59,17 @@ function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
|
||||
}
|
||||
|
||||
/** Renders a search hit's original message content (text/file/voice/...) via the same Drafty
|
||||
* formatter regular chat bubbles use -- `snapshot.content` is a real, complete Drafty document
|
||||
* (docs/IM-前后端对接文档.md's flat `SearchFileHit`-style shape doesn't actually exist on this
|
||||
* server; see messages.ts's SearchHit doc comment), so this passes it straight through rather than
|
||||
* picking fields back out of it by hand. */
|
||||
function SnapshotContent({ item }: { item: SearchHit }) {
|
||||
* formatter regular chat bubbles use -- `snapshot.content` is either a real, complete Drafty
|
||||
* document OR (for a plain unformatted text message, confirmed live) a bare string, exactly the
|
||||
* same duality `renderDrafty` already resolves for every other caller -- so this passes it straight
|
||||
* through rather than picking fields back out of it by hand. The previous version's manual
|
||||
* `{txt:content.txt||' ', ...}` reconstruction silently produced a single blank space for the bare-
|
||||
* string case (`content.txt` on a string primitive is `undefined`), which is exactly what a live
|
||||
* search against a real plain-text message showed before this fix. */
|
||||
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
|
||||
const content = item.snapshot?.content;
|
||||
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
|
||||
return (
|
||||
<div className="text-sm text-text-body">
|
||||
{renderDrafty({
|
||||
txt: content.txt || ' ',
|
||||
fmt: (content.fmt as { at?: number; len?: number; tp?: string; key?: number }[] | undefined) || [{ at: -1 }],
|
||||
ent: (content.ent || []).map((e) => ({ tp: e.tp || '', data: (e.data || {}) as Record<string, unknown> })),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,14 +130,40 @@ interface HistorySearchModalProps {
|
||||
topicId: string;
|
||||
initialTab?: Tab;
|
||||
onClose: () => void;
|
||||
/** For sender-name/avatar resolution on the 消息 tab (`05-favorites-history.dc.html #1b`'s rows
|
||||
* each carry the sender's name + colored avatar, e.g. "陈明远"/"接语彤"/"我") -- a group needs a
|
||||
* member-list lookup per hit since `from` can be anyone; a P2P conversation only ever has two
|
||||
* possible senders (me or the peer), so the caller's already-resolved `peerName` is enough. */
|
||||
isGroup: boolean;
|
||||
peerName: string;
|
||||
}
|
||||
|
||||
/** README §3.4's "查找聊天记录 ›" + 4-icon grid, opened as a centered modal (README §1.5). */
|
||||
export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: HistorySearchModalProps) {
|
||||
export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGroup, peerName }: HistorySearchModalProps) {
|
||||
const [tab, setTab] = useState<Tab>(initialTab);
|
||||
const [query, setQuery] = useState('');
|
||||
const debouncedQuery = useDebouncedValue(query, 300);
|
||||
useEscapeKey(onClose);
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['group-members', topicId],
|
||||
queryFn: () => getAllGroupMembers(topicId),
|
||||
enabled: isGroup,
|
||||
});
|
||||
// Confirmed live: this endpoint's `from` (both the top-level field and `snapshot.from`) comes back
|
||||
// with its `usr` prefix STRIPPED -- the same quirk already documented for favorites' own
|
||||
// `snapshot.from` (see favorites.ts's header comment), just discovered here independently rather
|
||||
// than assumed to apply. Comparing it directly against `myUserId`/`GroupMember.user_id` (both full
|
||||
// `usr...` ids) would never match "me" or any real member, so every sender always fell back to the
|
||||
// raw id. Reconstructing the full id is safe specifically because the stripping is a fixed, known
|
||||
// prefix removal, not a lossy truncation.
|
||||
const normalizeSender = (from: string) => (from.startsWith('usr') ? from : `usr${from}`);
|
||||
const senderLabel = (from: string): string => {
|
||||
const full = normalizeSender(from);
|
||||
if (myUserId && full === myUserId) return '我';
|
||||
if (isGroup) return membersQuery.data?.find((m) => m.user_id === full)?.display_name || from;
|
||||
return peerName;
|
||||
};
|
||||
|
||||
const searchQuery = useInfiniteQuery({
|
||||
queryKey: ['topic-search', topicId, debouncedQuery],
|
||||
@@ -159,31 +184,72 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
||||
const mediaItems = mediaQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
const fileLinkQuery = useInfiniteQuery({
|
||||
queryKey: ['topic-search-media', topicId, tab],
|
||||
queryFn: ({ pageParam }) => searchTopicMessages(topicId, '', tab as 'file' | 'link', pageParam),
|
||||
queryKey: ['topic-search-media', topicId, tab, debouncedQuery],
|
||||
// `searchTopicMessages` genuinely supports a keyword filter for file/link results (unlike
|
||||
// `getTopicMedia`'s image/video browse, which is date-grouped, not keyword-searchable, per its
|
||||
// own signature) -- this used to hardcode `''`, silently discarding the search box's query the
|
||||
// moment a user switched off the 消息 tab, even though the box itself is spec'd as one shared
|
||||
// header element sitting above all four tabs, not a 消息-tab-only control.
|
||||
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, tab as 'file' | 'link', pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
enabled: tab === 'file' || tab === 'link',
|
||||
});
|
||||
const fileLinkItems = fileLinkQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
// Search hits carry `seq` (used until now only for the timestamp/`key`) but nothing acted on it --
|
||||
// clicking a result did nothing at all. ConversationView owns the actual scroll-to/highlight logic
|
||||
// (it needs the message loaded first, which this modal has no reason to know how to do); this just
|
||||
// hands off the target and gets out of the way.
|
||||
const jumpToMessage = (seq: number) => {
|
||||
useMessageJumpStore.getState().requestJump(topicId, seq);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[620px] w-[720px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-list-item px-5">
|
||||
// README §6.3 (mock `05/1e`): mobile is a full-bleed PAGE, not a dimmed centered dialog -- no
|
||||
// overlay tint, no rounded card, search input lives in the header itself (with a "取消" text
|
||||
// button instead of a close icon). Desktop (`md`+) keeps the original centered 720x620 dialog
|
||||
// exactly as before.
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-transparent md:bg-[#22262d]/25" onClick={onClose}>
|
||||
<div
|
||||
className="flex h-full w-full flex-col bg-white md:h-[620px] md:w-[720px] md:rounded-xl md:shadow-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="hidden h-14 flex-none items-center justify-between border-b border-border-list-item px-5 md:flex">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">查找聊天记录</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<header className="flex h-[52px] flex-none items-center gap-2.5 px-4 md:hidden">
|
||||
<div className="flex h-9.5 flex-1 items-center gap-2 rounded-[10px] bg-surface-search px-2.5">
|
||||
<Icon name="search" className="text-[20px] text-text-placeholder-2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
|
||||
disabled={tab === 'image' || tab === 'video'}
|
||||
className="w-full min-w-0 bg-transparent text-[15px] outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
|
||||
/>
|
||||
{query ? (
|
||||
<button type="button" onClick={() => setQuery('')} className="grid flex-none place-items-center">
|
||||
<Icon name="cancel" className="text-[18px] text-text-placeholder-2" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="flex-none text-[15px] text-accent">
|
||||
取消
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-none gap-1 border-b border-list-item px-5 pt-3">
|
||||
<div className="flex flex-none gap-1 overflow-x-auto border-b border-border-list-item px-5 pt-3">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 rounded-t-lg px-3 py-2 text-sm ${
|
||||
className={`flex flex-none items-center gap-1.5 whitespace-nowrap rounded-t-lg px-3 py-2 text-sm ${
|
||||
tab === t.key ? 'border-b-2 border-accent font-medium text-accent' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
@@ -193,40 +259,69 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-none px-5 py-3 md:block">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
|
||||
disabled={tab === 'image' || tab === 'video'}
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'text' ? (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-none px-5 py-3">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索聊天记录"
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 pb-4">
|
||||
{!debouncedQuery.trim() ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">输入关键词搜索这个会话的聊天记录</div>
|
||||
) : searchQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">搜索中…</div>
|
||||
) : searchQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>搜索失败,请重试</span>
|
||||
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : !searchItems.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">没有找到相关消息</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{searchItems.map((item) => (
|
||||
<li key={item.seq} className="rounded-lg px-3 py-2.5 hover:bg-surface-rail">
|
||||
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
|
||||
<span>{formatEventTimestamp(new Date(item.ts))}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate">
|
||||
<SnapshotContent item={item} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{searchItems.map((item) => {
|
||||
const fullFrom = normalizeSender(item.from);
|
||||
const name = senderLabel(item.from);
|
||||
return (
|
||||
<li key={item.seq}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => jumpToMessage(item.seq)}
|
||||
className="flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-surface-rail"
|
||||
>
|
||||
<span
|
||||
className={`grid h-10 w-10 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
|
||||
myUserId && fullFrom === myUserId ? 'bg-accent' : avatarBgClass(fullFrom)
|
||||
}`}
|
||||
>
|
||||
{avatarLetter(name)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
|
||||
<span className="truncate text-text-tertiary-2">{name}</span>
|
||||
<span className="flex-none">{formatEventTimestamp(new Date(item.ts))}</span>
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<SnapshotContent item={item} highlightQuery={debouncedQuery} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{searchQuery.hasNextPage ? (
|
||||
<LoadMoreButton onClick={() => searchQuery.fetchNextPage()} loading={searchQuery.isFetchingNextPage} />
|
||||
@@ -239,6 +334,13 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{mediaQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : mediaQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => mediaQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : !mediaItems.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
||||
) : (
|
||||
@@ -258,13 +360,24 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{fileLinkQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : fileLinkQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => fileLinkQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : !fileLinkItems.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{fileLinkItems.map((item) =>
|
||||
tab === 'link' ? <LinkResult key={item.seq} item={item} /> : <SnapshotContent key={item.seq} item={item} />,
|
||||
tab === 'link' ? (
|
||||
<LinkResult key={item.seq} item={item} />
|
||||
) : (
|
||||
<SnapshotContent key={item.seq} item={item} highlightQuery={debouncedQuery} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{fileLinkQuery.hasNextPage ? (
|
||||
|
||||
Reference in New Issue
Block a user