From d3a54458f570e1ad760bdbab72fbdd45b27fd27a Mon Sep 17 00:00:00 2001 From: alltheseas <64376233+alltheseas@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:30:34 -0600 Subject: [PATCH] search: highlight terms in note search results Changelog-Changed: Highlight note search results Signed-off-by: alltheseas --- damus/Features/Events/EventView.swift | 7 ++- damus/Features/Events/NoteContentView.swift | 63 ++++++++++++++++--- damus/Features/Events/TextEvent.swift | 11 ++-- .../Features/Search/Views/NDBSearchView.swift | 41 +++++++++++- .../Search/Views/SearchResultsView.swift | 3 +- damus/Shared/Components/DamusColors.swift | 10 ++- damus/Shared/Utilities/Router.swift | 9 +-- 7 files changed, 122 insertions(+), 22 deletions(-) diff --git a/damus/Features/Events/EventView.swift b/damus/Features/Events/EventView.swift index 9885fe60..ff1d59e6 100644 --- a/damus/Features/Events/EventView.swift +++ b/damus/Features/Events/EventView.swift @@ -21,12 +21,14 @@ struct EventView: View { let options: EventViewOptions let damus: DamusState let pubkey: Pubkey + let highlightTerms: [String] - init(damus: DamusState, event: NostrEvent, pubkey: Pubkey? = nil, options: EventViewOptions = []) { + init(damus: DamusState, event: NostrEvent, pubkey: Pubkey? = nil, options: EventViewOptions = [], highlightTerms: [String] = []) { self.event = event self.options = options self.damus = damus self.pubkey = pubkey ?? event.pubkey + self.highlightTerms = highlightTerms } var body: some View { @@ -48,7 +50,7 @@ struct EventView: View { } else if event.known_kind == .highlight { HighlightView(state: damus, event: event, options: options) } else { - TextEvent(damus: damus, event: event, pubkey: pubkey, options: options) + TextEvent(damus: damus, event: event, pubkey: pubkey, options: options, highlightTerms: highlightTerms) //.padding([.top], 6) } } @@ -158,4 +160,3 @@ struct EventView_Previews: PreviewProvider { .padding() } } - diff --git a/damus/Features/Events/NoteContentView.swift b/damus/Features/Events/NoteContentView.swift index 28622201..9085f023 100644 --- a/damus/Features/Events/NoteContentView.swift +++ b/damus/Features/Events/NoteContentView.swift @@ -10,6 +10,7 @@ import LinkPresentation import NaturalLanguage import MarkdownUI import Translation +import UIKit struct Blur: UIViewRepresentable { var style: UIBlurEffect.Style = .systemUltraThinMaterial @@ -49,6 +50,7 @@ struct NoteContentView: View { let size: EventViewKind let preview_height: CGFloat? let options: EventViewOptions + let highlightTerms: [String] @State var isAppleTranslationPopoverPresented: Bool = false @@ -63,12 +65,13 @@ struct NoteContentView: View { return self.artifacts_model.state.artifacts ?? .separated(.just_content(event.get_content(damus_state.keypair))) } - init(damus_state: DamusState, event: NostrEvent, blur_images: Bool, size: EventViewKind, options: EventViewOptions) { + init(damus_state: DamusState, event: NostrEvent, blur_images: Bool, size: EventViewKind, options: EventViewOptions, highlightTerms: [String] = []) { self.damus_state = damus_state self.event = event self.blur_images = blur_images self.size = size self.options = options + self.highlightTerms = highlightTerms self.preview_height = lookup_cached_preview_size(previews: damus_state.previews, evid: event.id) let cached = damus_state.events.get_cache_data(event.id) self._preview_model = ObservedObject(wrappedValue: cached.preview_model) @@ -168,20 +171,22 @@ struct NoteContentView: View { } func MainContent(artifacts: NoteArtifactsSeparated) -> some View { - VStack(alignment: .leading) { + let contentToRender = highlightedContent(artifacts.content) + + return VStack(alignment: .leading) { if size == .selected { if with_padding { - SelectableText(damus_state: damus_state, event: self.event, attributedString: artifacts.content.attributed, size: self.size) + SelectableText(damus_state: damus_state, event: self.event, attributedString: contentToRender.attributed, size: self.size) .padding(.horizontal) } else { - SelectableText(damus_state: damus_state, event: self.event, attributedString: artifacts.content.attributed, size: self.size) + SelectableText(damus_state: damus_state, event: self.event, attributedString: contentToRender.attributed, size: self.size) } } else { if with_padding { - truncatedText(content: artifacts.content) + truncatedText(content: contentToRender) .padding(.horizontal) } else { - truncatedText(content: artifacts.content) + truncatedText(content: contentToRender) } } @@ -390,7 +395,51 @@ struct NoteContentView: View { } .fixedSize(horizontal: false, vertical: true) } - + + var normalizedHighlightTerms: [String] { + var output: [String] = [] + var seen = Set() + + let preparedTerms = highlightTerms + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .flatMap { term -> [String] in + if term.hasPrefix("#") { + let stripped = String(term.dropFirst()) + return [term, stripped] + } + return [term] + } + + for term in preparedTerms { + let lower = term.lowercased() + if !lower.isEmpty && seen.insert(lower).inserted { + output.append(lower) + } + } + + return output + } + + func highlightedContent(_ content: CompatibleText) -> CompatibleText { + guard !normalizedHighlightTerms.isEmpty else { return content } + + var attributed = content.attributed + highlightAttributedString(&attributed) + return CompatibleText(attributed: attributed) + } + + func highlightAttributedString(_ attributed: inout AttributedString) { + for term in normalizedHighlightTerms { + var searchStart = attributed.startIndex + + while let range = attributed[searchStart...].range(of: term, options: .caseInsensitive) { + attributed[range].backgroundColor = DamusColors.highlight + searchStart = range.upperBound + } + } + } + var body: some View { ArtifactContent .onReceive(handle_notify(.profile_updated)) { profile in diff --git a/damus/Features/Events/TextEvent.swift b/damus/Features/Events/TextEvent.swift index 5398e856..3c4020d4 100644 --- a/damus/Features/Events/TextEvent.swift +++ b/damus/Features/Events/TextEvent.swift @@ -38,13 +38,15 @@ struct TextEvent: View { let pubkey: Pubkey let options: EventViewOptions let evdata: EventData + let highlightTerms: [String] - init(damus: DamusState, event: NostrEvent, pubkey: Pubkey, options: EventViewOptions) { + init(damus: DamusState, event: NostrEvent, pubkey: Pubkey, options: EventViewOptions, highlightTerms: [String] = []) { self.damus = damus self.event = event self.pubkey = pubkey self.options = options self.evdata = damus.events.get_cache_data(event.id) + self.highlightTerms = highlightTerms } var body: some View { @@ -62,7 +64,8 @@ struct TextEvent: View { event: event, blur_images: blur_imgs, size: .small, - options: options) + options: options, + highlightTerms: highlightTerms) } return NoteContentView( @@ -70,7 +73,8 @@ struct TextEvent: View { event: event, blur_images: blur_imgs, size: .normal, - options: options + options: options, + highlightTerms: highlightTerms ) } @@ -87,4 +91,3 @@ struct TextEvent_Previews: PreviewProvider { } } } - diff --git a/damus/Features/Search/Views/NDBSearchView.swift b/damus/Features/Search/Views/NDBSearchView.swift index 7850ab05..7bdf4a1b 100644 --- a/damus/Features/Search/Views/NDBSearchView.swift +++ b/damus/Features/Search/Views/NDBSearchView.swift @@ -11,6 +11,38 @@ struct NDBSearchView: View { let damus_state: DamusState @Binding var results: [NostrEvent] + let searchQuery: String + + var highlightTerms: [String] { + let trimmed = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let parts = trimmed.split(whereSeparator: { $0.isWhitespace }) + var terms: [String] = [] + + for part in parts { + let term = String(part) + let strippedHashtag = term.hasPrefix("#") ? String(term.dropFirst()) : nil + + if let stripped = strippedHashtag, !stripped.isEmpty { + terms.append(stripped) + } + + if !term.isEmpty { + terms.append(term) + } + } + + var deduped: [String] = [] + var seen = Set() + for term in terms.map({ $0.lowercased() }) { + if seen.insert(term).inserted { + deduped.append(term) + } + } + + return deduped + } var body: some View { ScrollView { @@ -24,9 +56,16 @@ struct NDBSearchView: View { .padding() .foregroundColor(.secondary) + if !highlightTerms.isEmpty { + Text("Search: \(searchQuery)") + .font(.footnote) + .foregroundColor(.secondary) + .padding(.bottom, 4) + } + LazyVStack { ForEach(results, id: \.self) { note in - EventView(damus: damus_state, event: note, options: [.truncate_content]) + EventView(damus: damus_state, event: note, options: [.truncate_content], highlightTerms: highlightTerms) .onTapGesture { let event = note.get_inner_event(cache: damus_state.events) ?? note let thread = ThreadModel(event: event, damus_state: damus_state) diff --git a/damus/Features/Search/Views/SearchResultsView.swift b/damus/Features/Search/Views/SearchResultsView.swift index b11827e8..7b564f50 100644 --- a/damus/Features/Search/Views/SearchResultsView.swift +++ b/damus/Features/Search/Views/SearchResultsView.swift @@ -68,7 +68,7 @@ struct InnerSearchResults: View { } func TextSearch(_ txt: String) -> some View { - return NavigationLink(value: Route.NDBSearch(results: $results)) { + return NavigationLink(value: Route.NDBSearch(results: $results, query: txt)) { HStack { Text("Search word: \(txt)", comment: "Navigation link to search for a word.") } @@ -296,4 +296,3 @@ func search_profiles(profiles: Profiles, contacts: Contacts, search: String) -> } } } - diff --git a/damus/Shared/Components/DamusColors.swift b/damus/Shared/Components/DamusColors.swift index 860ff510..bf5d96e5 100644 --- a/damus/Shared/Components/DamusColors.swift +++ b/damus/Shared/Components/DamusColors.swift @@ -28,7 +28,15 @@ class DamusColors { static let green = Color("DamusGreen") static let purple = Color("DamusPurple") static let deepPurple = Color("DamusDeepPurple") - static let highlight = Color("DamusHighlight") + static let highlight = Color(UIColor { traits in + if traits.userInterfaceStyle == .dark { + // Vivid Damus magenta tuned for dark backgrounds (strong contrast without glow) + return UIColor(red: 0.95, green: 0.43, blue: 0.82, alpha: 0.78) + } else { + // Slightly deeper pink on light so text stays legible + return UIColor(red: 0.88, green: 0.32, blue: 0.74, alpha: 0.62) + } + }) static let blue = Color("DamusBlue") static let bitcoin = Color("Bitcoin") static let success = Color("DamusSuccessPrimary") diff --git a/damus/Shared/Utilities/Router.swift b/damus/Shared/Utilities/Router.swift index b48d2f18..11eb96d1 100644 --- a/damus/Shared/Utilities/Router.swift +++ b/damus/Shared/Utilities/Router.swift @@ -39,7 +39,7 @@ enum Route: Hashable { case Reactions(reactions: EventsModel) case Zaps(target: ZapTarget) case Search(search: SearchModel) - case NDBSearch(results: Binding<[NostrEvent]>) + case NDBSearch(results: Binding<[NostrEvent]>, query: String) case EULA case Login case CreateAccount @@ -115,8 +115,8 @@ enum Route: Hashable { ZapsView(state: damusState, target: target) case .Search(let search): SearchView(appstate: damusState, search: search) - case .NDBSearch(let results): - NDBSearchView(damus_state: damusState, results: results) + case .NDBSearch(let results, let query): + NDBSearchView(damus_state: damusState, results: results, searchQuery: query) case .EULA: EULAView(nav: navigationCoordinator) case .Login: @@ -226,8 +226,9 @@ enum Route: Hashable { case .Search(let search): hasher.combine("search") hasher.combine(search.search) - case .NDBSearch: + case .NDBSearch(_, let query): hasher.combine("results") + hasher.combine(query) case .EULA: hasher.combine("eula") case .Login: