damus

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

SearchResultsView.swift (9510B)


      1 //
      2 //  SearchResultsView.swift
      3 //  damus
      4 //
      5 //  Created by William Casarin on 2022-06-06.
      6 //
      7 
      8 import SwiftUI
      9 
     10 struct MultiSearch {
     11     let text: String
     12     let hashtag: String
     13     let profiles: [Pubkey]
     14 }
     15 
     16 enum Search: Identifiable {
     17     case profiles([Pubkey])
     18     case hashtag(String)
     19     case profile(Pubkey)
     20     case note(NoteId)
     21     case nip05(String)
     22     case hex(Data)
     23     case multi(MultiSearch)
     24     case nevent(NEvent)
     25     case naddr(NAddr)
     26     case nprofile(NProfile)
     27     
     28     var id: String {
     29         switch self {
     30         case .profiles: return "profiles"
     31         case .hashtag: return "hashtag"
     32         case .profile: return "profile"
     33         case .note: return "note"
     34         case .nip05: return "nip05"
     35         case .hex: return "hex"
     36         case .multi: return "multi"
     37         case .nevent: return "nevent"
     38         case .naddr: return "naddr"
     39         case .nprofile: return "nprofile"
     40         }
     41     }
     42 }
     43 
     44 struct InnerSearchResults: View {
     45     let damus_state: DamusState
     46     let search: Search?
     47     @Binding var results: [NostrEvent]
     48     
     49     func ProfileSearchResult(pk: Pubkey) -> some View {
     50         FollowUserView(target: .pubkey(pk), damus_state: damus_state)
     51     }
     52     
     53     func HashtagSearch(_ ht: String) -> some View {
     54         let search_model = SearchModel(state: damus_state, search: .filter_hashtag([ht]))
     55         return NavigationLink(value: Route.Search(search: search_model)) {
     56             HStack {
     57                 Text("#\(ht)", comment: "Navigation link to search hashtag.")
     58             }
     59             .padding(.horizontal, 15)
     60             .padding(.vertical, 5)
     61             .background(DamusColors.neutral1)
     62             .cornerRadius(20)
     63             .overlay(
     64                 RoundedRectangle(cornerRadius: 20)
     65                     .stroke(DamusColors.neutral3, lineWidth: 1)
     66             )
     67         }
     68     }
     69     
     70     func TextSearch(_ txt: String) -> some View {
     71         return NavigationLink(value: Route.NDBSearch(results: $results)) {
     72             HStack {
     73                 Text("Search word: \(txt)", comment: "Navigation link to search for a word.")
     74             }
     75             .padding(.horizontal, 15)
     76             .padding(.vertical, 5)
     77             .background(DamusColors.neutral1)
     78             .cornerRadius(20)
     79             .overlay(
     80                 RoundedRectangle(cornerRadius: 20)
     81                     .stroke(DamusColors.neutral3, lineWidth: 1)
     82             )
     83         }
     84     }
     85     
     86     func ProfilesSearch(_ results: [Pubkey]) -> some View {
     87         return LazyVStack {
     88             ForEach(results, id: \.id) { pk in
     89                 ProfileSearchResult(pk: pk)
     90             }
     91         }
     92     }
     93     
     94     var body: some View {
     95         Group {
     96             switch search {
     97             case .profiles(let results):
     98                 ProfilesSearch(results)
     99             case .hashtag(let ht):
    100                 HashtagSearch(ht)
    101             case .nip05(let addr):
    102                 SearchingEventView(state: damus_state, search_type: .nip05(addr))
    103             case .profile(let pubkey):
    104                 SearchingEventView(state: damus_state, search_type: .profile(pubkey))
    105             case .hex(let h):
    106                 VStack(spacing: 10) {
    107                     SearchingEventView(state: damus_state, search_type: .event(NoteId(h)))
    108                     SearchingEventView(state: damus_state, search_type: .profile(Pubkey(h)))
    109                 } 
    110             case .note(let nid):
    111                 SearchingEventView(state: damus_state, search_type: .event(nid))
    112             case .nevent(let nevent):
    113                 SearchingEventView(state: damus_state, search_type: .event(nevent.noteid))
    114             case .nprofile(let nprofile):
    115                 SearchingEventView(state: damus_state, search_type: .profile(nprofile.author))
    116             case .naddr(let naddr):
    117                 SearchingEventView(state: damus_state, search_type: .naddr(naddr))
    118             case .multi(let multi):
    119                 VStack(alignment: .leading) {
    120                     HStack(spacing: 20) {
    121                         HashtagSearch(multi.hashtag)
    122                         TextSearch(multi.text)
    123                     }
    124                     .padding(.bottom, 10)
    125                     
    126                     ProfilesSearch(multi.profiles)
    127                 }
    128                 
    129             case .none:
    130                 Text("none", comment: "No search results.")
    131             }
    132         }
    133     }
    134 }
    135 
    136 struct SearchResultsView: View {
    137     let damus_state: DamusState
    138     @Binding var search: String
    139     @State var result: Search? = nil
    140     @State var results: [NostrEvent] = []
    141     let debouncer: Debouncer = Debouncer(interval: 0.25)
    142     
    143     func do_search(query: String) {
    144         let limit = 128
    145         var note_keys = damus_state.ndb.text_search(query: query, limit: limit, order: .newest_first)
    146         var res = [NostrEvent]()
    147         // TODO: fix duplicate results from search
    148         var keyset = Set<NoteKey>()
    149 
    150         // try reverse because newest first is a bit buggy on partial searches
    151         if note_keys.count == 0 {
    152             // don't touch existing results if there are no new ones
    153             return
    154         }
    155 
    156         do {
    157             guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
    158             for note_key in note_keys {
    159                 guard let note = damus_state.ndb.lookup_note_by_key_with_txn(note_key, txn: txn) else {
    160                     continue
    161                 }
    162 
    163                 if !keyset.contains(note_key) {
    164                     let owned_note = note.to_owned()
    165                     res.append(owned_note)
    166                     keyset.insert(note_key)
    167                 }
    168             }
    169         }
    170 
    171         let res_ = res
    172 
    173         Task { @MainActor [res_] in
    174             results = res_
    175         }
    176     }
    177     
    178     var body: some View {
    179         ScrollView {
    180             InnerSearchResults(damus_state: damus_state, search: result, results: $results)
    181                 .padding()
    182         }
    183         .frame(maxHeight: .infinity)
    184         .onAppear {
    185             guard let txn = NdbTxn.init(ndb: damus_state.ndb) else { return }
    186             self.result = search_for_string(profiles: damus_state.profiles, contacts: damus_state.contacts, search: search, txn: txn)
    187         }
    188         .onChange(of: search) { new in
    189             guard let txn = NdbTxn.init(ndb: damus_state.ndb) else { return }
    190             self.result = search_for_string(profiles: damus_state.profiles, contacts: damus_state.contacts, search: search, txn: txn)
    191         }
    192         .onChange(of: search) { query in
    193             debouncer.debounce {
    194                 Task.detached {
    195                     do_search(query: query)
    196                 }
    197             }
    198         }
    199     }
    200 }
    201 
    202 /*
    203 struct SearchResultsView_Previews: PreviewProvider {
    204     static var previews: some View {
    205         SearchResultsView(damus_state: test_damus_state(), s)
    206     }
    207 }
    208  */
    209 
    210 
    211 func search_for_string<Y>(profiles: Profiles, contacts: Contacts, search new: String, txn: NdbTxn<Y>) -> Search? {
    212     guard new.count != 0 else {
    213         return nil
    214     }
    215     
    216     let splitted = new.split(separator: "@")
    217     
    218     if splitted.count == 2 {
    219         return .nip05(new)
    220     }
    221     
    222     if new.first! == "#" {
    223         return .hashtag(make_hashtagable(new))
    224     }
    225     
    226     let searchQuery = remove_nostr_uri_prefix(new)
    227     
    228     if let new = hex_decode_id(searchQuery) {
    229         return .hex(new)
    230     }
    231 
    232     if searchQuery.starts(with: "npub") {
    233         if let decoded = bech32_pubkey_decode(searchQuery) {
    234             return .profile(decoded)
    235         }
    236     }
    237     
    238     if searchQuery.starts(with: "note"), let decoded = try? bech32_decode(searchQuery) {
    239         return .note(NoteId(decoded.data))
    240     }
    241     
    242     if searchQuery.starts(with: "nevent"), case let .nevent(nevent) = Bech32Object.parse(searchQuery) {
    243         return .nevent(nevent)
    244     }
    245     
    246     if searchQuery.starts(with: "nprofile"), case let .nprofile(nprofile) = Bech32Object.parse(searchQuery) {
    247         return .nprofile(nprofile)
    248     }
    249     
    250     if searchQuery.starts(with: "naddr"), case let .naddr(naddr) = Bech32Object.parse(searchQuery) {
    251         return .naddr(naddr)
    252     }
    253     
    254     let multisearch = MultiSearch(text: new, hashtag: make_hashtagable(searchQuery), profiles: search_profiles(profiles: profiles, contacts: contacts, search: new, txn: txn))
    255     return .multi(multisearch)
    256 }
    257 
    258 func make_hashtagable(_ str: String) -> String {
    259     var new = str
    260     guard str.utf8.count > 0 else {
    261         return str
    262     }
    263     
    264     if new.hasPrefix("#") {
    265         new = String(new.dropFirst())
    266     }
    267     
    268     return String(new.filter{$0 != " "})
    269 }
    270 
    271 func search_profiles<Y>(profiles: Profiles, contacts: Contacts, search: String, txn: NdbTxn<Y>) -> [Pubkey] {
    272     // Search by hex pubkey.
    273     if let pubkey = hex_decode_pubkey(search),
    274        profiles.lookup_key_by_pubkey(pubkey) != nil
    275     {
    276         return [pubkey]
    277     }
    278 
    279     // Search by npub pubkey.
    280     if search.starts(with: "npub"),
    281        let bech32_key = decode_bech32_key(search),
    282        case Bech32Key.pub(let pk) = bech32_key,
    283        profiles.lookup_key_by_pubkey(pk) != nil
    284     {
    285         return [pk]
    286     }
    287 
    288     return profiles.search(search, limit: 128, txn: txn).sorted { a, b in
    289         let aFriendTypePriority = get_friend_type(contacts: contacts, pubkey: a)?.priority ?? 0
    290         let bFriendTypePriority = get_friend_type(contacts: contacts, pubkey: b)?.priority ?? 0
    291 
    292         if aFriendTypePriority > bFriendTypePriority {
    293             // `a` should be sorted before `b`
    294             return true
    295         } else {
    296             return false
    297         }
    298     }
    299 }
    300