Files
im_webapp/web/src/features/groups/GroupAnnouncementModal.tsx
T
Your Name 1a4a96461a feat: show group announcement read count and send read receipts
Backend added read_count/member_count/read_summary to the announcement
payload in today's deploy (previously confirmed absent). Wire it up:
- groups.ts: add the three fields to the GroupAnnouncement type.
- GroupAnnouncementModal.tsx: render "已读N/M" per README §4.8, and
  call readGroupAnnouncement() on open -- that endpoint existed in
  groups.ts already but was never called from anywhere, so opening an
  announcement never told the server this member had read it.
2026-09-18 16:05:04 +08:00

163 lines
9.2 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { getAllGroupMembers, getLatestGroupAnnouncement, readGroupAnnouncement, 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.
* 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).
* `read_count`/`member_count` were confirmed ABSENT on 2026-09-08; the 2026-09-18 backend deploy
* added them (see groups.ts's own comment on `GroupAnnouncement`), so this now renders "已读N/M" as
* README §4.8 originally called for, and calls `readGroupAnnouncement` on open -- that REST endpoint
* existed in groups.ts since before this fix but was never actually called from anywhere, so opening
* an announcement never told the server "this member has read it" at all.
*/
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);
// Fire-and-forget: viewing the announcement IS "reading" it, matching how the P2P/group message
// read receipt (native `{note what:"read"}`) already works elsewhere in this app -- no explicit
// "mark as read" button anywhere in the mock. Guarded on a real, non-empty `announcement_id` so
// this doesn't fire for the zero-valued "nothing published yet" response, and keyed on that id (not
// just "modal opened") so re-opening the SAME announcement doesn't re-send needlessly, though the
// endpoint's own idempotency key means it would be harmless either way.
const announcementId = query.data?.announcement_id;
useEffect(() => {
if (!announcementId) return;
// Invalidate (not just fire-and-forget) so THIS viewer's own read immediately bumps the
// displayed count instead of showing a stale pre-read number until some unrelated refetch.
readGroupAnnouncement(topicId, announcementId)
.then(() => queryClient.invalidateQueries({ queryKey: ['group-announcement', topicId] }))
.catch(() => {});
}, [topicId, announcementId, queryClient]);
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>
{/* README §4.8. Both the flat fields and `read_summary` carry the same two numbers
(confirmed reading the backend's own payload builder) -- prefer the flat ones,
fall back to the nested object in case a future response only sends one shape. */}
{(() => {
const readCount = query.data.read_count ?? query.data.read_summary?.read_count;
const memberCount = query.data.member_count ?? query.data.read_summary?.member_count;
if (readCount == null || memberCount == null) return null;
return (
<p className="m-0 mt-2 text-xs text-text-tertiary-3">
已读 {readCount}/{memberCount}
</p>
);
})()}
</>
)}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
</div>
);
}