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 }