@@ -1143,6 +1143,62 @@ actor MeshPackets {
11431143 }
11441144 }
11451145
1146+ /// Builds the notification body for a received tapback/reaction, or returns `nil` when the
1147+ /// reacted-to message isn't known locally (a "phantom" tapback). The reaction is still stored
1148+ /// as it is today — we just don't surface a notification for something there's nothing to show
1149+ /// for, matching Android's guard (`MeshDataHandlerImpl.rememberReaction`).
1150+ ///
1151+ /// Made `static` and context-injected (rather than reading `self.modelContext`) so the
1152+ /// phantom-tapback guard and body formatting can be exercised directly in unit tests.
1153+ static func reactionNotificationBody( replyID: Int64 , emoji: String ? , senderName: String , context: ModelContext ) -> String ? {
1154+ guard replyID > 0 else { return nil }
1155+ let descriptor = FetchDescriptor < MessageEntity > ( predicate: #Predicate { $0. messageId == replyID } )
1156+ guard let original = try ? context. fetch ( descriptor) . first else { return nil }
1157+ let originalText = original. messagePayload ?? " "
1158+ let reactionEmoji = ( emoji? . isEmpty == false ) ? emoji! : " ❤️ "
1159+ // Mirrors how iMessage phrases a tapback notification ("Name liked …").
1160+ return String . localizedStringWithFormat (
1161+ " %1$@ reacted %2$@ to \" %3$@ \" " . localized,
1162+ senderName, reactionEmoji, originalText
1163+ )
1164+ }
1165+
1166+ /// Builds a message/reaction local notification from a (context-bound) `MessageEntity`,
1167+ /// synchronously, so callers can hand the resulting value to a deferred `@MainActor` Task
1168+ /// without capturing the entity itself. Collapses the DM/channel × regular/reaction variants
1169+ /// that differ only in `content`, deep-link `path`, and `userNum`.
1170+ ///
1171+ /// `replyMessageId` is the message that tapback/reply notification actions should target. It
1172+ /// defaults to `message.messageId`, but for a reaction notification the caller passes the
1173+ /// original (reacted-to) message id so actions act on that message rather than the reaction
1174+ /// packet (which `messageId` — used for notification cancellation — must keep referencing).
1175+ private func makeMessageNotification(
1176+ message: MessageEntity ,
1177+ content: String ,
1178+ path: String ,
1179+ userNum: Int64 ? ,
1180+ critical: Bool ,
1181+ replyMessageId: Int64 ? = nil
1182+ ) -> Notification {
1183+ var notification = Notification (
1184+ id: ( " notification.id. \( message. messageId) " ) ,
1185+ title: " \( message. fromUser? . longName ?? " Unknown " . localized) " ,
1186+ subtitle: " AKA \( message. fromUser? . shortName ?? " ? " ) " ,
1187+ content: content,
1188+ target: " messages " ,
1189+ path: path,
1190+ messageId: message. messageId,
1191+ replyMessageId: replyMessageId ?? message. messageId,
1192+ channel: message. channel,
1193+ userNum: userNum,
1194+ critical: critical
1195+ )
1196+ #if os(iOS) && !targetEnvironment(macCatalyst)
1197+ notification. senderIntent = CarPlayIntentDonation . incomingMessageIntent ( from: message)
1198+ #endif
1199+ return notification
1200+ }
1201+
11461202 func textMessageAppPacket(
11471203 packet: MeshPacket ,
11481204 wantRangeTestPackets: Bool ,
@@ -1347,7 +1403,8 @@ actor MeshPackets {
13471403 // Send notifications if the message saved properly to core data
13481404 if messageSaved {
13491405 // Donate to SiriKit so the message appears in CarPlay Messages
1350- #if os(iOS)
1406+ // (CarPlay is iPhone-only, so skip on Mac Catalyst).
1407+ #if os(iOS) && !targetEnvironment(macCatalyst)
13511408 CarPlayIntentDonation . donateReceivedMessage ( newMessage)
13521409 #endif
13531410
@@ -1362,33 +1419,42 @@ actor MeshPackets {
13621419 appState? . unreadDirectMessages = unreadCount
13631420 }
13641421 }
1365- if !( newMessage. fromUser? . mute ?? false ) && newMessage . isEmoji == false {
1422+ if !( newMessage. fromUser? . mute ?? false ) {
13661423 // Build the notification from the model objects now, while this context is valid,
13671424 // and capture only the resulting value — a deferred Task must not hold `newMessage`
13681425 // (a context-bound model), since a device switch can reset this context first
13691426 // ("destroyed by ModelContext.reset").
13701427 let senderName = newMessage. fromUser? . longName ?? " Unknown " . localized
1371- var dmNotification = Notification (
1372- id: ( " notification.id. \( newMessage. messageId) " ) ,
1373- title: " \( senderName) " ,
1374- subtitle: " AKA \( newMessage. fromUser? . shortName ?? " ? " ) " ,
1375- content: messageText!,
1376- target: " messages " ,
1377- path: " meshtastic:///messages?userNum= \( newMessage. fromUser? . num ?? 0 ) &messageId= \( newMessage. isEmoji ? newMessage. replyID : newMessage. messageId) " ,
1378- messageId: newMessage. messageId,
1379- channel: newMessage. channel,
1380- userNum: Int64 ( packet. from) ,
1381- critical: critical
1382- )
1383- #if os(iOS)
1384- dmNotification. senderIntent = CarPlayIntentDonation . incomingMessageIntent ( from: newMessage)
1385- #endif
1386- let notification = dmNotification
1387- Task { @MainActor in
1388- let manager = LocalNotificationManager ( )
1389- manager. notifications = [ notification]
1390- manager. schedule ( )
1391- Logger . services. debug ( " iOS Notification Scheduled for text message from \( senderName, privacy: . public) " )
1428+ let dmUserNum = Int64 ( packet. from)
1429+ var dmNotification : Notification ?
1430+ if newMessage. isEmoji == false {
1431+ dmNotification = makeMessageNotification (
1432+ message: newMessage,
1433+ content: messageText!,
1434+ path: " meshtastic:///messages?userNum= \( newMessage. fromUser? . num ?? 0 ) &messageId= \( newMessage. messageId) " ,
1435+ userNum: dmUserNum,
1436+ critical: critical
1437+ )
1438+ } else if let reactionBody = MeshPackets . reactionNotificationBody ( replyID: newMessage. replyID, emoji: messageText, senderName: senderName, context: modelContext) {
1439+ // Tapback/reaction: only notify when the reacted-to message is known locally.
1440+ // A "phantom" tapback (replyID with no matching local message) is stored but not
1441+ // surfaced — reactionNotificationBody returns nil in that case.
1442+ dmNotification = makeMessageNotification (
1443+ message: newMessage,
1444+ content: reactionBody,
1445+ path: " meshtastic:///messages?userNum= \( newMessage. fromUser? . num ?? 0 ) &messageId= \( newMessage. replyID) " ,
1446+ userNum: dmUserNum,
1447+ critical: critical,
1448+ replyMessageId: newMessage. replyID
1449+ )
1450+ }
1451+ if let notification = dmNotification {
1452+ Task { @MainActor in
1453+ let manager = LocalNotificationManager ( )
1454+ manager. notifications = [ notification]
1455+ manager. schedule ( )
1456+ Logger . services. debug ( " iOS Notification Scheduled for direct message from \( senderName, privacy: . public) " )
1457+ }
13921458 }
13931459 }
13941460 } else if newMessage. fromUser != nil && newMessage. toUser == nil {
@@ -1406,25 +1472,34 @@ actor MeshPackets {
14061472 // to ~1/sec — the badge tolerates brief lag and resyncs on app-active and on read.
14071473 let recountUnread = shouldRecomputeChannelUnread ( )
14081474 let connectedNodeNum = connectedNode
1409- let shouldNotify = UserDefaults . channelMessageNotifications && newMessage. isEmoji == false && myInfo. channels. contains ( where: { $0. index == newMessage. channel && !$0. mute } )
1475+ let channelNotificationsEnabled = UserDefaults . channelMessageNotifications
1476+ && !( newMessage. fromUser? . mute ?? false )
1477+ && myInfo. channels. contains ( where: { $0. index == newMessage. channel && !$0. mute } )
1478+ let senderName = newMessage. fromUser? . longName ?? " Unknown " . localized
1479+ let channelUserNum = Int64 ( newMessage. fromUser? . userId ?? " 0 " )
14101480 var channelNotification : Notification ?
1411- if shouldNotify {
1412- var chNotification = Notification (
1413- id: ( " notification.id. \( newMessage. messageId) " ) ,
1414- title: " \( newMessage. fromUser? . longName ?? " Unknown " . localized) " ,
1415- subtitle: " AKA \( newMessage. fromUser? . shortName ?? " ? " ) " ,
1416- content: messageText!,
1417- target: " messages " ,
1418- path: " meshtastic:///messages?channelId= \( newMessage. channel) &messageId= \( newMessage. isEmoji ? newMessage. replyID : newMessage. messageId) " ,
1419- messageId: newMessage. messageId,
1420- channel: newMessage. channel,
1421- userNum: Int64 ( newMessage. fromUser? . userId ?? " 0 " ) ,
1422- critical: critical
1423- )
1424- #if os(iOS)
1425- chNotification. senderIntent = CarPlayIntentDonation . incomingMessageIntent ( from: newMessage)
1426- #endif
1427- channelNotification = chNotification
1481+ if channelNotificationsEnabled {
1482+ if newMessage. isEmoji == false {
1483+ channelNotification = makeMessageNotification (
1484+ message: newMessage,
1485+ content: messageText!,
1486+ path: " meshtastic:///messages?channelId= \( newMessage. channel) &messageId= \( newMessage. messageId) " ,
1487+ userNum: channelUserNum,
1488+ critical: critical
1489+ )
1490+ } else if let reactionBody = MeshPackets . reactionNotificationBody ( replyID: newMessage. replyID, emoji: messageText, senderName: senderName, context: modelContext) {
1491+ // Tapback/reaction: only notify when the reacted-to message is known
1492+ // locally. A "phantom" tapback is stored but not surfaced — the helper
1493+ // returns nil in that case, per Android's guard.
1494+ channelNotification = makeMessageNotification (
1495+ message: newMessage,
1496+ content: reactionBody,
1497+ path: " meshtastic:///messages?channelId= \( newMessage. channel) &messageId= \( newMessage. replyID) " ,
1498+ userNum: channelUserNum,
1499+ critical: critical,
1500+ replyMessageId: newMessage. replyID
1501+ )
1502+ }
14281503 }
14291504 let notification = channelNotification
14301505 Task { @MainActor in
0 commit comments