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
12 changes: 12 additions & 0 deletions Meshtastic.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,8 @@
DD1827FB0000000000000002 /* RegionCodesFirmwareLocaleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1827FA0000000000000002 /* RegionCodesFirmwareLocaleTests.swift */; };
DD007BAE2AA4E91200F5FA12 /* MyInfoEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */; };
DD007BB02AA5981000F5FA12 /* NodeInfoEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD007BAF2AA5981000F5FA12 /* NodeInfoEntityExtension.swift */; };
DDBEAC001000000000000300 /* MessageSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDBEAC001000000000000301 /* MessageSearch.swift */; };
DDBEAC001000000000000134 /* MessageSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDBEAC001000000000000124 /* MessageSearchTests.swift */; };
DD09240001E7FAD600E70001 /* MapLegend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD09240002E7FAD600E70001 /* MapLegend.swift */; };
DD0BE3102CB9FDC4000BA445 /* DetectionSensorEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0BE30F2CB9FDC4000BA445 /* DetectionSensorEnums.swift */; };
DD0D3D222A55CEB10066DB71 /* CocoaMQTT in Frameworks */ = {isa = PBXBuildFile; productRef = DD0D3D212A55CEB10066DB71 /* CocoaMQTT */; };
Expand Down Expand Up @@ -450,6 +452,7 @@
DD98EB2B2E7A838D0016320A /* AppIcon_MPowered.icon in Resources */ = {isa = PBXBuildFile; fileRef = DD98EB2A2E7A838D0016320A /* AppIcon_MPowered.icon */; };
DD994B69295F88B60013760A /* IntervalEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD994B68295F88B60013760A /* IntervalEnums.swift */; };
DD9C700D2E8D5A9500106227 /* ChannelMessageRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9C700C2E8D5A8900106227 /* ChannelMessageRow.swift */; };
DDBEAC001000000000000302 /* MessageSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDBEAC001000000000000303 /* MessageSearchBar.swift */; };
DD9C700F2E8DE80800106227 /* UserMessageRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9C700E2E8DE7FF00106227 /* UserMessageRow.swift */; };
DD9C70102E9F2A0000029299 /* DeviceOnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9C70112E9F2A0000029299 /* DeviceOnboardingTests.swift */; };
DD9C70112E916EBD00106227 /* UpdateIntervalPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9C70102E916EA200106227 /* UpdateIntervalPicker.swift */; };
Expand Down Expand Up @@ -990,6 +993,8 @@
DD1827FA0000000000000002 /* RegionCodesFirmwareLocaleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionCodesFirmwareLocaleTests.swift; sourceTree = "<group>"; };
DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyInfoEntityExtension.swift; sourceTree = "<group>"; };
DD007BAF2AA5981000F5FA12 /* NodeInfoEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityExtension.swift; sourceTree = "<group>"; };
DDBEAC001000000000000301 /* MessageSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSearch.swift; sourceTree = "<group>"; };
DDBEAC001000000000000124 /* MessageSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSearchTests.swift; sourceTree = "<group>"; };
DD04804A2E9295A5005F946C /* MeshtasticDataModelV 55.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 55.xcdatamodel"; sourceTree = "<group>"; };
DD05296F2B77F454008E44CD /* MeshtasticDataModelV 26.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 26.xcdatamodel"; sourceTree = "<group>"; };
DD0836AB2DE7C7CB00A3A973 /* MeshtasticDataModelV 52.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 52.xcdatamodel"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1120,6 +1125,7 @@
DD994B68295F88B60013760A /* IntervalEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalEnums.swift; sourceTree = "<group>"; };
DD9A1A912BA2D2D3001E602E /* MeshtasticDataModelV 30.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 30.xcdatamodel"; sourceTree = "<group>"; };
DD9C700C2E8D5A8900106227 /* ChannelMessageRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMessageRow.swift; sourceTree = "<group>"; };
DDBEAC001000000000000303 /* MessageSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSearchBar.swift; sourceTree = "<group>"; };
DD9C700E2E8DE7FF00106227 /* UserMessageRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMessageRow.swift; sourceTree = "<group>"; };
DD9C70102E916EA200106227 /* UpdateIntervalPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateIntervalPicker.swift; sourceTree = "<group>"; };
DD9C70112E9F2A0000029299 /* DeviceOnboardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceOnboardingTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1698,6 +1704,7 @@
AAD14FA280F0A130730DEB93 /* DiscoveryScanEngineTests.swift */,
DDBEAC0010000000000000C1 /* DiscoveredBeaconEntityTests.swift */,
DDBEAC001000000000000121 /* ModemPresetFrequencySlotTests.swift */,
DDBEAC001000000000000124 /* MessageSearchTests.swift */,
DDBEAC001000000000000122 /* ModemPresetSnrLimitTests.swift */,
DDBEAC001000000000000141 /* BeaconAddVsSwitchTests.swift */,
DDBEAC001000000000000201 /* MeshBeaconConfigEditorTests.swift */,
Expand Down Expand Up @@ -1927,6 +1934,7 @@
6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */,
DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */,
DD007BAF2AA5981000F5FA12 /* NodeInfoEntityExtension.swift */,
DDBEAC001000000000000301 /* MessageSearch.swift */,
DD5394FD276BA0EF00AD86B1 /* PositionEntityExtension.swift */,
DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */,
DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */,
Expand Down Expand Up @@ -2366,6 +2374,7 @@
children = (
DD9C700E2E8DE7FF00106227 /* UserMessageRow.swift */,
DD9C700C2E8D5A8900106227 /* ChannelMessageRow.swift */,
DDBEAC001000000000000303 /* MessageSearchBar.swift */,
D9C9839E2B79D0C600BDBE6A /* TextMessageField */,
DDB8F4132A9EE5F000230ECE /* ChannelList.swift */,
DD798B062915928D005217CD /* ChannelMessageList.swift */,
Expand Down Expand Up @@ -2841,6 +2850,7 @@
C54AC9F4DA920A4F718CE21A /* DiscoveryScanEngineTests.swift in Sources */,
DDBEAC0010000000000000D1 /* DiscoveredBeaconEntityTests.swift in Sources */,
DDBEAC001000000000000131 /* ModemPresetFrequencySlotTests.swift in Sources */,
DDBEAC001000000000000134 /* MessageSearchTests.swift in Sources */,
DDBEAC001000000000000132 /* ModemPresetSnrLimitTests.swift in Sources */,
DDBEAC001000000000000151 /* BeaconAddVsSwitchTests.swift in Sources */,
DDBEAC001000000000000211 /* MeshBeaconConfigEditorTests.swift in Sources */,
Expand Down Expand Up @@ -3145,6 +3155,7 @@
AA2054D12F9E000100000001 /* MessageDeliveryStatus.swift in Sources */,
6DEDA55C2A9592F900321D2E /* MessageEntityExtension.swift in Sources */,
DD9C700D2E8D5A9500106227 /* ChannelMessageRow.swift in Sources */,
DDBEAC001000000000000302 /* MessageSearchBar.swift in Sources */,
2315D1A02EECB44800E0FAE7 /* UF2MassStorageView.swift in Sources */,
DDDB444229F8A88700EE2349 /* Double.swift in Sources */,
DDF45C342BC1A48E005ED5F2 /* MQTTIcon.swift in Sources */,
Expand Down Expand Up @@ -3189,6 +3200,7 @@
BCD7448D2E0F2FAA00F265A2 /* ContactURLHandler.swift in Sources */,
BEEF00102F00000100000002 /* MeshtasticChannelURL.swift in Sources */,
DD007BB02AA5981000F5FA12 /* NodeInfoEntityExtension.swift in Sources */,
DDBEAC001000000000000300 /* MessageSearch.swift in Sources */,
DDDB26422AABF655003AFCB7 /* NodeListItem.swift in Sources */,
AB61EBF12E3326DC00B35962 /* NodeListItemCompact.swift in Sources */,
AB6E72762E3FC85A00639328 /* LayoutEnums.swift in Sources */,
Expand Down
160 changes: 160 additions & 0 deletions Meshtastic/Extensions/SwiftData/MessageSearch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//
// MessageSearch.swift
// Meshtastic
//
// "Find in conversation" content search over the stored message text.
//
// Scoped per-conversation (a channel, or a direct-message thread with one user), matching
// Android's find-in-conversation UX. The search runs against the whole message store — not
// the currently-loaded window — so messages that predate the current session are included.
// Matching is case- and diacritic-insensitive substring (`localizedStandardContains`).
//

import Foundation
@preconcurrency import SwiftData

/// A lightweight, timeline-ordered handle to a search match. Carries the timestamp/id cursor so a
/// caller can both scroll to the match and work out how far back in the conversation it sits
/// (to expand a windowed message list until the match is loaded).
struct MessageSearchMatch: Equatable {
let messageId: Int64
let timestamp: Int32
}

/// Runs conversation content search on a background context so the (unindexed) `CONTAINS`
/// scan never blocks the main actor. Results are `MessageSearchMatch` value types, so they
/// cross the actor boundary safely. The context is read-only for search — it sees saved
/// messages (which is all the list displays anyway).
@ModelActor
actor MessageSearchActor {
func channelMatches(channelIndex: Int32, query: String) throws -> [MessageSearchMatch] {
try MessageSearch.channelMatches(in: modelContext, channelIndex: channelIndex, query: query)
}

func directMatches(userNum: Int64, query: String) throws -> [MessageSearchMatch] {
try MessageSearch.directMatches(in: modelContext, userNum: userNum, query: query)
}
}

enum MessageSearch {

// MARK: Channel conversations

/// Matches within a channel conversation, oldest → newest.
// Note: `$0.toUser == nil` is a relationship-vs-nil comparison, which mis-counts / crashes
// SwiftData only when used *bare* (see the warnings in MyInfoEntityExtension / ChannelEntityExtension).
// It's safe here because a concrete scalar term leads the predicate (`channel == channelIndex`),
// matching the shape ChannelMessageList already ships in its own fetches. Exercised on iOS 26 by
// MessageSearchTests (empty and non-empty results), which keeps this shape honest.
static func channelMatches(in context: ModelContext, channelIndex: Int32, query: String) throws -> [MessageSearchMatch] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
var descriptor = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> {
$0.channel == channelIndex && $0.toUser == nil && $0.isEmoji == false
&& ($0.messagePayload?.localizedStandardContains(trimmed) ?? false)
},
sortBy: [
SortDescriptor(\MessageEntity.messageTimestamp, order: .forward),
SortDescriptor(\MessageEntity.messageId, order: .forward)
]
)
// Only the id/timestamp are needed to build matches — avoid hydrating message text + relationships.
descriptor.propertiesToFetch = [\.messageId, \.messageTimestamp]
return try context.fetch(descriptor).map { MessageSearchMatch(messageId: $0.messageId, timestamp: $0.messageTimestamp) }
}

