search: highlight terms in note search results

Changelog-Changed: Highlight note search results
Signed-off-by: alltheseas
This commit is contained in:
alltheseas
2025-12-10 12:30:34 -06:00
committed by William Casarin
parent 9eda7e5886
commit d3a54458f5
7 changed files with 122 additions and 22 deletions

View File

@@ -21,12 +21,14 @@ struct EventView: View {
let options: EventViewOptions let options: EventViewOptions
let damus: DamusState let damus: DamusState
let pubkey: Pubkey 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.event = event
self.options = options self.options = options
self.damus = damus self.damus = damus
self.pubkey = pubkey ?? event.pubkey self.pubkey = pubkey ?? event.pubkey
self.highlightTerms = highlightTerms
} }
var body: some View { var body: some View {
@@ -48,7 +50,7 @@ struct EventView: View {
} else if event.known_kind == .highlight { } else if event.known_kind == .highlight {
HighlightView(state: damus, event: event, options: options) HighlightView(state: damus, event: event, options: options)
} else { } else {
TextEvent(damus: damus, event: event, pubkey: pubkey, options: options) TextEvent(damus: damus, event: event, pubkey: pubkey, options: options, highlightTerms: highlightTerms)
//.padding([.top], 6) //.padding([.top], 6)
} }
} }
@@ -158,4 +160,3 @@ struct EventView_Previews: PreviewProvider {
.padding() .padding()
} }
} }

View File

@@ -10,6 +10,7 @@ import LinkPresentation
import NaturalLanguage import NaturalLanguage
import MarkdownUI import MarkdownUI
import Translation import Translation
import UIKit
struct Blur: UIViewRepresentable { struct Blur: UIViewRepresentable {
var style: UIBlurEffect.Style = .systemUltraThinMaterial var style: UIBlurEffect.Style = .systemUltraThinMaterial
@@ -49,6 +50,7 @@ struct NoteContentView: View {
let size: EventViewKind let size: EventViewKind
let preview_height: CGFloat? let preview_height: CGFloat?
let options: EventViewOptions let options: EventViewOptions
let highlightTerms: [String]
@State var isAppleTranslationPopoverPresented: Bool = false @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))) 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.damus_state = damus_state
self.event = event self.event = event
self.blur_images = blur_images self.blur_images = blur_images
self.size = size self.size = size
self.options = options self.options = options
self.highlightTerms = highlightTerms
self.preview_height = lookup_cached_preview_size(previews: damus_state.previews, evid: event.id) self.preview_height = lookup_cached_preview_size(previews: damus_state.previews, evid: event.id)
let cached = damus_state.events.get_cache_data(event.id) let cached = damus_state.events.get_cache_data(event.id)
self._preview_model = ObservedObject(wrappedValue: cached.preview_model) self._preview_model = ObservedObject(wrappedValue: cached.preview_model)
@@ -168,20 +171,22 @@ struct NoteContentView: View {
} }
func MainContent(artifacts: NoteArtifactsSeparated) -> some View { func MainContent(artifacts: NoteArtifactsSeparated) -> some View {
VStack(alignment: .leading) { let contentToRender = highlightedContent(artifacts.content)
return VStack(alignment: .leading) {
if size == .selected { if size == .selected {
if with_padding { 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) .padding(.horizontal)
} else { } 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 { } else {
if with_padding { if with_padding {
truncatedText(content: artifacts.content) truncatedText(content: contentToRender)
.padding(.horizontal) .padding(.horizontal)
} else { } else {
truncatedText(content: artifacts.content) truncatedText(content: contentToRender)
} }
} }
@@ -390,7 +395,51 @@ struct NoteContentView: View {
} }
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
var normalizedHighlightTerms: [String] {
var output: [String] = []
var seen = Set<String>()
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 { var body: some View {
ArtifactContent ArtifactContent
.onReceive(handle_notify(.profile_updated)) { profile in .onReceive(handle_notify(.profile_updated)) { profile in

View File

@@ -38,13 +38,15 @@ struct TextEvent: View {
let pubkey: Pubkey let pubkey: Pubkey
let options: EventViewOptions let options: EventViewOptions
let evdata: EventData 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.damus = damus
self.event = event self.event = event
self.pubkey = pubkey self.pubkey = pubkey
self.options = options self.options = options
self.evdata = damus.events.get_cache_data(event.id) self.evdata = damus.events.get_cache_data(event.id)
self.highlightTerms = highlightTerms
} }
var body: some View { var body: some View {
@@ -62,7 +64,8 @@ struct TextEvent: View {
event: event, event: event,
blur_images: blur_imgs, blur_images: blur_imgs,
size: .small, size: .small,
options: options) options: options,
highlightTerms: highlightTerms)
} }
return NoteContentView( return NoteContentView(
@@ -70,7 +73,8 @@ struct TextEvent: View {
event: event, event: event,
blur_images: blur_imgs, blur_images: blur_imgs,
size: .normal, size: .normal,
options: options options: options,
highlightTerms: highlightTerms
) )
} }
@@ -87,4 +91,3 @@ struct TextEvent_Previews: PreviewProvider {
} }
} }
} }

