From 826a0e6d027cf30b8f0e76b4cf7076676358b17f Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 18 May 2026 19:32:37 +0300 Subject: [PATCH] add new notification types --- modules/BlueskyNSE/NotificationService.swift | 50 +++++++++++++++++- src/Navigation.tsx | 26 +++++++--- src/lib/hooks/useNotificationHandler.ts | 53 ++++++++++++++++---- 3 files changed, 110 insertions(+), 19 deletions(-) diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift index b441f48a91..cc70672f73 100644 --- a/modules/BlueskyNSE/NotificationService.swift +++ b/modules/BlueskyNSE/NotificationService.swift @@ -44,11 +44,20 @@ class NotificationService: UNNotificationServiceExtension { if reason == "chat-message" || reason == "chat-reaction" { mutateWithChatMessage(bestAttempt) + mutateChatMessageBody(bestAttempt, userInfo: request.content.userInfo) + mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo) let finalContent = createCommunicationNotification( from: bestAttempt, userInfo: request.content.userInfo ) contentHandler(finalContent) + } else if reason == "chat-added-to-group" + || reason == "chat-removed-from-group" + || reason == "chat-join-request-rejected" { + mutateWithChatMessage(bestAttempt) + mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo) + mutateWithBadge(bestAttempt) + contentHandler(bestAttempt) } else { mutateWithBadge(bestAttempt) contentHandler(bestAttempt) @@ -87,11 +96,18 @@ class NotificationService: UNNotificationServiceExtension { customIdentifier: nil ) + var speakableGroupName: INSpeakableString? = nil + if userInfo["convoKind"] as? String == "group", + let groupName = userInfo["convoGroupName"] as? String, + !groupName.isEmpty { + speakableGroupName = INSpeakableString(spokenPhrase: groupName) + } + let intent = INSendMessageIntent( recipients: nil, outgoingMessageType: .outgoingMessageText, content: content.body, - speakableGroupName: nil, + speakableGroupName: speakableGroupName, conversationIdentifier: convoId, serviceName: nil, sender: sender, @@ -157,6 +173,38 @@ class NotificationService: UNNotificationServiceExtension { } } + // For group convos, surface the group name as the notification subtitle. + // The sender's display name is shown as the title (overridden by + // `INSendMessageIntent` for chat-message/chat-reaction). + func mutateWithGroupSubtitle( + _ content: UNMutableNotificationContent, + userInfo: [AnyHashable: Any] + ) { + guard userInfo["convoKind"] as? String == "group", + let groupName = userInfo["convoGroupName"] as? String, + !groupName.isEmpty else { + return + } + content.subtitle = groupName + } + + // System messages (`add_member`, `convo_locked`, `edit_group`, etc.) are + // delivered through `chat-message` but have a server-rendered description + // in `title`. iOS overrides the title with the sender name once we apply + // `INSendMessageIntent`, so we move the title text into the body before + // building the intent. + func mutateChatMessageBody( + _ content: UNMutableNotificationContent, + userInfo: [AnyHashable: Any] + ) { + guard let messageKind = userInfo["messageKind"] as? String, + messageKind != "message", + !content.title.isEmpty else { + return + } + content.body = content.title + } + func mutateWithDefaultSound(_ content: UNMutableNotificationContent) { content.sound = UNNotificationSound.default } diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 7889f0ff5f..834aef0c51 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -22,8 +22,9 @@ import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import { + type ChatNotificationPayload, getNotificationPayload, - type NotificationPayload, + isChatNotificationPayload, notificationToURL, storePayloadForAccountSwitch, } from '#/lib/hooks/useNotificationHandler' @@ -926,14 +927,14 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { const linkingUrl = Linking.useLinkingURL() /** - * Handle navigation to a conversation, or prepares for account switch. + * Handle navigation to the messages tab, or prepares for account switch. * * Non-reactive because we need the latest data from some hooks * after an async call - sfn */ - const handleChatMessage = useNonReactiveCallback( - (payload: Extract) => { - notyLogger.debug(`handleChatMessage`, {payload}) + const handleChatNotification = useNonReactiveCallback( + (payload: ChatNotificationPayload) => { + notyLogger.debug(`handleChatNotification`, {payload}) if (payload.recipientDid !== currentAccount?.did) { // handled in useNotificationHandler after account switch finishes @@ -946,7 +947,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { } else { setShowLoggedOut(true) } - } else { + } else if ( + payload.reason === 'chat-message' || + payload.reason === 'chat-reaction' + ) { // @ts-expect-error nested navigators aren't typed -sfn navigate('MessagesTab', { screen: 'Messages', @@ -954,6 +958,12 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { pushToConversation: payload.convoId, }, }) + } else { + // chat-added-to-group, chat-removed-from-group, + // chat-join-request-rejected: open the Messages list without + // targeting a specific conversation + // @ts-expect-error nested navigators aren't typed -sfn + navigate('MessagesTab', {screen: 'Messages'}) } }, ) @@ -989,8 +999,8 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { causedBoot: true, }) - if (payload.reason === 'chat-message') { - handleChatMessage(payload) + if (isChatNotificationPayload(payload)) { + handleChatNotification(payload) } else { const path = notificationToURL(payload) diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts index 60d6ebabfe..0d761451f9 100644 --- a/src/lib/hooks/useNotificationHandler.ts +++ b/src/lib/hooks/useNotificationHandler.ts @@ -30,6 +30,9 @@ export type NotificationReason = | 'quote' | 'chat-message' | 'chat-reaction' + | 'chat-added-to-group' + | 'chat-removed-from-group' + | 'chat-join-request-rejected' | 'starterpack-joined' | 'like-via-repost' | 'repost-via-repost' @@ -37,6 +40,8 @@ export type NotificationReason = | 'unverified' | 'subscribed-post' +type ChatNotificationReason = Extract + /** * Manually overridden type, but retains the possibility of * `notification.request.trigger.payload` being `undefined`, as specified in @@ -45,7 +50,7 @@ export type NotificationReason = export type NotificationPayload = | undefined | { - reason: Exclude + reason: Exclude uri: string subject: string recipientDid: string @@ -62,6 +67,25 @@ export type NotificationPayload = messageId: string recipientDid: string } + | { + reason: + | 'chat-added-to-group' + | 'chat-removed-from-group' + | 'chat-join-request-rejected' + convoId: string + recipientDid: string + } + +export type ChatNotificationPayload = Extract< + NonNullable, + {reason: ChatNotificationReason} +> + +export function isChatNotificationPayload( + payload: NonNullable, +): payload is ChatNotificationPayload { + return payload.reason.startsWith('chat-') +} const DEFAULT_HANDLER_OPTIONS = { shouldShowBanner: false, @@ -199,10 +223,7 @@ export function useNotificationsHandler() { const handleNotification = (payload?: NotificationPayload) => { if (!payload) return - if ( - payload.reason === 'chat-message' || - payload.reason === 'chat-reaction' - ) { + if (isChatNotificationPayload(payload)) { logger.debug(`useNotificationsHandler: handling chat notification`, { payload, }) @@ -220,7 +241,10 @@ export function useNotificationsHandler() { } else { setShowLoggedOut(true) } - } else { + } else if ( + payload.reason === 'chat-message' || + payload.reason === 'chat-reaction' + ) { navigation.dispatch(state => { if (state.routes[0].name === 'Messages') { if ( @@ -253,6 +277,13 @@ export function useNotificationsHandler() { }) } }) + } else { + // chat-added-to-group, chat-removed-from-group, + // chat-join-request-rejected: open the Messages list without + // targeting a specific conversation + navigation.dispatch( + CommonActions.navigate('MessagesTab', {screen: 'Messages'}), + ) } } else { const url = notificationToURL(payload) @@ -280,8 +311,7 @@ export function useNotificationsHandler() { logger.debug('useNotificationsHandler: incoming', {e, payload}) if ( - (payload.reason === 'chat-message' || - payload.reason === 'chat-reaction') && + isChatNotificationPayload(payload) && payload.recipientDid === currentAccount?.did ) { const shouldAlert = payload.convoId !== currentConvoId @@ -352,8 +382,8 @@ export function useNotificationsHandler() { // Whenever there's a stored payload, that means we had to switch accounts before handling the notification. // Whenever currentAccount changes, we should try to handle it again. if ( - (storedAccountSwitchPayload?.reason === 'chat-message' || - storedAccountSwitchPayload?.reason === 'chat-reaction') && + storedAccountSwitchPayload && + isChatNotificationPayload(storedAccountSwitchPayload) && currentAccount?.did === storedAccountSwitchPayload.recipientDid ) { handleNotification(storedAccountSwitchPayload) @@ -442,6 +472,9 @@ export function notificationToURL(payload: NotificationPayload): string | null { } case 'chat-message': case 'chat-reaction': + case 'chat-added-to-group': + case 'chat-removed-from-group': + case 'chat-join-request-rejected': // should be handled separately return null case 'verified':