/// Number of channel messages strictly newer than `match` in the timeline. Used to expand a
/// windowed list (newest-first) until the match becomes visible: the match sits at rank
/// `newerCount` from the newest message, so a window of `newerCount + 1` includes it.
static func channelNewerCount(in context: ModelContext, channelIndex: Int32, than match: MessageSearchMatch) throws -> Int {
let ts = match.timestamp
let mid = match.messageId
let newerByTimestamp = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> {
$0.channel == channelIndex && $0.toUser == nil && $0.isEmoji == false && $0.messageTimestamp > ts
}
)
let sameTimestampNewerId = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> {
$0.channel == channelIndex && $0.toUser == nil && $0.isEmoji == false
&& $0.messageTimestamp == ts && $0.messageId > mid
}
)
return try context.fetchCount(newerByTimestamp) + context.fetchCount(sameTimestampNewerId)
}

// MARK: Direct-message conversations

/// Matches within a direct-message thread with `userNum` (both incoming and outgoing),
/// oldest → newest. Excludes emoji tapbacks, admin, and detection-sensor traffic to mirror the
/// message list's own filtering.
static func directMatches(in context: ModelContext, userNum: Int64, query: String) throws -> [MessageSearchMatch] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
let detectionSensorPortNum: Int32 = 10
// `toUser != nil` is safe for the same reason as the channel search: a concrete scalar term
// (`fromUser?.num == userNum`) leads the predicate, mirroring fetchIncomingMessages' shipping shape.
var incoming = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> {
$0.fromUser?.num == userNum && $0.toUser != nil
&& $0.isEmoji == false && $0.admin == false && $0.portNum != detectionSensorPortNum
&& ($0.messagePayload?.localizedStandardContains(trimmed) ?? false)
}
)
var outgoing = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> {
$0.toUser?.num == userNum
&& $0.isEmoji == false && $0.admin == false && $0.portNum != detectionSensorPortNum
&& ($0.messagePayload?.localizedStandardContains(trimmed) ?? false)
}
)
// Only the id/timestamp are needed to build matches — avoid hydrating message text + relationships.
incoming.propertiesToFetch = [\.messageId, \.messageTimestamp]
outgoing.propertiesToFetch = [\.messageId, \.messageTimestamp]
let matches = try context.fetch(incoming) + context.fetch(outgoing)
return matches
.sorted {
if $0.messageTimestamp == $1.messageTimestamp { return $0.messageId < $1.messageId }
return $0.messageTimestamp < $1.messageTimestamp
}
.map { MessageSearchMatch(messageId: $0.messageId, timestamp: $0.messageTimestamp) }
}

