From 3fab8b08b96aeb253e36f66877b74277b10f2fd2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 22:25:55 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A2=AB=E6=8B=89=E5=85=A5=E6=96=B0=E7=BE=A4?= =?UTF-8?q?=E6=97=B6=E6=A0=87=E9=A2=98=E6=A0=8F/=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=B2=A1=E6=9C=89=E4=BB=BB=E4=BD=95=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E2=80=94=E2=80=94=E6=89=A9=E5=B1=95=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=9C=BA=E5=88=B6=E8=A6=86=E7=9B=96=E8=BF=99?= =?UTF-8?q?=E4=B8=AA=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconcileMeSubs()本来就能发现"此前完全不认识的话题"(今天早些时候为解决群聊 被动成员看不到新群加的兜底机制),但发现后只是原样信任服务端返回的read/unread 字段——服务端对一个刚加入的新成员,默认给的读游标经常是"视为已读"(read==seq), 如果这个群目前恰好一条消息都没有,unread就会是0,导致今天新加的标题栏滚动/ favicon角标(由同一个未读数驱动)完全没有反应,被拉群这件事本身悄无声息。 改成:对每一个新发现的话题,不管服务端给的read游标是什么,一律按"我自己完全 没确认过"处理(read清零,unread按当前seq计算);如果是群,额外通过 subscribeNewGroupDiscovered(新增的发布-订阅,push.ts订阅它去弹真正的桌面 通知"你被拉入了这个群聊",因为topics.ts不能反向依赖push.ts)触发一条独立的 桌面通知,不依赖未读数是否恰好大于0。 实机验证:systest04建一个只邀请systest05的全新群,systest05这边模拟切回 标签页触发兜底同步后,确认收到桌面通知(title/body/tag均正确)且标题栏 未读数正确增加。 Co-Authored-By: Claude Sonnet 5 --- web/src/lib/notifications/push.ts | 47 ++++++++++++++++++++++++------- web/src/lib/tinode/topics.ts | 28 +++++++++++++++++- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/web/src/lib/notifications/push.ts b/web/src/lib/notifications/push.ts index 22e8e194..fef67a73 100644 --- a/web/src/lib/notifications/push.ts +++ b/web/src/lib/notifications/push.ts @@ -6,7 +6,13 @@ import type { StoredMessage } from 'tinode-sdk'; import { formatMessagePreview } from '@/lib/messages/preview'; -import { getActiveConversationTopic, getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange } from '@/lib/tinode/topics'; +import { + getActiveConversationTopic, + getChatListSnapshot, + getMessagesSnapshot, + subscribeAnyTopicChange, + subscribeNewGroupDiscovered, +} from '@/lib/tinode/topics'; import { useNotificationNavStore } from '@/stores/notificationNav'; import { useSessionStore } from '@/stores/session'; @@ -85,16 +91,12 @@ function shouldNotify(topicName: string, msg: StoredMessage): boolean { return true; } -function fireNotification(topicName: string, msg: StoredMessage) { - const contact = getChatListSnapshot().find((c) => c.topic === topicName); - const title = contact?.public?.fn || topicName; - const myUserId = useSessionStore.getState().myUserId; - const body = formatMessagePreview(msg, myUserId) || '发来一条新消息'; +/** Shared by both real call sites below -- a new message, or being newly added to a group. `tag: + * topicName` makes a second notification from the same conversation REPLACE the first in the OS + * notification tray instead of stacking, matching every mainstream IM web client. */ +function showNotification(topicName: string, title: string, body: string) { let notif: Notification; try { - // `tag: topicName` -- a second notification from the same conversation REPLACES the first in the - // OS notification tray instead of stacking, matching every mainstream IM web client (you get one - // live-updating notification per conversation, not a pile of them from one chatty sender). notif = new Notification(title, { body, tag: topicName, icon: '/favicon.svg' }); } catch { // Some browsers/contexts throw constructing Notification even with permission granted (e.g. an @@ -109,13 +111,38 @@ function fireNotification(topicName: string, msg: StoredMessage) { }; } +function fireNotification(topicName: string, msg: StoredMessage) { + const contact = getChatListSnapshot().find((c) => c.topic === topicName); + const title = contact?.public?.fn || topicName; + const myUserId = useSessionStore.getState().myUserId; + const body = formatMessagePreview(msg, myUserId) || '发来一条新消息'; + showNotification(topicName, title, body); +} + +/** "被拉入群时title的消息没有提示" -- topics.ts's reconcileMeSubs() is the only thing that can even + * detect this (nothing else observes a group appearing that this client never knew about), so it + * fires this via subscribeNewGroupDiscovered rather than this module trying to infer "newly added" + * from message/seq activity, which a group with zero messages so far wouldn't have at all. */ +function onNewGroupDiscovered(topicName: string) { + if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return; + const contact = getChatListSnapshot().find((c) => c.topic === topicName); + if (contact?.private?.mute_notifications) return; + const title = contact?.public?.fn || topicName; + showNotification(topicName, title, '你被拉入了这个群聊'); +} + let unsubscribe: (() => void) | null = null; /** Call once per authenticated session (RequireAuth.tsx, alongside startMeReconciliation) and invoke * the returned function on logout/unmount. Safe to start/stop repeatedly. */ export function initMessageNotifications(): () => void { if (unsubscribe) return unsubscribe; - unsubscribe = subscribeAnyTopicChange(scheduleScan); + const unsubScan = subscribeAnyTopicChange(scheduleScan); + const unsubNewGroup = subscribeNewGroupDiscovered(onNewGroupDiscovered); + unsubscribe = () => { + unsubScan(); + unsubNewGroup(); + }; return () => { unsubscribe?.(); unsubscribe = null; diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts index 8e1cf39f..9750f63f 100644 --- a/web/src/lib/tinode/topics.ts +++ b/web/src/lib/tinode/topics.ts @@ -99,6 +99,18 @@ export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicI const goneListeners = new Set<(topicName: string) => void>(); +const newGroupListeners = new Set<(topicName: string) => void>(); + +/** Fires when reconcileMeSubs() (below) discovers a group topic this client has never seen before -- + * the "you were just added to a group" case (Zentao/user-reported: "被拉入群时title的消息没有提示"). + * Consumed by lib/notifications/push.ts to fire a desktop notification -- can't live there itself + * since push.ts already depends on topics.ts and the reverse would be a cycle, same reasoning as + * setActiveConversationTopic living here instead of in push.ts. */ +export function subscribeNewGroupDiscovered(callback: (topicName: string) => void): () => void { + newGroupListeners.add(callback); + return () => newGroupListeners.delete(callback); +} + /** Fires once a topic is confirmed gone (group dissolved by anyone, or removed from this account) -- * the SDK's own `Topic#_gone()` clears the topic's public/private/messages and calls * `onDeleteTopic()` (confirmed reading tinode.dev.js). Without ConversationView reacting to this, @@ -481,7 +493,21 @@ function reconcileMeSubs() { for (const c of getChatListSnapshot()) { if (!c.topic) continue; const prev = before.get(c.topic); - if (!prev) continue; // brand-new topic to this session -- exactly what this mitigation is for + if (!prev) { + // Brand-new topic to this session -- exactly what this mitigation is for. A fresh member's + // sub record can legitimately come back "already caught up" (read === seq) from the + // server's own default, which would otherwise leave being newly added to a group producing + // NO unread signal at all whenever it happens to have zero messages so far -- real, + // reported UX gap ("被拉入群时title的消息没有提示"): the tab-title/favicon-badge (see lib/ + // notifications/tabIndicator.ts) are driven by this same unread count, so nothing tells the + // user anything happened. Force it to read as genuinely unread from this client's own + // perspective regardless of what the server defaulted it to. + c.read = 0; + c.unread = c.seq || 0; + notifyTopic(c.topic); + if (c.topic.startsWith('grp')) newGroupListeners.forEach((cb) => cb(c.topic!)); + continue; + } const seq = c.seq || 0; const unread = c.unread || 0; if (seq <= prev.seq && unread > prev.unread) {