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:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
@@ -5,27 +5,52 @@ import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { useChatList } from '@/lib/tinode/hooks';
const MAX_COMMENT_LENGTH = 200;
interface ForwardPickerModalProps {
onPick: (targetTopic: string) => void;
onPick: (targetTopics: string[], comment: string) => void;
onClose: () => void;
}
/**
* Simplified single-target contact picker for the message context menu's "转发". The full design
* (README §3.6) supports multi-select + a merge/separate-forward toggle for the multi-select bulk
* bar's forward action -- this is the smaller single-message version, one tap sends immediately.
* README §3.6: multi-select targets + an optional comment, sent in one action. Used by the message
* context menu's single-message "转发", the multi-select bulk bar's "转发", and favorites' bulk
* forward -- all three previously shared this component when it only supported picking ONE target
* and sending immediately with no comment, meaning forwarding to several people required repeating
* the whole picker flow once per recipient. `forwardFavorites` (favorites.ts) already accepts
* `target_topics: string[]`/`comment` server-side; chat-message forwarding has no such batch REST
* endpoint (`forwardMessage` in lib/tinode/send.ts publishes one native {pub} per target instead),
* so callers loop `targetTopics` themselves -- this component only collects the selection.
*/
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
const contacts = useChatList();
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [comment, setComment] = useState('');
useEscapeKey(onClose);
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
const toggle = (topic: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(topic)) next.delete(topic);
else next.add(topic);
return next;
});
};
const send = () => {
if (selected.size === 0) return;
onPick([...selected], comment.trim());
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[480px] w-[420px] 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">
<strong className="text-[15px] font-semibold text-text-heading">转发到</strong>
<div className="flex h-[560px] w-[420px] 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-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">
转发到{selected.size ? `(已选 ${selected.size} 个接收方)` : ''}
</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
@@ -43,21 +68,46 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{filtered.map((c) => (
<button
key={c.topic}
type="button"
onClick={() => c.topic && onPick(c.topic)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
>
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
{filtered.map((c) => {
const isSelected = !!c.topic && selected.has(c.topic);
return (
<button
key={c.topic}
type="button"
onClick={() => c.topic && toggle(c.topic)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
>
{avatarLetter(c.public?.fn)}
</span>
<span className="truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
</button>
))}
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
>
{avatarLetter(c.public?.fn)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
<span
className={`grid h-5 w-5 flex-none place-items-center rounded-full border ${isSelected ? 'border-accent bg-accent' : 'border-[#d5d9df]'}`}
>
{isSelected ? <Icon name="check" className="text-[14px] text-white" /> : null}
</span>
</button>
);
})}
</div>
<div className="flex-none border-t border-border-list-item p-3">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value.slice(0, MAX_COMMENT_LENGTH))}
rows={2}
placeholder="附言(选填)"
className="w-full resize-none rounded-lg bg-surface-input p-2.5 text-sm outline-none"
/>
<button
type="button"
onClick={send}
disabled={selected.size === 0}
className="mt-2 h-9 w-full rounded-lg bg-btn text-sm font-medium text-white disabled:bg-btn-disabled"
>
发送{selected.size ? `(${selected.size})` : ''}
</button>
</div>
</div>
</div>