From 1a4a96461ab71f1ba4dbf81b4f46c8372fcd6bef Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 18 Sep 2026 16:05:04 +0800 Subject: [PATCH 1/4] feat: show group announcement read count and send read receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../groups/GroupAnnouncementModal.tsx | 48 +++++++++++++++---- web/src/lib/api/groups.ts | 17 +++++-- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/web/src/features/groups/GroupAnnouncementModal.tsx b/web/src/features/groups/GroupAnnouncementModal.tsx index 21a6f274..9c957768 100644 --- a/web/src/features/groups/GroupAnnouncementModal.tsx +++ b/web/src/features/groups/GroupAnnouncementModal.tsx @@ -1,10 +1,10 @@ -import { useState } from 'react'; +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, 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 { formatEventTimestamp } from '@/lib/messages/time'; import { useEscapeKey } from '@/lib/hooks'; @@ -24,12 +24,15 @@ interface GroupAnnouncementModalProps { * 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). + * 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(); @@ -42,6 +45,22 @@ export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnou 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) => { @@ -120,6 +139,19 @@ export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnou 发布于 {formatEventTimestamp(new Date(query.data.published_at))}

{query.data.content}

+ {/* 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 ( +

+ 已读 {readCount}/{memberCount} +

+ ); + })()} )} diff --git a/web/src/lib/api/groups.ts b/web/src/lib/api/groups.ts index 50e2f99c..df8dfd8b 100644 --- a/web/src/lib/api/groups.ts +++ b/web/src/lib/api/groups.ts @@ -409,11 +409,15 @@ export function getGroupSummary(topic: string) { /** 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 - * list for a display name/role, same as any other bare user id in this API. `read_count`/ - * `member_count` are confirmed ABSENT from the real response (not just unconfirmed), and when - * nothing has been published yet the endpoint still returns `200` with every field zero-valued - * (`announcement_id:"", author:"", content:"", published_at:"", version:0`), never `404`/`null` -- - * callers must check `content` for emptiness, not object existence. */ + * list for a display name/role, 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 (`announcement_id:"", + * author:"", content:"", published_at:"", version:0`), never `404`/`null` -- 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 { announcement_id: string; topic: string; @@ -422,6 +426,9 @@ export interface GroupAnnouncement { published_at: string; author: string; permissions?: { can_edit?: boolean }; + read_count?: number; + member_count?: number; + read_summary?: { read_count: number; member_count: number }; } export function getLatestGroupAnnouncement(topic: string) { From f5a512b8c92ff5dc05ba8de982a5f29d2e237508 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 18 Sep 2026 16:09:52 +0800 Subject: [PATCH 2/4] fix: recall/edit not syncing to the other party in real time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/IM-前后端对接文档.md:416,1256 documents a custom {info what:"im", event:"message.recalled"|"message.edited"} push to every other online topic member, with an explicit instruction to refresh that message by topic+seq+version rather than waiting for the user to re-enter the conversation. Nothing recognized "im" as a valid `what` value (the native SDK's own _routeInfo has no such case either, same gap as the existing group.dissolved handling right above this fix), so the push arrived over the wire but never triggered anything -- the peer's local message cache kept the stale pre-recall/pre-edit content until some unrelated full resync happened to refetch it. Widen the `what` union (both the app-level RawTopicInfo and the SDK's own onInfo callback type) to include 'im', and on that event issue the same targeted single-message getMeta(...).withData(seq,seq+1,1) refetch ConversationView's jump-to-message feature already established. Live-verified: recalling a message now shows "⚠ 消息已删除" on the other party's screen immediately, with no manual refresh. --- web/src/lib/tinode/tinode-sdk.d.ts | 2 +- web/src/lib/tinode/topics.ts | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/web/src/lib/tinode/tinode-sdk.d.ts b/web/src/lib/tinode/tinode-sdk.d.ts index c8b8d9af..d4bf2f67 100644 --- a/web/src/lib/tinode/tinode-sdk.d.ts +++ b/web/src/lib/tinode/tinode-sdk.d.ts @@ -270,7 +270,7 @@ declare module 'tinode-sdk' { /** Single-slot callbacks the app wires fresh per "currently open" topic and tears down on leave. */ onData?: (data?: StoredMessage) => void; onAllMessagesReceived?: (count: number) => void; - onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; seq?: number; from?: string; event?: string; payload?: Record }) => void; + onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call' | 'im'; seq?: number; from?: string; event?: string; payload?: Record }) => void; onMetaDesc?: (desc: unknown) => void; /** `me`-topic-only in practice (verified tinode.dev.js:4654-4657): fired with the just-subscribed * Topic itself the moment a brand-new contact's subscribe resolves -- notably including the diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts index 84e4e08b..6a8895a7 100644 --- a/web/src/lib/tinode/topics.ts +++ b/web/src/lib/tinode/topics.ts @@ -110,7 +110,7 @@ export function subscribeAnyTopicChange(callback: () => void): () => void { } export interface RawTopicInfo { - what: 'read' | 'recv' | 'kp' | 'call'; + what: 'read' | 'recv' | 'kp' | 'call' | 'im'; seq?: number; from?: string; event?: string; @@ -240,6 +240,24 @@ function wireTopicCallbacks(topic: Topic) { if (groupEvent?.what === 'group' && groupEvent.event === 'group.dissolved') { markTopicGoneAndNotify(topic.name); } + // docs/IM-前后端对接文档.md:416,1256: recall/edit push a custom `{info what:"im", + // event:"message.recalled"|"message.edited", payload:{seq,version}}` to every other ONLINE + // topic member -- explicitly NOT a native Tinode mechanism (`_routeInfo`'s switch has no `"im"` + // case, confirmed reading tinode.dev.js the same way the group.dissolved gap above was), so + // nothing here ever picked it up before. `notify()` above still fired (it's unconditional), so a + // component watching this topic WOULD re-render -- but the SDK's own local message cache was + // never told the content changed, so it re-rendered the SAME stale pre-recall/pre-edit bubble. + // The doc's own instruction is explicit: "按 topic+seq+version 刷新本地消息,不要等待用户重新 + // 进入会话才更新" -- live-confirmed this was exactly what was happening instead (recall/edit only + // ever showed up for the OTHER party after something unrelated caused a full resync, e.g. + // reopening the conversation). Reuses the exact `getMeta(...).withData(seq,seq+1,1)` targeted + // single-message refetch ConversationView's own jump-to-message feature already established. + if (info?.what === 'im' && (info.event === 'message.recalled' || info.event === 'message.edited')) { + const seq = info.payload?.seq; + if (typeof seq === 'number') { + topic.getMeta(topic.startMetaQuery().withData(seq, seq + 1, 1).build()).catch(() => {}); + } + } }; topic.onMetaDesc = notify; // Only ever actually invoked on the `me` topic (the SDK reads `me.onMetaSub`/`me.onSubsUpdated` From 0de8c0b5ac8249490b455cba91b01620b0284f08 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 18 Sep 2026 17:11:40 +0800 Subject: [PATCH 3/4] fix: message list shaking/flickering when scrolling to top or bottom topic.onData = notify (wireTopicCallbacks) fires once per individual message, not once per batch. Live-instrumented a real loadOlder() page fetch (24 older messages): scrollHeight/scrollTop mutated in ~14 separate small steps over ~400ms, each one racing loadOlder()'s own scroll-compensation and ConversationView's separate autoscroll-to- bottom effect before the page had actually finished arriving -- that rapid multi-step crawl is what reads as "shaking" rather than one clean jump. Coalesce notifications landing within the same animation frame into a single flush, so a burst of onData calls (initial load, loadOlder(), a fast run of incoming messages) triggers at most one re-render per frame instead of N. messageSnapshots invalidation stays synchronous (pure cache bookkeeping); only the listener callbacks that schedule React's re-render are batched. Live-verified with the same instrumentation: the same 24-message loadOlder() fetch now produces 2 dom-mutation events instead of 14, with scrollTop only moving once the batch has actually settled instead of crawling in lockstep with a growing scrollHeight. --- web/src/lib/tinode/topics.ts | 44 ++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts index 6a8895a7..f008d6ad 100644 --- a/web/src/lib/tinode/topics.ts +++ b/web/src/lib/tinode/topics.ts @@ -86,10 +86,50 @@ export function markConfirmedRead(topicName: string, seq: number) { if (seq > prev) confirmedReadSeq.set(topicName, seq); } +// Live-confirmed root cause of a real reported bug ("聊天消息框滚动到顶部或底部时UI会抖动闪烁"): +// `topic.onData = notify` (wireTopicCallbacks below) fires once per INDIVIDUAL message, not once per +// batch -- confirmed by instrumenting a real `loadOlder()` page fetch (24 older messages): the DOM +// mutated in ~14 separate small steps over ~400ms, each one growing `scrollHeight` a little and each +// one racing the OTHER open effect that reads it (loadOlder's own scroll-compensation, and +// ConversationView's separate "autoscroll to bottom" effect) -- neither gets a stable, final +// `scrollHeight` to work with until the whole page has actually finished arriving, so each of the ~14 +// intermediate re-renders nudges `scrollTop` again, which is what actually reads as "shaking" rather +// than one clean jump. This wasn't reproducible by reasoning about a single `getMessagesPage()` call +// in isolation -- it only showed up instrumenting `scrollTop` across a real fetch. +// Fix: coalesce any notifications that land within the same animation frame into one flush, so a +// burst of N `onData` calls (initial page load, `loadOlder()`, or a fast run of incoming messages) +// triggers at most one re-render per frame instead of N -- imperceptible as a delay, but it means +// `scrollHeight` is only ever read by a re-render once it reflects everything that arrived so far in +// that frame, not some in-between partial state. `messageSnapshots.delete` stays synchronous (it's +// pure cache invalidation, not a re-render) so a snapshot read between two notifications in the same +// frame still sees the latest data, only the LISTENER callbacks (which is what schedules React's own +// re-render) are what get batched. +const pendingTopicNotifications = new Set(); +let pendingAnyNotification = false; +let notifyFrameScheduled = false; + +function flushTopicNotifications() { + notifyFrameScheduled = false; + const topics = [...pendingTopicNotifications]; + pendingTopicNotifications.clear(); + const flushAny = pendingAnyNotification; + pendingAnyNotification = false; + for (const topicName of topics) { + topicListeners.get(topicName)?.forEach((cb) => cb()); + } + if (flushAny) { + anyTopicListeners.forEach((cb) => cb()); + } +} + function notifyTopic(topicName: string) { messageSnapshots.delete(topicName); - topicListeners.get(topicName)?.forEach((cb) => cb()); - anyTopicListeners.forEach((cb) => cb()); + pendingTopicNotifications.add(topicName); + pendingAnyNotification = true; + if (!notifyFrameScheduled) { + notifyFrameScheduled = true; + requestAnimationFrame(flushTopicNotifications); + } } export function subscribeTopicChanges(topicName: string, callback: () => void): () => void { From 5748f1290d1e083dea7fe931751ed0141c5ec7e9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 18 Sep 2026 17:36:11 +0800 Subject: [PATCH 4/4] fix(groups): sync group rename in real time for owner and members PATCH .../groups/{topic} pushes a custom {info what:"group", event:"group.profile.updated"} that nothing recognized, so the sidebar/header name (native SDK contact.public.fn) never refreshed until an unrelated focus-triggered resync happened to touch it. Add the missing branch to onInfo (mirrors the existing group.dissolved/message.recalled handling) to re-pull public/desc via topic.getMeta(...withDesc()), and trigger the same refresh from the renamer's own save success since they won't receive their own push. Co-Authored-By: Claude Sonnet 5 --- web/src/features/groups/GroupDetailColumn.tsx | 5 +++++ web/src/lib/tinode/topics.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/web/src/features/groups/GroupDetailColumn.tsx b/web/src/features/groups/GroupDetailColumn.tsx index ce47638e..fdb842ab 100644 --- a/web/src/features/groups/GroupDetailColumn.tsx +++ b/web/src/features/groups/GroupDetailColumn.tsx @@ -171,6 +171,11 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }: onSuccess: (data) => { queryClient.setQueryData(['group', topicId], (prev: GroupProfile | undefined) => (prev ? { ...prev, ...data } : data)); setNameEditing(false); + // The renamer's OWN sidebar/header (`contact.public?.fn`, native SDK state) doesn't update from + // this REST call either -- only from the `group.profile.updated` push topics.ts now listens for, + // which this account won't receive for its own action. Trigger the same SDK-sanctioned refresh + // directly instead of waiting on an unconfirmed self-echo of that push. + tinode.getTopic(topicId).getMeta(tinode.getTopic(topicId).startMetaQuery().withDesc().build()).catch(() => {}); }, onError: (err) => showError(err, '群名保存失败'), }); diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts index f008d6ad..648eea0c 100644 --- a/web/src/lib/tinode/topics.ts +++ b/web/src/lib/tinode/topics.ts @@ -298,6 +298,20 @@ function wireTopicCallbacks(topic: Topic) { topic.getMeta(topic.startMetaQuery().withData(seq, seq + 1, 1).build()).catch(() => {}); } } + // docs/IM-前后端对接文档.md:1542: `PATCH .../groups/{topic}` (rename/avatar/description) pushes a + // custom `{info what:"group", event:"group.profile.updated"}` to every other online member -- same + // undocumented-to-the-SDK shape as `group.dissolved` above, so nothing here ever refreshed the + // topic's native `public` fields on receipt. Live-confirmed the actual gap: the sidebar/header name + // comes from `contact.public?.fn` (native SDK state, see ChatListColumn.tsx), which a REST-only + // mutation never touches for ANYONE -- not just other members, but the renamer's own other tabs/ + // views too -- and only ever got refreshed as a side effect of the unrelated focus-triggered + // `reconcileMeSubs()`, which is why refocus "fixed" it for everyone. `withDesc()` is the SDK's own + // sanctioned way to re-pull `public`/description from the server (contrast the abandoned approach + // of manually patching `Topic._processMetaDesc` tried earlier for a different bug -- that one was + // reverted for inconclusive verification; this one goes through a real server round-trip instead). + if (groupEvent?.what === 'group' && groupEvent.event === 'group.profile.updated') { + topic.getMeta(topic.startMetaQuery().withDesc().build()).catch(() => {}); + } }; topic.onMetaDesc = notify; // Only ever actually invoked on the `me` topic (the SDK reads `me.onMetaSub`/`me.onSubsUpdated`