damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 505ce0bd392a5f8a17f445b9fb7bd6571d44e102
parent 0fae54a98df16d7bb04751ee54ac052f2f6b6eb0
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 13 Jul 2023 11:10:53 -0700

Add the ability to follow hashtags

Changelog-Added: Add the ability to follow hashtags

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 12++++++++++++
Adamus/Components/Search/SearchHeaderView.swift | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/ContentView.swift | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mdamus/Models/Contacts.swift | 15++++++++++++---
Mdamus/Models/HomeModel.swift | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mdamus/Views/FollowButtonView.swift | 8++++----
Mdamus/Views/SearchView.swift | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
7 files changed, 407 insertions(+), 93 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -144,6 +144,7 @@ 4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */; }; 4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 4C649880286E0EE300EAE2B3 /* secp256k1 */; }; 4C687C212A5F7ED00092C550 /* DamusBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C202A5F7ED00092C550 /* DamusBackground.swift */; }; + 4C687C242A5FA86D0092C550 /* SearchHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C232A5FA86D0092C550 /* SearchHeaderView.swift */; }; 4C687C272A6039500092C550 /* TestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C262A6039500092C550 /* TestData.swift */; }; 4C73C5142A4437C10062CAC0 /* ZapUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C73C5132A4437C10062CAC0 /* ZapUserView.swift */; }; 4C75EFA427FA577B0006080F /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFA327FA577B0006080F /* PostView.swift */; }; @@ -619,6 +620,7 @@ 4C64987B286D03E000EAE2B3 /* DirectMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesView.swift; sourceTree = "<group>"; }; 4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesModel.swift; sourceTree = "<group>"; }; 4C687C202A5F7ED00092C550 /* DamusBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusBackground.swift; sourceTree = "<group>"; }; + 4C687C232A5FA86D0092C550 /* SearchHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHeaderView.swift; sourceTree = "<group>"; }; 4C687C262A6039500092C550 /* TestData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestData.swift; sourceTree = "<group>"; }; 4C73C5132A4437C10062CAC0 /* ZapUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapUserView.swift; sourceTree = "<group>"; }; 4C75EFA327FA577B0006080F /* PostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostView.swift; sourceTree = "<group>"; }; @@ -1099,6 +1101,14 @@ path = Notifications; sourceTree = "<group>"; }; + 4C687C2A2A6058450092C550 /* Search */ = { + isa = PBXGroup; + children = ( + 4C687C232A5FA86D0092C550 /* SearchHeaderView.swift */, + ); + path = Search; + sourceTree = "<group>"; + }; 4C75EFA227FA576C0006080F /* Views */ = { isa = PBXGroup; children = ( @@ -1404,6 +1414,7 @@ 4CE4F9DF285287A000C00DD9 /* Components */ = { isa = PBXGroup; children = ( + 4C687C2A2A6058450092C550 /* Search */, 4C7D09702A0AEF4C00943473 /* Gradients */, 31D2E846295218AF006D67F8 /* Shimmer.swift */, 4CD7641A28A1641400B6928F /* EndBlock.swift */, @@ -1946,6 +1957,7 @@ 3169CAE6294E69C000EE4006 /* EmptyTimelineView.swift in Sources */, 4CC7AAF0297F11C700430951 /* SelectedEventView.swift in Sources */, 4CC7AAF8297F1CEE00430951 /* EventProfile.swift in Sources */, + 4C687C242A5FA86D0092C550 /* SearchHeaderView.swift in Sources */, 64FBD06F296255C400D9D3B2 /* Theme.swift in Sources */, 4C1A9A2329DDDB8100516EAC /* IconLabel.swift in Sources */, 4C3EA64928FF597700C48A62 /* bech32.c in Sources */, diff --git a/damus/Components/Search/SearchHeaderView.swift b/damus/Components/Search/SearchHeaderView.swift @@ -0,0 +1,134 @@ +// +// SearchIconView.swift +// damus +// +// Created by William Casarin on 2023-07-12. +// + +import SwiftUI + +struct SearchHeaderView: View { + let state: DamusState + let described: DescribedSearch + @State var is_following: Bool + + init(state: DamusState, described: DescribedSearch) { + self.state = state + self.described = described + + let is_following = (described.is_hashtag.map { + ht in is_following_hashtag(contacts: state.contacts.event, hashtag: ht) + }) ?? false + + self._is_following = State(wrappedValue: is_following) + } + + var Icon: some View { + ZStack { + Circle() + .fill(Color(red: 0xF8/255.0, green: 0xE7/255.0, blue: 0xF8/255.0)) + .frame(width: 54, height: 54) + + switch described { + case .hashtag: + Text("#") + .font(.largeTitle.bold()) + .foregroundStyle(PinkGradient) + .mask(Text("#") + .font(.largeTitle.bold())) + + case .unknown: + Image(systemName: "magnifyingglass") + .font(.title.bold()) + .foregroundStyle(PinkGradient) + } + } + } + + var SearchText: Text { + switch described { + case .hashtag(let ht): + Text(verbatim: "#" + ht) + case .unknown: + Text("Search") + } + } + + func unfollow(_ hashtag: String) { + is_following = false + handle_unfollow(state: state, unfollow: .t(hashtag)) + } + + func follow(_ hashtag: String) { + is_following = true + handle_follow(state: state, follow: .t(hashtag)) + } + + func FollowButton(_ ht: String) -> some View { + return Button(action: { follow(ht) }) { + Text("Follow hashtag") + .font(.footnote.bold()) + } + .buttonStyle(GradientButtonStyle(padding: 10)) + } + + func UnfollowButton(_ ht: String) -> some View { + return Button(action: { unfollow(ht) }) { + Text("Unfollow hashtag") + .font(.footnote.bold()) + } + .buttonStyle(GradientButtonStyle(padding: 10)) + } + + var body: some View { + HStack(alignment: .center, spacing: 30) { + Icon + + VStack(alignment: .leading, spacing: 10.0) { + SearchText + .foregroundStyle(DamusLogoGradient.gradient) + .font(.title.bold()) + + if state.is_privkey_user, case .hashtag(let ht) = described { + if is_following { + UnfollowButton(ht) + } else { + FollowButton(ht) + } + } + } + } + .onReceive(handle_notify(.followed)) { notif in + let ref = notif.object as! ReferencedId + guard hashtag_matches_search(desc: self.described, ref: ref) else { return } + self.is_following = true + } + .onReceive(handle_notify(.unfollowed)) { notif in + let ref = notif.object as! ReferencedId + guard hashtag_matches_search(desc: self.described, ref: ref) else { return } + self.is_following = false + } + } +} + +func hashtag_matches_search(desc: DescribedSearch, ref: ReferencedId) -> Bool { + guard let ht = desc.is_hashtag, ref.key == "t" && ref.ref_id == ht + else { return false } + return true +} + +func is_following_hashtag(contacts: NostrEvent?, hashtag: String) -> Bool { + guard let contacts else { return false } + return is_already_following(contacts: contacts, follow: .t(hashtag)) +} + + +struct SearchHeaderView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading) { + SearchHeaderView(state: test_damus_state(), described: .hashtag("damus")) + + SearchHeaderView(state: test_damus_state(), described: .unknown) + } + } +} diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -395,16 +395,22 @@ struct ContentView: View { } } .onReceive(handle_notify(.unfollow)) { notif in - guard let state = self.damus_state else { - return - } - handle_unfollow(state: state, notif: notif) + guard let state = self.damus_state else { return } + guard let unfollow = handle_unfollow_notif(state: state, notif: notif) else { return } + } + .onReceive(handle_notify(.unfollowed)) { notif in + guard let state = self.damus_state else { return } + let unfollow = notif.object as! ReferencedId + home.resubscribe(.unfollowing(unfollow)) } .onReceive(handle_notify(.follow)) { notif in - guard let state = self.damus_state else { - return - } - handle_follow(state: state, notif: notif) + guard let state = self.damus_state else { return } + guard handle_follow_notif(state: state, notif: notif) else { return } + } + .onReceive(handle_notify(.followed)) { notif in + guard let state = self.damus_state else { return } + let follow = notif.object as! ReferencedId + home.resubscribe(.following) } .onReceive(handle_notify(.post)) { notif in guard let state = self.damus_state, @@ -879,47 +885,75 @@ func timeline_name(_ timeline: Timeline?) -> String { } } -func handle_unfollow(state: DamusState, notif: Notification) { +@discardableResult +func handle_unfollow(state: DamusState, unfollow: ReferencedId) -> Bool { guard let keypair = state.keypair.to_full() else { - return + return false } - - let target = notif.object as! FollowTarget - let pk = target.pubkey + let old_contacts = state.contacts.event - guard let ev = unfollow_reference(postbox: state.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: .p(pk)) - else { return } + guard let ev = unfollow_reference(postbox: state.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow) + else { + return false + } - notify(.unfollowed, pk) + notify(.unfollowed, unfollow) state.contacts.event = ev - state.contacts.remove_friend(pk) - state.user_search_cache.updateOwnContactsPetnames(id: state.pubkey, oldEvent: old_contacts, newEvent: ev) + + if unfollow.key == "p" { + state.contacts.remove_friend(unfollow.ref_id) + state.user_search_cache.updateOwnContactsPetnames(id: state.pubkey, oldEvent: old_contacts, newEvent: ev) + } + + return true } -func handle_follow(state: DamusState, notif: Notification) { - guard let keypair = state.keypair.to_full() else { - return +func handle_unfollow_notif(state: DamusState, notif: Notification) -> ReferencedId? { + let target = notif.object as! FollowTarget + let pk = target.pubkey + + let ref = ReferencedId.p(pk) + if handle_unfollow(state: state, unfollow: ref) { + return ref } - let fnotify = notif.object as! FollowTarget + return nil +} + +@discardableResult +func handle_follow(state: DamusState, follow: ReferencedId) -> Bool { + guard let keypair = state.keypair.to_full() else { + return false + } - guard let ev = follow_reference(box: state.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: .p(fnotify.pubkey)) + guard let ev = follow_reference(box: state.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow) else { - return + return false } - notify(.followed, fnotify.pubkey) + notify(.followed, follow) state.contacts.event = ev + if follow.key == "p" { + state.contacts.add_friend_pubkey(follow.ref_id) + } + return true +} + +@discardableResult +func handle_follow_notif(state: DamusState, notif: Notification) -> Bool { + let fnotify = notif.object as! FollowTarget switch fnotify { case .pubkey(let pk): state.contacts.add_friend_pubkey(pk) case .contact(let ev): state.contacts.add_friend_contact(ev) } + + return handle_follow(state: state, follow: .p(fnotify.pubkey)) } func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, notif: Notification) -> Bool { diff --git a/damus/Models/Contacts.swift b/damus/Models/Contacts.swift @@ -66,10 +66,19 @@ class Contacts { } } - func get_friend_list() -> [String] { - return Array(friends) + func get_friend_list() -> Set<String> { + return friends } - + + func get_followed_hashtags() -> Set<String> { + guard let ev = self.event else { return Set() } + return ev.tags.reduce(into: Set<String>(), { htags, tag in + if tag.count >= 2 && tag[0] == "t" && tag[1] != "" { + htags.insert(tag[1]) + } + }) + } + func add_friend_pubkey(_ pubkey: String) { friends.insert(pubkey) } diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -23,6 +23,40 @@ struct NewEventsBits: OptionSet { static let notifications: NewEventsBits = [.zaps, .likes, .reposts, .mentions] } +enum Resubscribe { + case following + case unfollowing(ReferencedId) +} + +enum HomeResubFilter { + case pubkey(String) + case hashtag(String) + + init?(from: ReferencedId) { + if from.key == "p" { + self = .pubkey(from.ref_id) + return + } else if from.key == "t" { + self = .hashtag(from.ref_id) + return + } + + return nil + } + + func filter(contacts: Contacts, ev: NostrEvent) -> Bool { + switch self { + case .pubkey(let pk): + return ev.pubkey == pk + case .hashtag(let ht): + if contacts.is_friend(ev.pubkey) { + return false + } + return ev.references(id: ht, key: "t") + } + } +} + class HomeModel { // Don't trigger a user notification for events older than a certain age static let event_max_age_for_notification: TimeInterval = 12 * 60 * 60 @@ -36,6 +70,7 @@ class HomeModel { var done_init: Bool = false var incoming_dms: [NostrEvent] = [] let dm_debouncer = Debouncer(interval: 0.5) + let resub_debouncer = Debouncer(interval: 3.0) var should_debounce_dms = true let home_subid = UUID().description @@ -90,6 +125,31 @@ class HomeModel { } } + func resubscribe(_ resubbing: Resubscribe) { + if self.should_debounce_dms { + // don't resub on initial load + return + } + + print("hit resub debouncer") + + resub_debouncer.debounce { + print("resub") + self.unsubscribe_to_home_filters() + + switch resubbing { + case .following: + break + case .unfollowing(let r): + if let filter = HomeResubFilter(from: r) { + self.events.filter { ev in !filter.filter(contacts: self.damus_state.contacts, ev: ev) } + } + } + + self.subscribe_to_home_filters() + } + } + func process_event(sub_id: String, relay_id: String, ev: NostrEvent) { if has_sub_id_event(sub_id: sub_id, ev_id: ev.id) { return @@ -382,8 +442,7 @@ class HomeModel { // TODO: since times should be based on events from a specific relay // perhaps we could mark this in the relay pool somehow - var friends = damus_state.contacts.get_friend_list() - friends.append(damus_state.pubkey) + let friends = get_friends() var contacts_filter = NostrFilter(kinds: [.metadata]) contacts_filter.authors = friends @@ -405,18 +464,6 @@ class HomeModel { dms_filter.pubkeys = [ damus_state.pubkey ] our_dms_filter.authors = [ damus_state.pubkey ] - // TODO: separate likes? - var home_filter_kinds: [NostrKind] = [ - .text, .longform, .boost - ] - if !damus_state.settings.onlyzaps_mode { - home_filter_kinds.append(.like) - } - var home_filter = NostrFilter(kinds: home_filter_kinds) - // include our pubkey as well even if we're not technically a friend - home_filter.authors = friends - home_filter.limit = 500 - var notifications_filter_kinds: [NostrKind] = [ .text, .boost, @@ -429,33 +476,71 @@ class HomeModel { notifications_filter.pubkeys = [damus_state.pubkey] notifications_filter.limit = 500 - var home_filters = [home_filter] var notifications_filters = [notifications_filter] var contacts_filters = [contacts_filter, our_contacts_filter, our_blocklist_filter] var dms_filters = [dms_filter, our_dms_filter] + let last_of_kind = get_last_of_kind(relay_id: relay_id) - let last_of_kind = relay_id.flatMap { last_event_of_kind[$0] } ?? [:] - - home_filters = update_filters_with_since(last_of_kind: last_of_kind, filters: home_filters) contacts_filters = update_filters_with_since(last_of_kind: last_of_kind, filters: contacts_filters) notifications_filters = update_filters_with_since(last_of_kind: last_of_kind, filters: notifications_filters) dms_filters = update_filters_with_since(last_of_kind: last_of_kind, filters: dms_filters) //print_filters(relay_id: relay_id, filters: [home_filters, contacts_filters, notifications_filters, dms_filters]) - if let relay_id { - pool.send(.subscribe(.init(filters: home_filters, sub_id: home_subid)), to: [relay_id]) - pool.send(.subscribe(.init(filters: contacts_filters, sub_id: contacts_subid)), to: [relay_id]) - pool.send(.subscribe(.init(filters: notifications_filters, sub_id: notifications_subid)), to: [relay_id]) - pool.send(.subscribe(.init(filters: dms_filters, sub_id: dms_subid)), to: [relay_id]) - } else { - pool.send(.subscribe(.init(filters: home_filters, sub_id: home_subid))) - pool.send(.subscribe(.init(filters: contacts_filters, sub_id: contacts_subid))) - pool.send(.subscribe(.init(filters: notifications_filters, sub_id: notifications_subid))) - pool.send(.subscribe(.init(filters: dms_filters, sub_id: dms_subid))) + subscribe_to_home_filters(relay_id: relay_id) + + let relay_ids = relay_id.map { [$0] } + + pool.send(.subscribe(.init(filters: contacts_filters, sub_id: contacts_subid)), to: relay_ids) + pool.send(.subscribe(.init(filters: notifications_filters, sub_id: notifications_subid)), to: relay_ids) + pool.send(.subscribe(.init(filters: dms_filters, sub_id: dms_subid)), to: relay_ids) + } + + func get_last_of_kind(relay_id: String?) -> [Int: NostrEvent] { + return relay_id.flatMap { last_event_of_kind[$0] } ?? [:] + } + + func unsubscribe_to_home_filters() { + pool.send(.unsubscribe(home_subid)) + } + + func get_friends() -> [String] { + var friends = damus_state.contacts.get_friend_list() + friends.insert(damus_state.pubkey) + return Array(friends) + } + + func subscribe_to_home_filters(friends fs: [String]? = nil, relay_id: String? = nil) { + // TODO: separate likes? + let home_filter_kinds: [NostrKind] = [ + .text, .longform, .boost + ] + //if !damus_state.settings.onlyzaps_mode { + //home_filter_kinds.append(.like) + //} + + let friends = fs ?? get_friends() + var home_filter = NostrFilter(kinds: home_filter_kinds) + // include our pubkey as well even if we're not technically a friend + home_filter.authors = friends + home_filter.limit = 500 + + var home_filters = [home_filter] + + let followed_hashtags = Array(damus_state.contacts.get_followed_hashtags()) + if followed_hashtags.count != 0 { + var hashtag_filter = NostrFilter.filter_hashtag(followed_hashtags) + hashtag_filter.limit = 100 + home_filters.append(hashtag_filter) } + + let relay_ids = relay_id.map { [$0] } + home_filters = update_filters_with_since(last_of_kind: get_last_of_kind(relay_id: relay_id), filters: home_filters) + let sub = NostrSubscribe(filters: home_filters, sub_id: home_subid) + + pool.send(.subscribe(sub), to: relay_ids) } - + func handle_list_event(_ ev: NostrEvent) { // we only care about our lists guard ev.pubkey == damus_state.pubkey else { @@ -614,32 +699,34 @@ func add_contact_if_friend(contacts: Contacts, ev: NostrEvent) { func load_our_contacts(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) { let contacts = state.contacts - var new_pks = Set<String>() + var new_refs = Set<ReferencedId>() // our contacts for tag in ev.tags { - if tag.count >= 2 && tag[0] == "p" { - new_pks.insert(tag[1]) - } + guard let ref = tag_to_refid(tag) else { continue } + new_refs.insert(ref) } - var old_pks = Set<String>() + var old_refs = Set<ReferencedId>() // find removed contacts if let old_ev = m_old_ev { for tag in old_ev.tags { - if tag.count >= 2 && tag[0] == "p" { - old_pks.insert(tag[1]) - } + guard let ref = tag_to_refid(tag) else { continue } + old_refs.insert(ref) } } - let diff = new_pks.symmetricDifference(old_pks) - for pk in diff { - if new_pks.contains(pk) { - notify(.followed, pk) - contacts.add_friend_pubkey(pk) + let diff = new_refs.symmetricDifference(old_refs) + for ref in diff { + if new_refs.contains(ref) { + notify(.followed, ref) + if ref.key == "p" { + contacts.add_friend_pubkey(ref.ref_id) + } } else { - notify(.unfollowed, pk) - contacts.remove_friend(pk) + notify(.unfollowed, ref) + if ref.key == "p" { + contacts.remove_friend(ref.ref_id) + } } } diff --git a/damus/Views/FollowButtonView.swift b/damus/Views/FollowButtonView.swift @@ -32,16 +32,16 @@ struct FollowButtonView: View { } } .onReceive(handle_notify(.followed)) { notif in - let pk = notif.object as! String - if pk != target.pubkey { + let pk = notif.object as! ReferencedId + if pk.key == "p", pk.ref_id != target.pubkey { return } self.follow_state = .follows } .onReceive(handle_notify(.unfollowed)) { notif in - let pk = notif.object as! String - if pk != target.pubkey { + let pk = notif.object as! ReferencedId + if pk.key == "p", pk.ref_id != target.pubkey { return } diff --git a/damus/Views/SearchView.swift b/damus/Views/SearchView.swift @@ -11,32 +11,70 @@ struct SearchView: View { let appstate: DamusState @ObservedObject var search: SearchModel @Environment(\.dismiss) var dismiss - + + let height: CGFloat = 250.0 + var body: some View { - TimelineView<AnyView>(events: search.events, loading: $search.loading, damus: appstate, show_friend_icon: true, filter: { _ in true }) - .navigationBarTitle(describe_search(search.search)) - .onReceive(handle_notify(.switched_timeline)) { obj in - dismiss() - } - .onAppear() { - search.subscribe() - } - .onDisappear() { - search.unsubscribe() - } - .onReceive(handle_notify(.new_mutes)) { notif in - search.filter_muted() + TimelineView(events: search.events, loading: $search.loading, damus: appstate, show_friend_icon: true, filter: { _ in true }) { + ZStack(alignment: .leading) { + DamusBackground(maxHeight: height) + .mask(LinearGradient(gradient: Gradient(colors: [.black, .black, .black, .clear]), startPoint: .top, endPoint: .bottom)) + SearchHeaderView(state: appstate, described: described_search) + .padding(.leading, 30) + .padding(.top, 100) } + } + .ignoresSafeArea() + .onReceive(handle_notify(.switched_timeline)) { obj in + dismiss() + } + .onAppear() { + search.subscribe() + } + .onDisappear() { + search.unsubscribe() + } + .onReceive(handle_notify(.new_mutes)) { notif in + search.filter_muted() + } + } + + var described_search: DescribedSearch { + return describe_search(search.search) + } +} + +enum DescribedSearch { + case hashtag(String) + case unknown + + var is_hashtag: String? { + switch self { + case .hashtag(let ht): + return ht + case .unknown: + return nil + } + } + + var description: String { + switch self { + case .hashtag(let s): + return "#" + s + case .unknown: + return "Search" + } } } -func describe_search(_ filter: NostrFilter) -> String { +func describe_search(_ filter: NostrFilter) -> DescribedSearch { if let hashtags = filter.hashtag { if hashtags.count >= 1 { - return "#" + hashtags[0] + return .hashtag(hashtags[0]) } } - return "Search" + + return .unknown } struct SearchView_Previews: PreviewProvider {