diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 0cdf27c9a..1bd5e7d45 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -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 @@ -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 { @@ -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() @@ -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 ) } @@ -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() @@ -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 - } } diff --git a/Meshtastic/Views/Messages/MessageSearchBar.swift b/Meshtastic/Views/Messages/MessageSearchBar.swift index eeca7dbbb..b94ea95f1 100644 --- a/Meshtastic/Views/Messages/MessageSearchBar.swift +++ b/Meshtastic/Views/Messages/MessageSearchBar.swift @@ -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)" } } diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index 88f88028e..70d70a703 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -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 @@ -411,7 +410,7 @@ struct UserMessageList: View { var body: some View { VStack { - if isSearching { searchBar } + if !searchQuery.isEmpty { searchBar } ScrollViewReader { scrollView in ScrollView { LazyVStack { @@ -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 { @@ -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 ) } @@ -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() @@ -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 - } } diff --git a/docs/user/messages.md b/docs/user/messages.md index 239b28fb2..5d185c1d8 100644 --- a/docs/user/messages.md +++ b/docs/user/messages.md @@ -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.