diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index e4cce355..936fd40b 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -435,6 +435,7 @@ D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = D723C38D2AB8D83400065664 /* ContentFilters.swift */; }; D7315A2A2ACDF3B70036E30A /* DamusCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */; }; D7315A2C2ACDF4DA0036E30A /* DamusCacheManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */; }; + D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */; }; D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; }; D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; }; D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; }; @@ -1132,6 +1133,7 @@ D72A2D062AD9C1FB002AFF62 /* MockProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProfiles.swift; sourceTree = ""; }; D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManager.swift; sourceTree = ""; }; D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManagerTests.swift; sourceTree = ""; }; + D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = ""; }; D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = ""; }; D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = ""; }; D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = ""; }; @@ -1719,6 +1721,7 @@ 50DA11252A16A23F00236234 /* Launch.storyboard */, 5C513FCB2984ACA60072348F /* QRCodeView.swift */, 643EA5C7296B764E005081BB /* RelayFilterView.swift */, + D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */, ); path = Views; sourceTree = ""; @@ -2818,6 +2821,7 @@ 4CC14FF52A740BB7007AEB17 /* NoteId.swift in Sources */, 4C19AE512A5CEF7C00C90DB7 /* NostrScript.swift in Sources */, 4C32B95E2A9AD44700DC3548 /* FlatBufferObject.swift in Sources */, + D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */, 4C3EA64F28FF59F200C48A62 /* tal.c in Sources */, 4CB88393296F798300DC99E7 /* ReactionsModel.swift in Sources */, 5C42E78C29DB76D90086AAC1 /* EmptyUserSearchView.swift in Sources */, diff --git a/damus/Models/SearchHomeModel.swift b/damus/Models/SearchHomeModel.swift index b655b3e3..1fd3d372 100644 --- a/damus/Models/SearchHomeModel.swift +++ b/damus/Models/SearchHomeModel.swift @@ -17,7 +17,7 @@ class SearchHomeModel: ObservableObject { let damus_state: DamusState let base_subid = UUID().description let profiles_subid = UUID().description - let limit: UInt32 = 250 + let limit: UInt32 = 500 //let multiple_events_per_pubkey: Bool = false init(damus_state: DamusState) { diff --git a/damus/Views/SearchHomeView.swift b/damus/Views/SearchHomeView.swift index ac0649c7..9308539b 100644 --- a/damus/Views/SearchHomeView.swift +++ b/damus/Views/SearchHomeView.swift @@ -74,6 +74,19 @@ struct SearchHomeView: View { } return preferredLanguages.contains(note_lang) + }, + content: { + AnyView(VStack { + SuggestedHashtagsView(damus_state: damus_state, max_items: 5, events: model.events) + HStack { + Image(systemName: "bubble.fill") + Text(NSLocalizedString("All recent notes", comment: "A label indicating that the notes being displayed below it are all recent notes")) + Spacer() + } + .foregroundColor(.secondary) + .padding(.top, 20) + .padding(.horizontal) + }) } ) .refreshable { diff --git a/damus/Views/SuggestedHashtagsView.swift b/damus/Views/SuggestedHashtagsView.swift new file mode 100644 index 00000000..1cddfa5a --- /dev/null +++ b/damus/Views/SuggestedHashtagsView.swift @@ -0,0 +1,135 @@ +// +// SuggestedHashtagsView.swift +// damus +// +// Created by Daniel D’Aquino on 2023-10-09. +// + +import SwiftUI + +// Currently we have a hardcoded list of possible hashtags that might be nice to suggest, +// and we suggest the top-N ones most active in the past day. +// This might be simple and effective until we find a more sophisticated way to let the user discover new hashtags +let DEFAULT_SUGGESTED_HASHTAGS: [String] = [ + "grownostr", "damus", "zapathon", "introductions", "plebchain", "bitcoin", "food", + "coffeechain", "nostr", "asknostr", "bounty", "freedom", "freedomtech", "foodstr", + "memestr", "memes", "music", "musicstr", "art", "artstr" +] + +struct SuggestedHashtagsView: View { + struct HashtagWithUserCount: Hashable { + var hashtag: String + var count: Int + } + + let damus_state: DamusState + @StateObject var events: EventHolder + var item_limit: Int? + let suggested_hashtags: [String] + var hashtags_with_count_to_display: [HashtagWithUserCount] { + get { + let all_items = self.suggested_hashtags + .map({ hashtag in + return HashtagWithUserCount( + hashtag: hashtag, + count: self.users_talking_about(hashtag: Hashtag(hashtag: hashtag)) + ) + }) + .sorted(by: { a, b in + a.count > b.count + }) + guard let item_limit else { + return all_items + } + return Array(all_items.prefix(item_limit)) + } + } + + init(damus_state: DamusState, suggested_hashtags: [String]? = nil, max_items item_limit: Int? = nil, events: EventHolder) { + self.damus_state = damus_state + self.suggested_hashtags = suggested_hashtags ?? DEFAULT_SUGGESTED_HASHTAGS + self.item_limit = item_limit + _events = StateObject.init(wrappedValue: events) + } + + var body: some View { + VStack { + HStack { + Image(systemName: "sparkles") + Text(NSLocalizedString("Suggested hashtags", comment: "A label indicating that the items below it are suggested hashtags")) + Spacer() + } + .foregroundColor(.secondary) + .padding(.bottom, 10) + + ForEach(hashtags_with_count_to_display, + id: \.self) { hashtag_with_count in + SuggestedHashtagView(damus_state: damus_state, hashtag: hashtag_with_count.hashtag, count: hashtag_with_count.count) + } + } + .padding() + } + + private struct SuggestedHashtagView: View { // Purposefully private to SuggestedHashtagsView because it assumes the same 24h window + let damus_state: DamusState + let hashtag: String + let count: Int + + init(damus_state: DamusState, hashtag: String, count: Int) { + self.damus_state = damus_state + self.hashtag = hashtag + self.count = count + } + + var body: some View { + HStack { + SingleCharacterAvatar(character: "#") + + VStack(alignment: .leading, spacing: 10) { + Text("#\(hashtag)") + .bold() + + Text(self.count != 1 ? String( + format: NSLocalizedString("%d users talking about it", comment: "A label indicating how many users have been talking about a hashtag"), + self.count + ) : NSLocalizedString("1 user talking about it", comment: "A label indicating 1 user has been talking about a hashtag")) + .foregroundStyle(.secondary) + } + + Spacer() + } + .onTapGesture { + let search_model = SearchModel(state: damus_state, search: NostrFilter.init(hashtag: [hashtag])) + damus_state.nav.push(route: Route.Search(search: search_model)) + } + } + } + + func users_talking_about(hashtag: Hashtag) -> Int { + return self.events.all_events + .filter({ $0.referenced_hashtags.contains(hashtag)}) + .reduce(Set([]), { authors, note in + return authors.union([note.pubkey]) + }) + .count + } +} + +struct SuggestedHashtagsView_Previews: PreviewProvider { + static var previews: some View { + let time_window: TimeInterval = 24 * 60 * 60 // 1 day + let search_model = SearchModel( + state: test_damus_state, + search: NostrFilter.init( + since: UInt32(Date.now.timeIntervalSince1970 - time_window), + hashtag: ["nostr", "bitcoin", "zapathon"] + ) + ) + + SuggestedHashtagsView( + damus_state: test_damus_state, + events: search_model.events + ) + } +} +