From f4a6ff36b42f90848769b422133eba32a15b61b5 Mon Sep 17 00:00:00 2001 From: Austin Date: Fri, 18 Sep 2026 17:42:48 +0800 Subject: [PATCH] fix: realtime friend-request badge via connection-level info fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server friend.request.* signals ride {info} frames addressed to the recipient's own user id, but the SDK dispatcher resolves that topic via its topic cache, where the me topic lives under the literal me -- the lookup missed and the frame was dropped before any listener could see it, so the 新朋友 badge and NewFriendsView list only updated after a manual refresh or relogin. topics.ts now forwards frames whose topic is not in the SDK cache (isTopicCached) through the connection-level onInfoMessage hook to the same infoListeners used by topic-level routing (no duplicate delivery), and AppShell invalidates the shared ['friend-requests'] query namespace on friend.request.* events, keeping the pending badge live on any page. Zentao #3. --- web/src/app/AppShell.tsx | 20 +++++++++++++++++++- web/src/lib/tinode/topics.ts | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/web/src/app/AppShell.tsx b/web/src/app/AppShell.tsx index e72b10a2..8ab4a969 100644 --- a/web/src/app/AppShell.tsx +++ b/web/src/app/AppShell.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Outlet, useNavigate } from 'react-router-dom'; import { CallRoot } from '@/features/calls/CallRoot'; @@ -8,6 +8,7 @@ import { useBootstrap } from '@/lib/api/hooks'; import { getMe } from '@/lib/api/profile'; import { updateTabIndicator } from '@/lib/notifications/tabIndicator'; import { useChatList } from '@/lib/tinode/hooks'; +import { subscribeTopicInfo } from '@/lib/tinode/topics'; import { initCallSignaling } from '@/stores/calls'; import { useNotificationNavStore } from '@/stores/notificationNav'; @@ -35,6 +36,23 @@ export function AppShell() { useEffect(() => { initCallSignaling(); }, []); + const queryClient = useQueryClient(); + // Friend-request realtime signals (Zentao #3): after a request is created/accepted/rejected the + // server pushes `{info what:"im" event:"friend.request.*"}` to the recipient's `me` topic + // (publishIMRelationEvent -> sessionStore.DeliverToUser). Nothing here used to listen for it -- + // the SDK's _routeInfo hits its default case for what:"im" and only fires the onInfo chain -- so + // the 新朋友 badge/list stayed stale until a manual refresh or relogin. Subscribe once per app + // session (same shape as initCallSignaling above) and invalidate the shared ['friend-requests'] + // query namespace: ContactsListColumn's pending badge (['friend-requests','inbound','pending']) + // and NewFriendsView's list (['friend-requests','all']) both refetch on invalidation. Listening + // here rather than in those views keeps the badge live no matter which page the user is on. + useEffect(() => { + return subscribeTopicInfo((_topicName, info) => { + if (info?.event?.startsWith('friend.request.')) { + queryClient.invalidateQueries({ queryKey: ['friend-requests'] }); + } + }); + }, [queryClient]); // "sys" has its own authoritative unread source in entry_badges (bootstrap.ts) -- excluded here // in case it ever also shows up as a regular Tinode subscription in `contacts`, which would // otherwise double-count the same unread messages into this badge. "slf" gets no such exclusion: diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts index 84e4e08b..83a93c5a 100644 --- a/web/src/lib/tinode/topics.ts +++ b/web/src/lib/tinode/topics.ts @@ -128,6 +128,35 @@ export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicI return () => infoListeners.delete(callback); } +/** + * The SDK only routes an incoming {info} packet into Topic._routeInfo (and from there to + * topic.onInfo -> the infoListeners fanout above) when the packet's topic name is already in the + * client's topic cache -- tinode.dev.js's dispatcher does `#cacheGet('topic', pkt.info.topic)` and + * silently drops the frame on a miss. Friend-request signals (Zentao #3) arrive with `topic` set to + * the RECIPIENT'S OWN user id (publishIMRelationEvent -> sessionStore.DeliverToUser, i.e. addressed + * to the user's `me` topic), but the me topic is cached under the literal "me" -- so that lookup + * misses every time and the frame never reached any info listener. Live-confirmed 2026-09-18: the + * SDK's own logger showed the raw `friend.request.rejected` frame arriving on the socket while + * AppShell's subscribeTopicInfo callback never fired. The connection-level onInfoMessage hook sees + * EVERY info packet regardless of cache state, so forward ONLY the frames the topic-level path will + * not deliver (isTopicCached false) to the same infoListeners: cached topics keep flowing through + * topic.onInfo exactly once (no duplicates), uncached ones stop being silently lost. Both members + * are real SDK runtime members absent from the hand-maintained .d.ts (same cast rationale as the + * read/unread reads in getChatListSnapshot). Installed from subscribeMeTopic -- the one point every + * login flows through -- and re-installed there after resetTinode() rebuilds the instance. + */ +function installUncachedInfoFallback(t: unknown) { + const sdk = t as { + onInfoMessage?: ((info: RawTopicInfo & { topic: string }) => void) | null; + isTopicCached?: (topicName: string) => boolean; + }; + sdk.onInfoMessage = (info) => { + if (info && sdk.isTopicCached && !sdk.isTopicCached(info.topic)) { + infoListeners.forEach((cb) => cb(info.topic, info)); + } + }; +} + const goneListeners = new Set<(topicName: string) => void>(); const newGroupListeners = new Set<(topicName: string) => void>(); @@ -283,7 +312,9 @@ let inFlightMeSubscribe: Promise | null = null; /** Call once at session start (after login) -- chat-list metadata only, no message bodies. */ export function subscribeMeTopic(): Promise { if (inFlightMeSubscribe) return inFlightMeSubscribe; - const me = getTinode().getMeTopic(); + const tinode = getTinode(); + installUncachedInfoFallback(tinode); + const me = tinode.getMeTopic(); wireTopicCallbacks(me); // Read/recv/unread updates (e.g. from noteRead() when opening a conversation) travel through // TopicMe's OWN single-slot callback, separate from onData/onInfo/onPres -- wireTopicCallbacks