From 1a524345aac4066184ee97edaaec9ed4437b998a Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 30 Jul 2026 01:00:58 +0200 Subject: [PATCH] Implement notification deduplication with `collapseKey` to prevent stacking across messages/conversations. --- backend/api/src/helpers/private-messages.ts | 1 + backend/api/src/update-connection-interests.ts | 1 + backend/shared/src/mobile.ts | 7 +++++++ web/lib/service/android-push.ts | 4 +++- 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/api/src/helpers/private-messages.ts b/backend/api/src/helpers/private-messages.ts index af8f7de0..85c6574c 100644 --- a/backend/api/src/helpers/private-messages.ts +++ b/backend/api/src/helpers/private-messages.ts @@ -186,6 +186,7 @@ const notifyOtherUserInChannelIfInactive = async ( title: `${creator.name}`, body: textContent, url: `/messages/${channelId}`, + collapseKey: `channel-${channelId}`, } try { await sendWebNotifications(pg, receiverId, JSON.stringify(payload)) diff --git a/backend/api/src/update-connection-interests.ts b/backend/api/src/update-connection-interests.ts index 098acc42..a8dba35c 100644 --- a/backend/api/src/update-connection-interests.ts +++ b/backend/api/src/update-connection-interests.ts @@ -78,6 +78,7 @@ export const updateConnectionInterests: APIHandler<'update-connection-interest'> }, ), url: `/${currentUser.username}`, + collapseKey: `connection-${currentUser.id}`, } try { await sendWebNotifications(pg, targetUserId, JSON.stringify(payload)) diff --git a/backend/shared/src/mobile.ts b/backend/shared/src/mobile.ts index 7f060e80..6dc8258d 100644 --- a/backend/shared/src/mobile.ts +++ b/backend/shared/src/mobile.ts @@ -89,6 +89,9 @@ interface PushPayload { url: string data?: Record imageUrl?: string + // Notifications sharing a collapseKey replace each other instead of stacking, so a + // conversation only ever occupies one slot in the tray. See sendPushToToken. + collapseKey?: string } export async function sendPushToToken( @@ -100,10 +103,14 @@ export async function sendPushToToken( const message: TokenMessage = { token, android: { + // FCM-side: if the device is offline, only the latest message of a conversation is delivered + collapseKey: payload.collapseKey, notification: { title: payload.title, body: payload.body, imageUrl: payload.imageUrl || undefined, // 👈 publicly accessible HTTPS URL + // Tray-side: a new notification replaces the previous one carrying the same tag + tag: payload.collapseKey, }, }, data: { diff --git a/web/lib/service/android-push.ts b/web/lib/service/android-push.ts index 1d52bcb7..1edb9151 100644 --- a/web/lib/service/android-push.ts +++ b/web/lib/service/android-push.ts @@ -42,7 +42,9 @@ export default function AndroidPush() { if (endpoint === window.location.pathname) return const author = notif?.title const message = notif?.body - toast.success(`${author}: "${message}"`) + // Reuse the endpoint as the toast id so successive messages from the same + // conversation replace each other rather than piling up. + toast.success(`${author}: "${message}"`, {id: endpoint}) }) }, [user?.id, isAndroid])