Skip to content

Add find-in-conversation message search#2079

Merged
garthvh merged 3 commits into
meshtastic:mainfrom
bruschill:feat/message-search-2045
Jul 16, 2026
Merged

Add find-in-conversation message search#2079
garthvh merged 3 commits into
meshtastic:mainfrom
bruschill:feat/message-search-2045

Conversation

@bruschill

@bruschill bruschill commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

What changed?

Adds find in conversation — per-conversation message content search — to both ChannelMessageList and UserMessageList, matching Android's UX (meshtastic/Meshtastic-Android#5373).

  • A search bar (magnifying-glass toolbar toggle) with a query field, live match count + current index ("2/7"), previous/next navigation (wraps around), and Done/clear.
  • Scoped to the open conversation, searching the entire history — not just the loaded window. Matching is case- and diacritic-insensitive substring.
  • The focused match is highlighted and scrolled into view; if it's older than the loaded window, the list expands to load it first.
  • New reusable MessageSearch helper (SwiftData) + shared MessageSearchBar view; search logic lives in same-file extensions to keep the view bodies focused.

Why did it change?

Per meshtastic/design#75, there was no way to search message content on iOS — .searchable only filtered the contact/node list, and the conversation views had no search at all. Long conversations were hard to navigate.

Per the issue, this uses an NSPredicate/#Predicate substring match (localizedStandardContains) rather than an FTS5 virtual table — the simpler first-pass approach the issue explicitly allows. Historical messages are included (no reinstall required).

Fixes #2045

Performance

Because the app already has performance-sensitive areas, the search path was built to stay off the hot path:

  • Off the main thread: the (unindexed) CONTAINS scan runs on a background @ModelActor (MessageSearchActor); only Sendable MessageSearchMatch value structs cross back to the main actor, so nothing blocks typing/scrolling regardless of conversation size.
  • Partial fetch: propertiesToFetch = [messageId, messageTimestamp] avoids hydrating message text + relationships for every hit.
  • Debounced (250ms) with a stale-result guard; zero work when the search bar is closed or the query is empty.
  • Match navigation uses cheap fetchCount "newer-count" queries (no object hydration) and only expands the window when jumping to an off-window match.

How is this tested?

New MessageSearchTests (6 tests): substring + case/diacritic-insensitivity, channel scoping + emoji exclusion, empty-query, channel newer-count, direct search spanning incoming+outgoing with user scoping + emoji exclusion, and direct newer-count. The tests assert exact match sets/counts.

Run and passing on both iOS 18.2 and iOS 26.3 (cross-version check because SwiftData relationship-vs-nil predicates have OS-specific miscount/crash caveats — the leading concrete scalar term keeps this shape safe, and the exact-count assertions would catch a regression on either OS). Full build-for-testing succeeds under Swift 6 concurrency checking; SourceKit-LSP clean; reviewed by an internal Swift reviewer (concurrency model approved).

Screenshots/Videos (when applicable)

UI: a find bar appears above the conversation with match position and up/down navigation; the current match is highlighted.

Checklist

  • My code adheres to the project's coding and style guidelines.
  • I have conducted a self-review of my code.
  • I have commented my code, particularly in complex areas.
  • I have verified whether these changes require updates to the in-app documentation under docs/user/ or docs/developer/, and updated accordingly (see copilot-instructions.md for the view → doc page mapping). If no doc update is needed, add the skip-docs-check label.
  • I have tested the change to ensure that it works as intended.

Summary by CodeRabbit

  • New Features
    • Added “Find in Conversation” search for channel broadcasts and direct messages via the magnifying glass.
    • Case- and accent-insensitive matching across the full conversation history, excluding emoji reactions/tapbacks.
    • Result navigation with position indicator, previous/next controls (with wraparound), and automatic loading of older messages.
    • Matches are highlighted in the message list.
  • Bug Fixes
    • Prevented highlight resets/jumps from clearing an updated target after delayed scrolling.
  • Documentation
    • Updated the Messages & Channels guide with search, navigation, and clearing/closing instructions.
  • Tests
    • Added SwiftData-backed message search tests (channels and direct messages, including “newer than” behavior).