/// Number of direct messages strictly newer than `match`, across incoming and outgoing.
static func directNewerCount(in context: ModelContext, userNum: Int64, than match: MessageSearchMatch) throws -> Int {
let ts = match.timestamp
let mid = match.messageId
let detectionSensorPortNum: Int32 = 10
let incomingNewer = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> {
$0.fromUser?.num == userNum && $0.toUser != nil
&& $0.isEmoji == false && $0.admin == false && $0.portNum != detectionSensorPortNum
&& $0.messageTimestamp > ts
}
)
let incomingSame = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> {
$0.fromUser?.num == userNum && $0.toUser != nil
&& $0.isEmoji == false && $0.admin == false && $0.portNum != detectionSensorPortNum
&& $0.messageTimestamp == ts && $0.messageId > mid
}
)
let outgoingNewer = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> {
$0.toUser?.num == userNum
&& $0.isEmoji == false && $0.admin == false && $0.portNum != detectionSensorPortNum
&& $0.messageTimestamp > ts
}
)
let outgoingSame = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> {
$0.toUser?.num == userNum
&& $0.isEmoji == false && $0.admin == false && $0.portNum != detectionSensorPortNum
&& $0.messageTimestamp == ts && $0.messageId > mid
}
)
return try context.fetchCount(incomingNewer) + context.fetchCount(incomingSame)
+ context.fetchCount(outgoingNewer) + context.fetchCount(outgoingSame)
}
}
35 changes: 22 additions & 13 deletions Meshtastic/Resources/docs/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -279,35 +279,44 @@
"navOrder": 3,
"keywords": [
"message",
"channel",
"key",
"not",
"circle",
"messages",
"fill",
"channels",
"text",
"key",
"not",
"formatting",
"available",
"orange",
"markdown",
"delimiters",
"direct",
"tap",
"selection",
"bold",
"from",
"button",
"channel",
"circle",
"fill",
"available",
"orange",
"retry",
"exclamationmark",
"direct",
"recipient",
"node",
"after",
"selection",
"bold",
"tap",
"public",
"button",
"symbol",
"radio",
"preview",
"press",
"from"
"encryption",
"destination",
"conversation",
"broadcast",
"verified",
"toolbar",
"tip",
"style",
"shield"
],
"charCount": 10415
},
Expand Down
12 changes: 12 additions & 0 deletions Meshtastic/Resources/docs/markdown/user/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@ 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.