View File

@@ -11,6 +11,38 @@ struct NDBSearchView: View {
let damus_state: DamusState let damus_state: DamusState
@Binding var results: [NostrEvent] @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<String>()
for term in terms.map({ $0.lowercased() }) {
if seen.insert(term).inserted {
deduped.append(term)
}
}
return deduped
}
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -24,9 +56,16 @@ struct NDBSearchView: View {
.padding() .padding()
.foregroundColor(.secondary) .foregroundColor(.secondary)
if !highlightTerms.isEmpty {
Text("Search: \(searchQuery)")
.font(.footnote)
.foregroundColor(.secondary)
.padding(.bottom, 4)
}
LazyVStack { LazyVStack {
ForEach(results, id: \.self) { note in 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 { .onTapGesture {
let event = note.get_inner_event(cache: damus_state.events) ?? note let event = note.get_inner_event(cache: damus_state.events) ?? note
let thread = ThreadModel(event: event, damus_state: damus_state) let thread = ThreadModel(event: event, damus_state: damus_state)

View File

@@ -68,7 +68,7 @@ struct InnerSearchResults: View {
} }
func TextSearch(_ txt: String) -> some View { func TextSearch(_ txt: String) -> some View {
return NavigationLink(value: Route.NDBSearch(results: $results)) { return NavigationLink(value: Route.NDBSearch(results: $results, query: txt)) {
HStack { HStack {
Text("Search word: \(txt)", comment: "Navigation link to search for a word.") 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) ->
} }
} }
} }

View File

@@ -28,7 +28,15 @@ class DamusColors {
static let green = Color("DamusGreen") static let green = Color("DamusGreen")
static let purple = Color("DamusPurple") static let purple = Color("DamusPurple")
static let deepPurple = Color("DamusDeepPurple") 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 blue = Color("DamusBlue")
static let bitcoin = Color("Bitcoin") static let bitcoin = Color("Bitcoin")
static let success = Color("DamusSuccessPrimary") static let success = Color("DamusSuccessPrimary")

View File

@@ -39,7 +39,7 @@ enum Route: Hashable {
case Reactions(reactions: EventsModel) case Reactions(reactions: EventsModel)
case Zaps(target: ZapTarget) case Zaps(target: ZapTarget)
case Search(search: SearchModel) case Search(search: SearchModel)
case NDBSearch(results: Binding<[NostrEvent]>) case NDBSearch(results: Binding<[NostrEvent]>, query: String)
case EULA case EULA
case Login case Login
case CreateAccount case CreateAccount
@@ -115,8 +115,8 @@ enum Route: Hashable {
ZapsView(state: damusState, target: target) ZapsView(state: damusState, target: target)
case .Search(let search): case .Search(let search):
SearchView(appstate: damusState, search: search) SearchView(appstate: damusState, search: search)
case .NDBSearch(let results): case .NDBSearch(let results, let query):
NDBSearchView(damus_state: damusState, results: results) NDBSearchView(damus_state: damusState, results: results, searchQuery: query)
case .EULA: case .EULA:
EULAView(nav: navigationCoordinator) EULAView(nav: navigationCoordinator)
case .Login: case .Login:
@@ -226,8 +226,9 @@ enum Route: Hashable {
case .Search(let search): case .Search(let search):
hasher.combine("search") hasher.combine("search")
hasher.combine(search.search) hasher.combine(search.search)
case .NDBSearch: case .NDBSearch(_, let query):
hasher.combine("results") hasher.combine("results")
hasher.combine(query)
case .EULA: case .EULA:
hasher.combine("eula") hasher.combine("eula")
case .Login: case .Login: