damus

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

SuggestedHashtagsView.swift (11490B)


      1 //
      2 //  SuggestedHashtagsView.swift
      3 //  damus
      4 //
      5 //  Created by Daniel D’Aquino on 2023-10-09.
      6 //
      7 
      8 import SwiftUI
      9 
     10 // Currently we have a hardcoded list of possible hashtags that might be nice to suggest,
     11 // and we suggest the top-N ones most active in the past day.
     12 // This might be simple and effective until we find a more sophisticated way to let the user discover new hashtags
     13 let DEFAULT_SUGGESTED_HASHTAGS: [String] = [
     14     "grownostr", "damus", "zapathon", "introductions", "plebchain", "bitcoin", "food",
     15     "coffeechain", "nostr", "asknostr", "bounty", "freedom", "freedomtech", "foodstr",
     16     "memestr", "memes", "music", "musicstr", "art", "artstr"
     17 ]
     18 
     19 struct SuggestedHashtagsView: View {
     20     struct HashtagWithUserCount: Hashable {
     21         var hashtag: String
     22         var count: Int
     23     }
     24     
     25     let damus_state: DamusState
     26     @StateObject var events: EventHolder
     27     @SceneStorage("SuggestedHashtagsView.show_suggested_hashtags") var show_suggested_hashtags : Bool = true
     28     var item_limit: Int?
     29     let suggested_hashtags: [String]
     30     var hashtags_with_count_to_display: [HashtagWithUserCount] {
     31         get {
     32             let all_items = self.suggested_hashtags
     33                 .map({ hashtag in
     34                     return HashtagWithUserCount(
     35                         hashtag: hashtag,
     36                         count: self.users_talking_about(hashtag: Hashtag(hashtag: hashtag))
     37                     )
     38                 })
     39                 .sorted(by: { a, b in
     40                     a.count > b.count
     41                 })
     42             SuggestedHashtagsView.lastRefresh_hashtags = all_items // Collecting recent hash-tag data from Search-page
     43             guard let item_limit else {
     44                 return all_items
     45             }
     46             return Array(all_items.prefix(item_limit))
     47         }
     48     }
     49     
     50     
     51     static var lastRefresh_hashtags: [HashtagWithUserCount] = [] // Holds hash-tag data for PostView
     52     var isFromPostView: Bool
     53     var queryHashTag: String
     54     
     55     var filteredSuggestedHashtags: [HashtagWithUserCount] {
     56         let val = SuggestedHashtagsView.lastRefresh_hashtags.filter {$0.hashtag.hasPrefix(returnFirstWordOnly(hashTag: queryHashTag))}
     57         if val.isEmpty {
     58             if SuggestedHashtagsView.lastRefresh_hashtags.isEmpty {
     59                 // This is special case when user goes directly to PostView without opening Search-page previously.
     60                 var val = hashtags_with_count_to_display // retrieves default hash-tage values
     61                 // if not-found, put query hash tag at top
     62                 val.insert(HashtagWithUserCount(hashtag: returnFirstWordOnly(hashTag: queryHashTag), count: 0), at: 0)
     63                 return val
     64             } else {
     65                 // if not-found, put query hash tag at top
     66                 var val = SuggestedHashtagsView.lastRefresh_hashtags
     67                 val.insert(HashtagWithUserCount(hashtag: returnFirstWordOnly(hashTag: queryHashTag), count: 0), at: 0)
     68                return val
     69             }
     70         } else {
     71             return val
     72         }
     73     }
     74     
     75     @Binding var focusWordAttributes: (String?, NSRange?)
     76     @Binding var newCursorIndex: Int?
     77     @Binding var post: NSMutableAttributedString
     78     @EnvironmentObject var tagModel: TagModel
     79     
     80     init(damus_state: DamusState,
     81          suggested_hashtags: [String]? = nil,
     82          max_items item_limit: Int? = nil,
     83          events: EventHolder,
     84          isFromPostView: Bool = false,
     85          queryHashTag: String = "",
     86          focusWordAttributes: Binding<(String?, NSRange?)> = .constant((nil, nil)),
     87          newCursorIndex: Binding<Int?> = .constant(nil),
     88          post: Binding<NSMutableAttributedString> = .constant(NSMutableAttributedString(string: ""))) {
     89         self.damus_state = damus_state
     90         self.suggested_hashtags = suggested_hashtags ?? DEFAULT_SUGGESTED_HASHTAGS
     91         self.item_limit = item_limit
     92         
     93         self.isFromPostView = isFromPostView
     94         self.queryHashTag = queryHashTag
     95         self._focusWordAttributes = focusWordAttributes
     96         self._newCursorIndex = newCursorIndex
     97         self._post = post
     98         
     99         _events = StateObject.init(wrappedValue: events)
    100     }
    101     
    102     var body: some View {
    103         VStack {
    104             HStack {
    105                 Image(systemName: "sparkles")
    106                 Text("Suggested hashtags", comment: "A label indicating that the items below it are suggested hashtags")
    107                 Spacer()
    108                 // Don't show suggestion expand/contract button when user is in PostView
    109                 if !isFromPostView  {
    110                     Button(action: {
    111                         withAnimation(.easeOut(duration: 0.2)) {
    112                             show_suggested_hashtags.toggle()
    113                         }
    114                     }) {
    115                         if show_suggested_hashtags {
    116                             Image(systemName: "rectangle.compress.vertical")
    117                                 .foregroundStyle(PinkGradient)
    118                         } else {
    119                             Image(systemName: "rectangle.expand.vertical")
    120                                 .foregroundStyle(PinkGradient)
    121                         }
    122                     }
    123                 }
    124             }
    125             .foregroundColor(.secondary)
    126             .padding(.vertical, 10)
    127             
    128             if isFromPostView {
    129                 ScrollView {
    130                     LazyVStack {
    131                         ForEach(filteredSuggestedHashtags,
    132                                 id: \.self) { hashtag_with_count in
    133                             SuggestedHashtagView(damus_state: damus_state,
    134                                                  hashtag: hashtag_with_count.hashtag,
    135                                                  count: hashtag_with_count.count,
    136                                                  isFromPostView: true,
    137                                                  focusWordAttributes: $focusWordAttributes,
    138                                                  newCursorIndex: $newCursorIndex,
    139                                                  post: $post)
    140                             .environmentObject(tagModel)
    141                         }
    142                     }
    143                 }
    144             } else if show_suggested_hashtags {
    145                 ForEach(hashtags_with_count_to_display,
    146                         id: \.self) { hashtag_with_count in
    147                     SuggestedHashtagView(damus_state: damus_state, hashtag: hashtag_with_count.hashtag, count: hashtag_with_count.count)
    148                 }
    149             }
    150         }
    151         .padding(.horizontal)
    152     }
    153     
    154     private struct SuggestedHashtagView: View { // Purposefully private to SuggestedHashtagsView because it assumes the same 24h window
    155         let damus_state: DamusState
    156         let hashtag: String
    157         let count: Int
    158         
    159         let isFromPostView: Bool
    160         @Binding var focusWordAttributes: (String?, NSRange?)
    161         @Binding var newCursorIndex: Int?
    162         @Binding var post: NSMutableAttributedString
    163         @EnvironmentObject var tagModel: TagModel
    164         
    165         init(damus_state: DamusState,
    166              hashtag: String,
    167              count: Int,
    168              isFromPostView: Bool = false,
    169              focusWordAttributes: Binding<(String?, NSRange?)> = .constant((nil, nil)),
    170              newCursorIndex: Binding<Int?> = .constant(nil),
    171              post: Binding<NSMutableAttributedString> = .constant(NSMutableAttributedString(string: ""))) {
    172             self.damus_state = damus_state
    173             self.hashtag = hashtag
    174             self.count = count
    175             self.isFromPostView = isFromPostView
    176             self._focusWordAttributes = focusWordAttributes
    177             self._newCursorIndex = newCursorIndex
    178             self._post = post
    179         }
    180         
    181         var body: some View {
    182             HStack {
    183                 SingleCharacterAvatar(character: "#")
    184                 
    185                 VStack(alignment: .leading, spacing: 10) {
    186                     Text(verbatim: "#\(hashtag)")
    187                         .bold()
    188                     
    189                     // Don't show user-talking label from PostView when the count is 0
    190                     if isFromPostView {
    191                         if  count != 0 {
    192                             let pluralizedString = pluralizedString(key: "users_talking_about_it", count: self.count)
    193                             Text(pluralizedString)
    194                                 .foregroundStyle(.secondary)
    195                         }
    196                     } else {
    197                         let pluralizedString = pluralizedString(key: "users_talking_about_it", count: self.count)
    198                         Text(pluralizedString)
    199                             .foregroundStyle(.secondary)
    200                     }
    201                 }
    202                 
    203                 Spacer()
    204             }
    205             .contentShape(Rectangle()) // make the entire row/rectangle tappable
    206             .onTapGesture {
    207                 if isFromPostView {
    208                     let hashTag = NSMutableAttributedString(string: "#\(returnFirstWordOnly(hashTag: hashtag))",
    209                                                             attributes: [
    210                                                                 NSAttributedString.Key.foregroundColor: UIColor.black,
    211                                                                 NSAttributedString.Key.link: "#\(hashtag)"
    212                                                             ])
    213                     appendHashTag(withTag: hashTag)
    214                 } else {
    215                     let search_model = SearchModel(state: damus_state, search: NostrFilter.init(hashtag: [hashtag]))
    216                     damus_state.nav.push(route: Route.Search(search: search_model))
    217                 }
    218             }
    219         }
    220         
    221         // Current working-code similar to UserSearch/appendUserTag
    222         private func appendHashTag(withTag tag: NSMutableAttributedString) {
    223             guard let wordRange = focusWordAttributes.1 else { return }
    224             let appended = append_user_tag(tag: tag, post: post, word_range: wordRange)
    225             self.post = appended.post
    226             // adjust cursor position appropriately: ('diff' used in TextViewWrapper / updateUIView after below update of 'post')
    227             tagModel.diff = appended.tag.length - wordRange.length
    228             focusWordAttributes = (nil, nil)
    229             newCursorIndex = wordRange.location + appended.tag.length
    230         }
    231     }
    232     
    233     func users_talking_about(hashtag: Hashtag) -> Int {
    234         return self.events.all_events
    235             .filter({ $0.referenced_hashtags.contains(hashtag)})
    236             .reduce(Set<Pubkey>([]), { authors, note in
    237                 return authors.union([note.pubkey])
    238             })
    239             .count
    240     }
    241 }
    242 
    243 struct SuggestedHashtagsView_Previews: PreviewProvider {
    244     static var previews: some View {
    245         let time_window: TimeInterval = 24 * 60 * 60 // 1 day
    246         let search_model = SearchModel(
    247             state: test_damus_state,
    248             search: NostrFilter.init(
    249                 since: UInt32(Date.now.timeIntervalSince1970 - time_window),
    250                 hashtag: ["nostr", "bitcoin", "zapathon"]
    251             )
    252         )
    253         
    254         SuggestedHashtagsView(
    255             damus_state: test_damus_state,
    256             events: search_model.events
    257         )
    258     }
    259 }
    260 
    261 fileprivate func returnFirstWordOnly(hashTag: String) -> String {
    262     return hashTag.components(separatedBy: " ").first?.lowercased() ?? ""
    263 }