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.
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { Toast } from '@/app/Toast';
|
import { Toast } from '@/app/Toast';
|
||||||
import { getAllGroupMembers, getLatestGroupAnnouncement, updateCurrentGroupAnnouncement, type GroupRole } from '@/lib/api/groups';
|
import { getAllGroupMembers, getLatestGroupAnnouncement, readGroupAnnouncement, updateCurrentGroupAnnouncement, type GroupRole } from '@/lib/api/groups';
|
||||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||||
import { useEscapeKey } from '@/lib/hooks';
|
import { useEscapeKey } from '@/lib/hooks';
|
||||||
@@ -24,12 +24,15 @@ interface GroupAnnouncementModalProps {
|
|||||||
* corrections from real responses (2026-09-08, real group `grpvrdHqKqkCq0`): the publisher comes
|
* 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
|
* 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.
|
* -- 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
|
* When nothing has been published yet, the endpoint still returns `200` with every field
|
||||||
* than kept as permanently-dead optional-chained code. When nothing has been published yet, the
|
* zero-valued, never `404`/`null` -- checked below via `content` emptiness, not object existence (a
|
||||||
* endpoint still returns `200` with every field zero-valued, never `404`/`null` -- checked below
|
* real bug this file had before this fix: `!query.data` was never true, so it rendered a blank
|
||||||
* via `content` emptiness, not object existence (a real bug this file had before this fix: `!query.
|
* "announcement" -- empty name, empty date -- instead of the empty state).
|
||||||
* data` was never true, so it rendered a blank "announcement" -- empty name, empty date -- instead
|
* `read_count`/`member_count` were confirmed ABSENT on 2026-09-08; the 2026-09-18 backend deploy
|
||||||
* of the empty state).
|
* 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) {
|
export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnouncementModalProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -42,6 +45,22 @@ export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnou
|
|||||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||||
const author = membersQuery.data?.find((m) => m.user_id === query.data?.author);
|
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({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (content: string) => updateCurrentGroupAnnouncement(topicId, content, query.data?.version ?? 0),
|
mutationFn: (content: string) => updateCurrentGroupAnnouncement(topicId, content, query.data?.version ?? 0),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -120,6 +139,19 @@ export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnou
|
|||||||
<span className="text-xs text-text-tertiary-3">发布于 {formatEventTimestamp(new Date(query.data.published_at))}</span>
|
<span className="text-xs text-text-tertiary-3">发布于 {formatEventTimestamp(new Date(query.data.published_at))}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="m-0 mt-3 whitespace-pre-wrap text-sm leading-[1.7] text-text-body">{query.data.content}</p>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -409,11 +409,15 @@ export function getGroupSummary(topic: string) {
|
|||||||
|
|
||||||
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): `author` is a flat `usr...` id, not
|
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): `author` is a flat `usr...` id, not
|
||||||
* the `published_by` object this file originally guessed -- resolve it against the group's member
|
* the `published_by` object this file originally guessed -- resolve it against the group's member
|
||||||
* list for a display name/role, same as any other bare user id in this API. `read_count`/
|
* list for a display name/role, same as any other bare user id in this API. When nothing has been
|
||||||
* `member_count` are confirmed ABSENT from the real response (not just unconfirmed), and when
|
* published yet the endpoint still returns `200` with every field zero-valued (`announcement_id:"",
|
||||||
* nothing has been published yet the endpoint still returns `200` with every field zero-valued
|
* author:"", content:"", published_at:"", version:0`), never `404`/`null` -- callers must check
|
||||||
* (`announcement_id:"", author:"", content:"", published_at:"", version:0`), never `404`/`null` --
|
* `content` for emptiness, not object existence.
|
||||||
* callers must check `content` for emptiness, not object existence. */
|
* `read_count`/`member_count`/`read_summary` were confirmed ABSENT on 2026-09-08 (dropped from this
|
||||||
|
* type entirely, not just left optional) -- backend added them in the 2026-09-18 deploy (confirmed
|
||||||
|
* by reading `groupAnnouncementPayload` in `im_be/server/im_http.go`, which now sets all three).
|
||||||
|
* Optional here because an old cached response (or a tenant not yet on the new deploy) may still
|
||||||
|
* omit them. */
|
||||||
export interface GroupAnnouncement {
|
export interface GroupAnnouncement {
|
||||||
announcement_id: string;
|
announcement_id: string;
|
||||||
topic: string;
|
topic: string;
|
||||||
@@ -422,6 +426,9 @@ export interface GroupAnnouncement {
|
|||||||
published_at: string;
|
published_at: string;
|
||||||
author: string;
|
author: string;
|
||||||
permissions?: { can_edit?: boolean };
|
permissions?: { can_edit?: boolean };
|
||||||
|
read_count?: number;
|
||||||
|
member_count?: number;
|
||||||
|
read_summary?: { read_count: number; member_count: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLatestGroupAnnouncement(topic: string) {
|
export function getLatestGroupAnnouncement(topic: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user