Merge branch 'dev' of https://git.118im.com/im_team/im_webapp into dev
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 { 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
|
||||
<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>
|
||||
|
||||
@@ -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, '群名保存失败'),
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+1
-1
@@ -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<string, unknown> }) => void;
|
||||
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call' | 'im'; seq?: number; from?: string; event?: string; payload?: Record<string, unknown> }) => 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
|
||||
|
||||
@@ -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<string>();
|
||||
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 {
|
||||
@@ -110,7 +150,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;
|
||||
@@ -269,6 +309,38 @@ 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(() => {});
|
||||
}
|
||||
}
|
||||
// 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`
|
||||
|
||||
Reference in New Issue
Block a user