Adds per-conversation content search to ChannelMessageList and
UserMessageList (issue meshtastic#2045), matching Android's find-in-conversation
UX: a search bar with live match count, current-match index, and
previous/next navigation, scoped to the open conversation and covering
the whole message history (not just the loaded window).

Search runs on a background @Modelactor (MessageSearchActor) so the
case/diacritic-insensitive substring scan never blocks the main thread,
returning Sendable MessageSearchMatch value structs. Results are
debounced (250ms), fetched with propertiesToFetch to avoid hydrating
message text/relationships, and skipped entirely when the search bar is
closed. Navigation expands the windowed list (via cheap fetchCount
newer-counts) until the match is loaded, then scrolls to and highlights
it (reusing the existing messageToHighlight binding, which now also gives
reply-jumps a visible highlight).

Adds MessageSearchTests (6 tests) covering substring/case/diacritic
matching, conversation scoping, emoji exclusion, ordering, and
newer-count; verified on iOS 18.2 and iOS 26.3.

Fixes meshtastic#2045
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 27de5793-5058-4a15-8214-9d59034e2864

📥 Commits

Reviewing files that changed from the base of the PR and between f197155 and 75bd52a.

📒 Files selected for processing (8)
  • Meshtastic.xcodeproj/project.pbxproj
  • Meshtastic/Resources/docs/index.json
  • Meshtastic/Resources/docs/markdown/user/messages.md
  • Meshtastic/Resources/docs/user/messages.html
  • Meshtastic/Views/Messages/ChannelMessageList.swift
  • Meshtastic/Views/Messages/ChannelMessageRow.swift
  • Meshtastic/Views/Messages/UserMessageRow.swift
  • docs/user/messages.md

📝 Walkthrough

Walkthrough

Adds per-conversation message-content search for channels and direct messages, with SwiftData queries, debounced navigation UI, match highlighting, tests, documentation, and Xcode project integration.

Changes

Conversation Search

Layer / File(s) Summary
SwiftData search APIs
Meshtastic/Extensions/SwiftData/MessageSearch.swift
Adds channel and direct-message matching, deterministic ordering, filtering, and newer-match counts through MessageSearchActor and MessageSearch.
Channel search interaction
Meshtastic/Views/Messages/MessageSearchBar.swift, Meshtastic/Views/Messages/ChannelMessageList.swift, Meshtastic/Views/Messages/ChannelMessageRow.swift
Adds debounced channel search, result navigation, automatic message loading and scrolling, and highlighted matches.
Direct-message search interaction
Meshtastic/Views/Messages/UserMessageList.swift, Meshtastic/Views/Messages/UserMessageRow.swift
Adds equivalent search state, navigation, stale-result handling, loading expansion, scrolling, and row highlighting for direct messages.
Validation, documentation, and project integration
MeshtasticTests/MessageSearchTests.swift, Meshtastic.xcodeproj/project.pbxproj, Meshtastic/Resources/docs/..., docs/user/messages.md
Registers new sources, tests channel and direct search behavior, and documents the find-in-conversation workflow.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant MessageSearchBar
  participant ChannelMessageList
  participant UserMessageList
  participant MessageSearchActor
  participant ModelContext
  User->>MessageSearchBar: enter query
  MessageSearchBar->>ChannelMessageList: update channel search state
  MessageSearchBar->>UserMessageList: update direct-message search state
  ChannelMessageList->>MessageSearchActor: request channel matches
  UserMessageList->>MessageSearchActor: request direct-message matches
  MessageSearchActor->>ModelContext: fetch matching messages
  ModelContext-->>MessageSearchActor: return match cursors
  MessageSearchActor-->>ChannelMessageList: return ordered channel matches
  MessageSearchActor-->>UserMessageList: return ordered direct-message matches
  ChannelMessageList-->>User: show count, scroll, and highlight
  UserMessageList-->>User: show count, scroll, and highlight
Loading

Poem

A rabbit taps search with a hop and a cheer,
Finds hidden old messages year after year.
Up, down, and around through the chat’s winding trail,
Bright yellow highlights make each match prevail.
“Done!” says the bunny, and wiggles its tail.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding in-conversation message search.
Description check ✅ Passed The description covers what changed, why, how it was tested, screenshots, and the checklist, matching the template well.
Linked Issues check ✅ Passed The PR implements per-conversation search, live count/index, next/previous navigation, and historical message coverage required by #2045.
Out of Scope Changes check ✅ Passed The added docs, tests, UI, and SwiftData search helper all support the requested search feature and appear in scope.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
Meshtastic/Views/Messages/UserMessageList.swift (2)

661-673: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

directNewerCount runs a full-store count scan on the main thread.

The substring directMatches scan was deliberately moved onto the background MessageSearchActor, but ensureLoaded still calls MessageSearch.directNewerCount(in: context, …) against the main-actor @Environment modelContext. That issues up to four fetchCount queries synchronously on the main thread whenever the user navigates to a match outside the loaded window. On large conversations this can jank the UI at exactly the moment the user expects smooth scroll-to-match.

Consider adding a directNewerCount method to MessageSearchActor and awaiting it, keeping parity with the async search path. Note this only fires for off-window matches, so impact is occasional.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Meshtastic/Views/Messages/UserMessageList.swift` around lines 661 - 673, Move
the directNewerCount query out of the main-actor ensureLoaded flow by adding an
asynchronous directNewerCount method to MessageSearchActor and invoking it with
await. Update ensureLoaded to handle the actor result before adjusting
messageLimit or calling loadMessages, while preserving the existing error
logging and loaded-window behavior.

574-692: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Extract the shared search/navigation flow
UserMessageList and ChannelMessageList still duplicate most of the search state machine. Only the match/count providers and a few conversation-specific strings differ; pulling the common debounce/navigation logic into a reusable helper would keep them from drifting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Meshtastic/Views/Messages/UserMessageList.swift` around lines 574 - 692,
Extract the duplicated search state-machine logic from UserMessageList,
including debouncedSearch, runSearch orchestration, focusMatch, next/previous
navigation, and closeSearch, into a reusable helper shared with
ChannelMessageList. Parameterize the helper with conversation-specific
match/count providers and differing strings while preserving existing loading,
stale-query, highlighting, and empty-result behavior in both views.
Meshtastic/Views/Messages/ChannelMessageList.swift (1)

456-460: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Search results go stale while new matching messages arrive.

.task(id: searchQuery) only re-runs when the query text changes. Unlike messages (refreshed via .onChange(of: appState.unreadChannelMessages) at Line 417), searchMatches/matchCount aren't refreshed when new messages land in the channel while the search bar is open, so a newly-arrived matching message won't show up in the live count until the user retypes or reopens search.

♻️ Proposed fix
 			.onChange(of: appState.unreadChannelMessages) {
 				refreshIfNeeded()
+				if isSearching { Task { await runSearch() } }
 			}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Meshtastic/Views/Messages/ChannelMessageList.swift` around lines 456 - 460,
Update the search-results refresh flow around debouncedSearch and the existing
unreadChannelMessages change handler so active searches rerun when new channel
messages arrive, not only when searchQuery changes. Preserve the current
debouncing behavior and ensure searchMatches and matchCount are refreshed using
the latest messages.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Meshtastic/Resources/docs/markdown/user/messages.md`:
- Line 111: Align the search behavior with the documentation by updating both
MessageSearch.channelMatches and channelNewerCount to exclude admin/system
messages using an admin == false predicate. Preserve existing channel filtering
and count behavior for non-admin messages.

In `@Meshtastic/Views/Messages/ChannelMessageList.swift`:
- Around line 574-587: The ensureLoaded function currently reloads only when the
required message count exceeds messageLimit. When the match is absent from
messages, always call loadMessages(markReadAfterLoad: false), while still
increasing messageLimit first when needed exceeds the current limit.

In `@Meshtastic/Views/Messages/ChannelMessageRow.swift`:
- Around line 173-178: Guard the delayed reset of messageToHighlight in
ChannelMessageRow so it only clears the highlight if the binding still
references the same messageId captured when the reply-jump timer was scheduled.
Preserve newer search highlights by skipping the reset when the value has
changed.

---

Nitpick comments:
In `@Meshtastic/Views/Messages/ChannelMessageList.swift`:
- Around line 456-460: Update the search-results refresh flow around
debouncedSearch and the existing unreadChannelMessages change handler so active
searches rerun when new channel messages arrive, not only when searchQuery
changes. Preserve the current debouncing behavior and ensure searchMatches and
matchCount are refreshed using the latest messages.

In `@Meshtastic/Views/Messages/UserMessageList.swift`:
- Around line 661-673: Move the directNewerCount query out of the main-actor
ensureLoaded flow by adding an asynchronous directNewerCount method to
MessageSearchActor and invoking it with await. Update ensureLoaded to handle the
actor result before adjusting messageLimit or calling loadMessages, while
preserving the existing error logging and loaded-window behavior.
- Around line 574-692: Extract the duplicated search state-machine logic from
UserMessageList, including debouncedSearch, runSearch orchestration, focusMatch,
next/previous navigation, and closeSearch, into a reusable helper shared with
ChannelMessageList. Parameterize the helper with conversation-specific
match/count providers and differing strings while preserving existing loading,
stale-query, highlighting, and empty-result behavior in both views.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b14fcbab-0712-4d19-b2e9-7f77fd4bca25

📥 Commits

Reviewing files that changed from the base of the PR and between 8e0eaaf and 9656d2b.

📒 Files selected for processing (12)
  • Meshtastic.xcodeproj/project.pbxproj
  • Meshtastic/Extensions/SwiftData/MessageSearch.swift
  • Meshtastic/Resources/docs/index.json
  • Meshtastic/Resources/docs/markdown/user/messages.md
  • Meshtastic/Resources/docs/user/messages.html
  • Meshtastic/Views/Messages/ChannelMessageList.swift
  • Meshtastic/Views/Messages/ChannelMessageRow.swift
  • Meshtastic/Views/Messages/MessageSearchBar.swift
  • Meshtastic/Views/Messages/UserMessageList.swift
  • Meshtastic/Views/Messages/UserMessageRow.swift
  • MeshtasticTests/MessageSearchTests.swift
  • docs/user/messages.md

Comment thread Meshtastic/Resources/docs/markdown/user/messages.md Outdated
Comment thread Meshtastic/Views/Messages/ChannelMessageList.swift
Comment thread Meshtastic/Views/Messages/ChannelMessageRow.swift
bruschill and others added 2 commits July 12, 2026 14:44
- ensureLoaded: reload the window whenever the search match isn't in the
  current messages array, not only when the window must grow — otherwise
  a match that should already be in range but isn't loaded left the
  highlight/scroll with nothing to target.
- Reply-jump's delayed highlight clear now only resets messageToHighlight
  when it still points at that jump's message, so it can't wipe a newer
  search highlight set in the intervening second.
- Correct the messages.md search note: channel search mirrors the channel
  list (which doesn't exclude admin), so drop the inaccurate blanket
  'system/admin traffic excluded' claim.
# Conflicts:
#	Meshtastic.xcodeproj/project.pbxproj
#	Meshtastic/Resources/docs/index.json
@garthvh
garthvh merged commit 35167f8 into meshtastic:main Jul 16, 2026
2 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add full-text search of message content (in-conversation)

2 participants