fix: realtime friend-request badge via connection-level info fallback
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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
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 { Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { CallRoot } from '@/features/calls/CallRoot';
|
import { CallRoot } from '@/features/calls/CallRoot';
|
||||||
@@ -8,6 +8,7 @@ import { useBootstrap } from '@/lib/api/hooks';
|
|||||||
import { getMe } from '@/lib/api/profile';
|
import { getMe } from '@/lib/api/profile';
|
||||||
import { updateTabIndicator } from '@/lib/notifications/tabIndicator';
|
import { updateTabIndicator } from '@/lib/notifications/tabIndicator';
|
||||||
import { useChatList } from '@/lib/tinode/hooks';
|
import { useChatList } from '@/lib/tinode/hooks';
|
||||||
|
import { subscribeTopicInfo } from '@/lib/tinode/topics';
|
||||||
import { initCallSignaling } from '@/stores/calls';
|
import { initCallSignaling } from '@/stores/calls';
|
||||||
import { useNotificationNavStore } from '@/stores/notificationNav';
|
import { useNotificationNavStore } from '@/stores/notificationNav';
|
||||||
|
|
||||||
@@ -35,6 +36,23 @@ export function AppShell() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initCallSignaling();
|
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
|
// "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
|
// 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:
|
// otherwise double-count the same unread messages into this badge. "slf" gets no such exclusion:
|
||||||
|
|||||||
@@ -128,6 +128,35 @@ export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicI
|
|||||||
return () => infoListeners.delete(callback);
|
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 goneListeners = new Set<(topicName: string) => void>();
|
||||||
|
|
||||||
const newGroupListeners = new Set<(topicName: string) => void>();
|
const newGroupListeners = new Set<(topicName: string) => void>();
|
||||||
@@ -283,7 +312,9 @@ let inFlightMeSubscribe: Promise<unknown> | null = null;
|
|||||||
/** Call once at session start (after login) -- chat-list metadata only, no message bodies. */
|
/** Call once at session start (after login) -- chat-list metadata only, no message bodies. */
|
||||||
export function subscribeMeTopic(): Promise<unknown> {
|
export function subscribeMeTopic(): Promise<unknown> {
|
||||||
if (inFlightMeSubscribe) return inFlightMeSubscribe;
|
if (inFlightMeSubscribe) return inFlightMeSubscribe;
|
||||||
const me = getTinode().getMeTopic();
|
const tinode = getTinode();
|
||||||
|
installUncachedInfoFallback(tinode);
|
||||||
|
const me = tinode.getMeTopic();
|
||||||
wireTopicCallbacks(me);
|
wireTopicCallbacks(me);
|
||||||
// Read/recv/unread updates (e.g. from noteRead() when opening a conversation) travel through
|
// 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
|
// TopicMe's OWN single-slot callback, separate from onData/onInfo/onPres -- wireTopicCallbacks
|
||||||
|
|||||||
Reference in New Issue
Block a user