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
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { getAllGroupMembers, getLatestGroupAnnouncement, updateCurrentGroupAnnouncement, type GroupRole } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
|
||||
|
||||
interface GroupAnnouncementModalProps {
|
||||
topicId: string;
|
||||
canEdit: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §4.8 (480px): publisher avatar/name/role tag + publish time, body text, read count, and an
|
||||
* 编辑 affordance for owner/admin. `PUT .../announcements/current` is optimistic-locked by
|
||||
* `version`, confirmed present in the GET response (doc line 1745, and re-confirmed live). Two
|
||||
* corrections from real responses (2026-09-08, real group `grpvrdHqKqkCq0`): the publisher comes
|
||||
* back as a flat `author: "usr..."` id, not the `published_by` object this file originally guessed
|
||||
* -- resolved here against the group's member list, same as any other bare user id in this API.
|
||||
* And `read_count`/`member_count` are confirmed ABSENT (not just unconfirmed), so dropped rather
|
||||
* than kept as permanently-dead optional-chained code. When nothing has been published yet, the
|
||||
* endpoint still returns `200` with every field zero-valued, never `404`/`null` -- checked below
|
||||
* via `content` emptiness, not object existence (a real bug this file had before this fix: `!query.
|
||||
* data` was never true, so it rendered a blank "announcement" -- empty name, empty date -- instead
|
||||
* of the empty state).
|
||||
*/
|
||||
export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnouncementModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const query = useQuery({ queryKey: ['group-announcement', topicId], queryFn: () => getLatestGroupAnnouncement(topicId) });
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const author = membersQuery.data?.find((m) => m.user_id === query.data?.author);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (content: string) => updateCurrentGroupAnnouncement(topicId, content, query.data?.version ?? 0),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['group-announcement', topicId], data);
|
||||
// `GET .../settings` genuinely has no `announcement` field (see GroupDetailColumn.tsx's own
|
||||
// comment on this) -- the detail rail's own announcement preview actually reads
|
||||
// `group-summary`, so invalidating `group-settings` here never refreshed it; the preview text
|
||||
// was stuck at whatever it was before this save until something ELSE happened to touch summary.
|
||||
queryClient.invalidateQueries({ queryKey: ['group-summary', topicId] });
|
||||
setEditing(false);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '发布失败'),
|
||||
});
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(query.data?.content || '');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex max-h-[70vh] w-[480px] 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">群公告</strong>
|
||||
<div className="flex items-center gap-3">
|
||||
{canEdit && !editing ? (
|
||||
<button type="button" onClick={startEdit} className="text-sm text-accent">
|
||||
{query.data?.content ? '编辑' : '发布'}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{error ? <div className="text-xs text-danger-text">{error}</div> : null}
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={8}
|
||||
placeholder="发布群公告,所有成员都会收到通知"
|
||||
className="resize-none rounded-lg bg-surface-input p-3 text-sm outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate(draft.trim())}
|
||||
disabled={!draft.trim() || saveMutation.isPending}
|
||||
className="h-10 self-end rounded-lg bg-btn px-5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{saveMutation.isPending ? '发布中…' : '发布'}
|
||||
</button>
|
||||
</div>
|
||||
) : query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : !query.data?.content ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群公告</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`grid h-8 w-8 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(query.data.author)}`}>
|
||||
{avatarLetter(author?.display_name || query.data.author)}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-body">{author?.display_name || query.data.author}</span>
|
||||
{/* README §4.3: border style, no fill (matches MessageBubble.tsx's MessageRoleTag) --
|
||||
this used to hardcode warn/orange for BOTH owner and admin authors, so an admin's
|
||||
own announcement showed the wrong (owner) color. */}
|
||||
{author?.role === 'owner' ? (
|
||||
<span className="rounded-[3px] border border-warn px-1 text-[10px] leading-[14px] text-warn">{ROLE_LABEL.owner}</span>
|
||||
) : author?.role === 'admin' ? (
|
||||
<span className="rounded-[3px] border border-accent px-1 text-[10px] leading-[14px] text-accent">{ROLE_LABEL.admin}</span>
|
||||
) : null}
|
||||
<span className="text-xs text-text-tertiary-3">发布于 {formatEventTimestamp(new Date(query.data.published_at))}</span>
|
||||
</div>
|
||||
<p className="m-0 mt-3 whitespace-pre-wrap text-sm leading-[1.7] text-text-body">{query.data.content}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user