Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,9 @@
}
}
},
"%1$@ reacted %2$@ to \"%3$@\"" : {
"comment" : "Tapback/reaction notification body. %1$@ is the sender name, %2$@ is the reaction emoji, %3$@ is the original message text that was reacted to."
},
"%@" : {
"localizations" : {
"da" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,8 +379,9 @@ extension AccessoryManager {
try context.save()
Logger.data.info("πŸ’Ύ Saved a new sent message from \(self.activeDeviceNum?.toHex() ?? "0", privacy: .public) to \(toUserNum.toHex(), privacy: .public)")
// Donate outgoing message to SiriKit for CarPlay
// (CarPlay is iPhone-only, so skip on Mac Catalyst).
if !isEmoji {
#if os(iOS)
#if os(iOS) && !targetEnvironment(macCatalyst)
CarPlayIntentDonation.donateOutgoingMessage(content: message, toUserNum: toUserNum, channel: channel)
#endif
}
Expand Down
8 changes: 8 additions & 0 deletions Meshtastic/Helpers/LocalNotificationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ class LocalNotificationManager {
if notification.messageId != nil {
content.categoryIdentifier = "messageNotificationCategory"
content.userInfo["messageId"] = notification.messageId
// `messageId` identifies this notification for cancellation (keyed on the packet's
// own id). Tapback/reply actions must instead target the message being replied to,
// which differs for reactions β€” carry it separately, defaulting to `messageId`.
content.userInfo["replyMessageId"] = notification.replyMessageId ?? notification.messageId
}
if notification.channel != nil {
content.userInfo["channel"] = notification.channel
Expand Down Expand Up @@ -146,6 +150,10 @@ struct Notification {
var target: String?
var path: String?
var messageId: Int64?
/// Target message id for tapback/reply actions. For a reaction notification this is the
/// original (reacted-to) message rather than the reaction packet in `messageId`. `nil` means
/// "same as `messageId`" (regular messages).
var replyMessageId: Int64?
var channel: Int32?
var userNum: Int64?
var critical: Bool = false
Expand Down
157 changes: 116 additions & 41 deletions Meshtastic/Helpers/MeshPackets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,62 @@ actor MeshPackets {
}
}

/// Builds the notification body for a received tapback/reaction, or returns `nil` when the
/// reacted-to message isn't known locally (a "phantom" tapback). The reaction is still stored
/// as it is today β€” we just don't surface a notification for something there's nothing to show
/// for, matching Android's guard (`MeshDataHandlerImpl.rememberReaction`).
///
/// Made `static` and context-injected (rather than reading `self.modelContext`) so the
/// phantom-tapback guard and body formatting can be exercised directly in unit tests.
static func reactionNotificationBody(replyID: Int64, emoji: String?, senderName: String, context: ModelContext) -> String? {
guard replyID > 0 else { return nil }
let descriptor = FetchDescriptor<MessageEntity>(predicate: #Predicate { $0.messageId == replyID })
guard let original = try? context.fetch(descriptor).first else { return nil }
let originalText = original.messagePayload ?? ""
let reactionEmoji = (emoji?.isEmpty == false) ? emoji! : "❀️"
// Mirrors how iMessage phrases a tapback notification ("Name liked …").
return String.localizedStringWithFormat(
"%1$@ reacted %2$@ to \"%3$@\"".localized,
senderName, reactionEmoji, originalText
)
}

/// Builds a message/reaction local notification from a (context-bound) `MessageEntity`,
/// synchronously, so callers can hand the resulting value to a deferred `@MainActor` Task
/// without capturing the entity itself. Collapses the DM/channel Γ— regular/reaction variants
/// that differ only in `content`, deep-link `path`, and `userNum`.
///
/// `replyMessageId` is the message that tapback/reply notification actions should target. It
/// defaults to `message.messageId`, but for a reaction notification the caller passes the
/// original (reacted-to) message id so actions act on that message rather than the reaction
/// packet (which `messageId` β€” used for notification cancellation β€” must keep referencing).
private func makeMessageNotification(
message: MessageEntity,
content: String,
path: String,
userNum: Int64?,
critical: Bool,
replyMessageId: Int64? = nil
) -> Notification {
var notification = Notification(
id: ("notification.id.\(message.messageId)"),
title: "\(message.fromUser?.longName ?? "Unknown".localized)",
subtitle: "AKA \(message.fromUser?.shortName ?? "?")",
content: content,
target: "messages",
path: path,
messageId: message.messageId,
replyMessageId: replyMessageId ?? message.messageId,
channel: message.channel,
userNum: userNum,
critical: critical
)
#if os(iOS) && !targetEnvironment(macCatalyst)
notification.senderIntent = CarPlayIntentDonation.incomingMessageIntent(from: message)
#endif
return notification
}

func textMessageAppPacket(
packet: MeshPacket,
wantRangeTestPackets: Bool,
Expand Down Expand Up @@ -1347,7 +1403,8 @@ actor MeshPackets {
// Send notifications if the message saved properly to core data
if messageSaved {
// Donate to SiriKit so the message appears in CarPlay Messages
#if os(iOS)
// (CarPlay is iPhone-only, so skip on Mac Catalyst).
#if os(iOS) && !targetEnvironment(macCatalyst)
CarPlayIntentDonation.donateReceivedMessage(newMessage)
#endif

Expand All @@ -1362,33 +1419,42 @@ actor MeshPackets {
appState?.unreadDirectMessages = unreadCount
}
}
if !(newMessage.fromUser?.mute ?? false) && newMessage.isEmoji == false {
if !(newMessage.fromUser?.mute ?? false) {
// Build the notification from the model objects now, while this context is valid,
// and capture only the resulting value β€” a deferred Task must not hold `newMessage`
// (a context-bound model), since a device switch can reset this context first
// ("destroyed by ModelContext.reset").
let senderName = newMessage.fromUser?.longName ?? "Unknown".localized
var dmNotification = Notification(
id: ("notification.id.\(newMessage.messageId)"),
title: "\(senderName)",
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
content: messageText!,
target: "messages",
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)",
messageId: newMessage.messageId,
channel: newMessage.channel,
userNum: Int64(packet.from),
critical: critical
)
#if os(iOS)
dmNotification.senderIntent = CarPlayIntentDonation.incomingMessageIntent(from: newMessage)
#endif
let notification = dmNotification
Task {@MainActor in
let manager = LocalNotificationManager()
manager.notifications = [notification]
manager.schedule()
Logger.services.debug("iOS Notification Scheduled for text message from \(senderName, privacy: .public)")
let dmUserNum = Int64(packet.from)
var dmNotification: Notification?
if newMessage.isEmoji == false {
dmNotification = makeMessageNotification(
message: newMessage,
content: messageText!,
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)",
userNum: dmUserNum,
critical: critical
)
} else if let reactionBody = MeshPackets.reactionNotificationBody(replyID: newMessage.replyID, emoji: messageText, senderName: senderName, context: modelContext) {
// Tapback/reaction: only notify when the reacted-to message is known locally.
// A "phantom" tapback (replyID with no matching local message) is stored but not
// surfaced β€” reactionNotificationBody returns nil in that case.
dmNotification = makeMessageNotification(
message: newMessage,
content: reactionBody,
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.replyID)",
userNum: dmUserNum,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
critical: critical,
replyMessageId: newMessage.replyID
)
}
if let notification = dmNotification {
Task {@MainActor in
let manager = LocalNotificationManager()
manager.notifications = [notification]
manager.schedule()
Logger.services.debug("iOS Notification Scheduled for direct message from \(senderName, privacy: .public)")
}
}
}
} else if newMessage.fromUser != nil && newMessage.toUser == nil {
Expand All @@ -1406,25 +1472,34 @@ actor MeshPackets {
// to ~1/sec β€” the badge tolerates brief lag and resyncs on app-active and on read.
let recountUnread = shouldRecomputeChannelUnread()
let connectedNodeNum = connectedNode
let shouldNotify = UserDefaults.channelMessageNotifications && newMessage.isEmoji == false && myInfo.channels.contains(where: { $0.index == newMessage.channel && !$0.mute })
let channelNotificationsEnabled = UserDefaults.channelMessageNotifications
&& !(newMessage.fromUser?.mute ?? false)
&& myInfo.channels.contains(where: { $0.index == newMessage.channel && !$0.mute })
let senderName = newMessage.fromUser?.longName ?? "Unknown".localized
let channelUserNum = Int64(newMessage.fromUser?.userId ?? "0")
var channelNotification: Notification?
if shouldNotify {
var chNotification = Notification(
id: ("notification.id.\(newMessage.messageId)"),
title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)",
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
content: messageText!,
target: "messages",
path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)",
messageId: newMessage.messageId,
channel: newMessage.channel,
userNum: Int64(newMessage.fromUser?.userId ?? "0"),
critical: critical
)
#if os(iOS)
chNotification.senderIntent = CarPlayIntentDonation.incomingMessageIntent(from: newMessage)
#endif
channelNotification = chNotification
if channelNotificationsEnabled {
if newMessage.isEmoji == false {
channelNotification = makeMessageNotification(
message: newMessage,
content: messageText!,
path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)",
userNum: channelUserNum,
critical: critical
)
} else if let reactionBody = MeshPackets.reactionNotificationBody(replyID: newMessage.replyID, emoji: messageText, senderName: senderName, context: modelContext) {
// Tapback/reaction: only notify when the reacted-to message is known
// locally. A "phantom" tapback is stored but not surfaced β€” the helper
// returns nil in that case, per Android's guard.
channelNotification = makeMessageNotification(
message: newMessage,
content: reactionBody,
path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.replyID)",
userNum: channelUserNum,
critical: critical,
replyMessageId: newMessage.replyID
)
}
}
let notification = channelNotification
Task {@MainActor in
Expand Down
6 changes: 3 additions & 3 deletions Meshtastic/MeshtasticAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
}
case "messageNotification.thumbsUpAction":
if let channel = userInfo["channel"] as? Int32,
let replyID = userInfo["messageId"] as? Int64 {
let replyID = userInfo["replyMessageId"] as? Int64 ?? userInfo["messageId"] as? Int64 {
Task {
do {
try await AccessoryManager.shared.sendMessage(
Expand All @@ -142,7 +142,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
}
case "messageNotification.thumbsDownAction":
if let channel = userInfo["channel"] as? Int32,
let replyID = userInfo["messageId"] as? Int64 {
let replyID = userInfo["replyMessageId"] as? Int64 ?? userInfo["messageId"] as? Int64 {
Task {
do {
try await AccessoryManager.shared.sendMessage(
Expand All @@ -161,7 +161,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
case "messageNotification.replyInputAction":
if let userInput = (response as? UNTextInputNotificationResponse)?.userText,
let channel = userInfo["channel"] as? Int32,
let replyID = userInfo["messageId"] as? Int64 {
let replyID = userInfo["replyMessageId"] as? Int64 ?? userInfo["messageId"] as? Int64 {
Task {
do {
try await AccessoryManager.shared.sendMessage(
Expand Down
24 changes: 12 additions & 12 deletions Meshtastic/Resources/docs/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"text",
"fill",
"channels",
"formatting",
"direct",
"formatting",
"available",
"orange",
"markdown",
Expand All @@ -297,19 +297,19 @@
"tap",
"recipient",
"node",
"conversation",
"after",
"selection",
"bold",
"radio",
"public",
"field",
"button",
"symbol",
"radio",
"preview",
"press",
"from",
"encrypted"
"press"
],
"charCount": 11223
"charCount": 11637
},
{
"id": "mqtt",
Expand Down Expand Up @@ -672,14 +672,15 @@
"jun",
"settings",
"nodes",
"node",
"messages",
"node",
"map",
"page",
"now",
"mesh",
"local",
"channel",
"now",
"quot",
"show",
"region",
"list",
Expand All @@ -688,18 +689,17 @@
"app",
"signed",
"radios",
"quot",
"per",
"only",
"message",
"jul",
"docs",
"amp",
"waypoints",
"watch",
"verified",
"translation"
"verified"
],
"charCount": 6314
"charCount": 6609
},
{
"id": "adding-features",
Expand Down
8 changes: 5 additions & 3 deletions Meshtastic/Resources/docs/markdown/user/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ A green shield (πŸ›‘οΈ) on a broadcast message bubble means the message is **si

Long press any message and tap **Tapback** to send an emoji reaction.

When someone reacts to a message, you get a notification such as **Alice reacted πŸ‘ to "See you soon"** β€” unless the sender (for a direct message) or the channel is muted. Reactions notify **without** adding to the conversation's unread badge. If your radio hasn't seen the message that was reacted to, the reaction is saved but no notification is shown (there'd be nothing to display it against).

---

{: .tip }
Expand All @@ -102,11 +104,11 @@ Long press any message and tap **Tapback** to send an emoji reaction.

## Find in Conversation

Tap the **magnifying glass** in the top-right of any channel or direct-message conversation to open the find bar, then type to search that conversation's message text.
Tap the **Find in conversation** search field below the title of any channel or direct-message conversation, then type to search that conversation's message text.

- Matching is **case- and accent-insensitive**, and searches the **entire conversation history** β€” not just the messages currently on screen.
- The bar shows your position in the results (e.g. **2/7**); use the **up/down chevrons** to jump to the previous or next match, wrapping around at the ends. The current match is highlighted and scrolled into view, loading older messages automatically if needed.
- Tap **Done** (or the magnifying glass again) to close the bar and clear the search.
- A results bar shows your position in the matches (e.g. **2 of 7**); use the **up/down chevrons** to jump to the previous or next match, wrapping around at the ends. The current match is highlighted and scrolled into view, loading older messages automatically if needed.
- Tap **Cancel** (or clear the field) to dismiss the search and return to the conversation.

Search covers the text of channel broadcasts and direct messages, matching exactly the messages each conversation shows. Emoji reactions aren't matched.

Expand Down
2 changes: 2 additions & 0 deletions Meshtastic/Resources/docs/markdown/user/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Recent user-facing changes from roughly the last 12 months. Newest at the top.
Show roughly the last 12 months of changes; archive entries older than a year by removing them.
-->

**Jul 2026** β€” [Messages](messages.md) β€” Received tapback reactions now notify (e.g. "Alice reacted πŸ‘ to …"), matching iMessage and Meshtastic-Android; muted conversations are respected, the unread-badge behavior is unchanged, and reactions to messages your radio never saw are stored silently.

**Jul 2026** β€” [Local Mesh Discovery](discovery.md) β€” Mesh beacons: scans capture beacons advertising a mesh (message, channel, region, preset) and show them in the results. Public-channel presets are auto-added to the scan and pre-selected next time; custom-channel beacons are tuned into directly (name + key) so even private meshes are found. Each beaconed channel gets a Switch to this channel button to join it.

**Jul 2026** β€” [Map & Waypoints](map.md) β€” Precise locations only: a new Map Options toggle hides nodes that broadcast an approximate (reduced-precision) location β€” the ones drawn with a translucent precision circle β€” so the mesh map shows only nodes reporting an exact position.
Expand Down
Loading
Loading