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
28 changes: 4 additions & 24 deletions Meshtastic/Views/Messages/ChannelMessageList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ struct ChannelMessageList: View {
@State private var messageToHighlight: Int64 = 0
@State private var messageLimit: Int = 100
@State private var messages: [MessageEntity] = []
@State private var isSearching = false
@State private var searchQuery = ""
@State private var searchMatches: [MessageSearchMatch] = []
@State private var currentMatchIndex = -1
Expand Down Expand Up @@ -335,7 +334,7 @@ struct ChannelMessageList: View {

var body: some View {
VStack(spacing: 0) {
if isSearching { searchBar }
if !searchQuery.isEmpty { searchBar }
ScrollViewReader { scrollView in
ScrollView {
LazyVStack {
Expand Down Expand Up @@ -458,9 +457,10 @@ struct ChannelMessageList: View {
}
}
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: "Find in conversation")
.autocorrectionDisabled()
.task(id: searchQuery) { await debouncedSearch() }
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) { searchToolbarButton }
ToolbarItem(placement: .principal) {
HStack {
CircleText(text: String(channel.index), color: .accentColor, circleSize: 44).fixedSize()
Expand Down Expand Up @@ -495,12 +495,10 @@ struct ChannelMessageList: View {
private extension ChannelMessageList {
@ViewBuilder var searchBar: some View {
MessageSearchBar(
query: $searchQuery,
matchCount: searchMatches.count,
currentIndex: currentMatchIndex,
onPrevious: goToPreviousMatch,
onNext: goToNextMatch,
onClose: closeSearch
onNext: goToNextMatch
)
}

Expand All @@ -513,20 +511,10 @@ private extension ChannelMessageList {
}
}

@ViewBuilder var searchToolbarButton: some View {
Button {
if isSearching { closeSearch() } else { isSearching = true }
} label: {
Image(systemName: isSearching ? "magnifyingglass.circle.fill" : "magnifyingglass")
}
.accessibilityLabel("Find in conversation")
}

/// Debounces search so a full-store scan doesn't run on every keystroke. Cancelled and
/// restarted by `.task(id: searchQuery)` whenever the query changes.
@MainActor
func debouncedSearch() async {
guard isSearching else { return }
// Clearing the field should empty the results immediately, not after the debounce.
guard !searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
await runSearch()
Expand Down Expand Up @@ -600,12 +588,4 @@ private extension ChannelMessageList {
guard !searchMatches.isEmpty else { return }
focusMatch(at: currentMatchIndex - 1 < 0 ? searchMatches.count - 1 : currentMatchIndex - 1)
}

func closeSearch() {
isSearching = false
searchQuery = ""
searchMatches = []
currentMatchIndex = -1
messageToHighlight = -1
}
}
74 changes: 21 additions & 53 deletions Meshtastic/Views/Messages/MessageSearchBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,83 +2,51 @@
// MessageSearchBar.swift
// Meshtastic
//
// Shared "find in conversation" bar for ChannelMessageList / UserMessageList.
// Live match count + current index, previous/next navigation, and a close button.
// Shared "find in conversation" results bar for ChannelMessageList / UserMessageList:
// live match count + current index and previous/next navigation. The search field itself
// is provided by each list's `.searchable` modifier; this bar sits just beneath it while
// a query is active.
//

import SwiftUI

struct MessageSearchBar: View {
@Binding var query: String
/// Total number of matches for the current query.
let matchCount: Int
/// Zero-based index of the currently-focused match, or -1 when there are none.
let currentIndex: Int
var onPrevious: () -> Void
var onNext: () -> Void
var onClose: () -> Void

@FocusState private var focused: Bool

var body: some View {
HStack(spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Find in conversation", text: $query)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.submitLabel(.search)
.focused($focused)
.onSubmit(onNext)
if !query.isEmpty {
Button {
query = ""
focused = true
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
.accessibilityLabel("Clear search")
}
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
HStack(spacing: 16) {
Text(positionText)
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
.accessibilityLabel(matchCount == 0 ? Text("No matches") : Text("Match \(currentIndex + 1) of \(matchCount)"))

if !query.isEmpty {
Text(positionText)
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
.accessibilityLabel(matchCount == 0 ? Text("No matches") : Text("Match \(currentIndex + 1) of \(matchCount)"))
Spacer()

Button(action: onPrevious) {
Image(systemName: "chevron.up")
}
.disabled(matchCount == 0)
.accessibilityLabel("Previous match")

Button(action: onNext) {
Image(systemName: "chevron.down")
}
.disabled(matchCount == 0)
.accessibilityLabel("Next match")
Button(action: onPrevious) {
Image(systemName: "chevron.up")
}
.disabled(matchCount == 0)
.accessibilityLabel("Previous match")

Button("Done", action: onClose)
.font(.callout)
Button(action: onNext) {
Image(systemName: "chevron.down")
}
.disabled(matchCount == 0)
.accessibilityLabel("Next match")
}
.buttonStyle(.borderless)
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
.onAppear { focused = true }
}

private var positionText: String {
matchCount == 0 ? "0/0" : "\(currentIndex + 1)/\(matchCount)"
matchCount == 0 ? "No matches" : "\(currentIndex + 1) of \(matchCount)"
}
}
28 changes: 4 additions & 24 deletions Meshtastic/Views/Messages/UserMessageList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ struct UserMessageList: View {
@AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1
@State private var messageLimit: Int = 100
@State private var messages: [MessageEntity] = []
@State private var isSearching = false
@State private var searchQuery = ""
@State private var searchMatches: [MessageSearchMatch] = []
@State private var currentMatchIndex = -1
Expand Down Expand Up @@ -411,7 +410,7 @@ struct UserMessageList: View {

var body: some View {
VStack {
if isSearching { searchBar }
if !searchQuery.isEmpty { searchBar }
ScrollViewReader { scrollView in
ScrollView {
LazyVStack {
Expand Down Expand Up @@ -531,9 +530,10 @@ struct UserMessageList: View {
.fixedSize(horizontal: false, vertical: true)
}
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: "Find in conversation")
.autocorrectionDisabled()
.task(id: searchQuery) { await debouncedSearch() }
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) { searchToolbarButton }
if !user.keyMatch {
ToolbarItem(placement: .bottomBar) {
VStack {
Expand Down Expand Up @@ -578,12 +578,10 @@ struct UserMessageList: View {
private extension UserMessageList {
@ViewBuilder var searchBar: some View {
MessageSearchBar(
query: $searchQuery,
matchCount: searchMatches.count,
currentIndex: currentMatchIndex,
onPrevious: goToPreviousMatch,
onNext: goToNextMatch,
onClose: closeSearch
onNext: goToNextMatch
)
}

Expand All @@ -596,20 +594,10 @@ private extension UserMessageList {
}
}

@ViewBuilder var searchToolbarButton: some View {
Button {
if isSearching { closeSearch() } else { isSearching = true }
} label: {
Image(systemName: isSearching ? "magnifyingglass.circle.fill" : "magnifyingglass")
}
.accessibilityLabel("Find in conversation")
}

/// Debounces search so a full-store scan doesn't run on every keystroke. Cancelled and
/// restarted by `.task(id: searchQuery)` whenever the query changes.
@MainActor
func debouncedSearch() async {
guard isSearching else { return }
// Clearing the field should empty the results immediately, not after the debounce.
guard !searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
await runSearch()
Expand Down Expand Up @@ -683,12 +671,4 @@ private extension UserMessageList {
guard !searchMatches.isEmpty else { return }
focusMatch(at: currentMatchIndex - 1 < 0 ? searchMatches.count - 1 : currentMatchIndex - 1)
}

func closeSearch() {
isSearching = false
searchQuery = ""
searchMatches = []
currentMatchIndex = -1
messageToHighlight = -1
}
}
6 changes: 3 additions & 3 deletions docs/user/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,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
Loading