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.
163 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|