diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift index b441f48a91..18b63b1606 100644 --- a/modules/BlueskyNSE/NotificationService.swift +++ b/modules/BlueskyNSE/NotificationService.swift @@ -44,11 +44,26 @@ class NotificationService: UNNotificationServiceExtension { if reason == "chat-message" || reason == "chat-reaction" { mutateWithChatMessage(bestAttempt) + // Only apply the title->body swap for chat-message. For chat-reaction, + // `messageKind` refers to the reacted-to message, so we'd clobber the + // descriptive reaction body with the title for reactions on system + // messages. + if reason == "chat-message" { + 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,17 +102,33 @@ 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, attachments: nil ) + // For group convos, attach the composite group avatar (rendered by the + // ogcard service) to the `speakableGroupName` parameter so iOS shows it + // alongside the sender on the Communication Notification. + if userInfo["convoKind"] as? String == "group", + let convoAvatarUrlString = userInfo["convoAvatarUrl"] as? String, + let groupImage = downloadAvatarImage(from: convoAvatarUrlString) { + intent.setImage(groupImage, forParameterNamed: \.speakableGroupName) + } + let interaction = INInteraction(intent: intent, response: nil) interaction.direction = .incoming interaction.donate(completion: nil) @@ -157,6 +188,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..5f8d715f53 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,13 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { } else { setShowLoggedOut(true) } - } else { + } else if ( + payload.reason === 'chat-message' || + payload.reason === 'chat-reaction' || + payload.reason === 'chat-added-to-group' + ) { + // chat-added-to-group routes to the convo because the recipient was + // just added and now has access. // @ts-expect-error nested navigators aren't typed -sfn navigate('MessagesTab', { screen: 'Messages', @@ -954,6 +961,11 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { pushToConversation: payload.convoId, }, }) + } else { + // chat-removed-from-group, chat-join-request-rejected: the convo is + // no longer accessible to the recipient, so just open the list. + // @ts-expect-error nested navigators aren't typed -sfn + navigate('MessagesTab', {screen: 'Messages'}) } }, ) @@ -989,8 +1001,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..65332dea0b 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,13 @@ export function useNotificationsHandler() { } else { setShowLoggedOut(true) } - } else { + } else if ( + payload.reason === 'chat-message' || + payload.reason === 'chat-reaction' || + payload.reason === 'chat-added-to-group' + ) { + // chat-added-to-group routes to the convo because the recipient was + // just added and now has access. navigation.dispatch(state => { if (state.routes[0].name === 'Messages') { if ( @@ -253,6 +280,12 @@ export function useNotificationsHandler() { }) } }) + } else { + // chat-removed-from-group, chat-join-request-rejected: the convo is + // no longer accessible to the recipient, so just open the list. + navigation.dispatch( + CommonActions.navigate('MessagesTab', {screen: 'Messages'}), + ) } } else { const url = notificationToURL(payload) @@ -280,11 +313,16 @@ 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 + // chat-removed-from-group / chat-join-request-rejected always alert, + // even if the recipient is currently viewing the affected convo - + // they need to know they were removed/rejected. + const shouldAlert = + payload.reason === 'chat-removed-from-group' || + payload.reason === 'chat-join-request-rejected' || + payload.convoId !== currentConvoId return { shouldShowList: shouldAlert, shouldShowBanner: shouldAlert, @@ -352,8 +390,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 +480,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':