- 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.

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

---

## Message Status

![Message status reference](../assets/screenshots/ackErrors.png)
Expand Down
9 changes: 9 additions & 0 deletions Meshtastic/Resources/docs/user/messages.html
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ <h3>Tapback Reactions</h3>
<div class="tips-callout"><strong>Tip — Messages</strong>
Send channel broadcasts and direct messages. Long press any message for actions like copy, reply, tapback, and delivery details.</div>
<hr />
<h2>Find in Conversation</h2>
<p>Tap the <strong>magnifying glass</strong> 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.</p>
<ul>
<li>Matching is <strong>case- and accent-insensitive</strong>, and searches the <strong>entire conversation history</strong> — not just the messages currently on screen.</li>
<li>The bar shows your position in the results (e.g. <strong>2/7</strong>); use the <strong>up/down chevrons</strong> 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.</li>
<li>Tap <strong>Done</strong> (or the magnifying glass again) to close the bar and clear the search.</li>
</ul>
<p>Search covers the text of channel broadcasts and direct messages, matching exactly the messages each conversation shows. Emoji reactions aren't matched.</p>
<hr />
<h2>Message Status</h2>
<p><img src="../assets/screenshots/ackErrors.png" alt="Message status reference" /></p>
<p>The message status row combines a short label, SF Symbol icon, and color. Color reinforces the text; it is not the only signal.</p>
Expand Down
Loading
Loading