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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user