damus

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

commit 7ae66b84907c52685d18c6cbfe49c23f658b2d8e
parent bf43842590a0f62aafcd0f811d0634b396ab7f6e
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Fri, 20 Oct 2023 18:16:13 +0000

ui: Add suggested hashtags to universe view

This commit adds a suggested hashtag section to the universe view tab. The method for suggesting hashtags is currently simple:
1. It contains a list of many possible hashtags that we could recommend
2. It calculates how many users have been talking about it in the events fetched by the Universe tab
3. It selects the Top-N most mentioned suggested hashtags in the Universe tab

This has the following properties:
1. It has some spam resistance as it ranks by unique users mentioning the tag (instead of events)
2. It is a simple way to curate good hashtags
3. It shows the ones with the most amount of people talking about it among the notes fecthed in the Universe view

Testing
-------

PASS

Device: iPhone 14 Pro simulator
iOS: 17.0
Damus: This commit
Coverage:

1. Suggested hashtags are displayed
2. Layout looks similar to Figma
3. User count goes up (does not stay at zero)
4. Clicking on a suggested hashtag takes you to that hashtag view
5. Only the top 5 hashtags are displayed

Notes: The counts seem low, probably because there are not enough notes loaded in Universe View

Changelog-Added: Add suggested hashtags to universe view
Closes: https://github.com/damus-io/damus/issues/1569
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 4++++
Mdamus/Models/SearchHomeModel.swift | 2+-
Mdamus/Views/SearchHomeView.swift | 13+++++++++++++
Adamus/Views/SuggestedHashtagsView.swift | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 153 insertions(+), 1 deletion(-)

diff --git 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 = "<group>"; }; D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManager.swift; sourceTree = "<group>"; }; D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManagerTests.swift; sourceTree = "<group>"; }; + D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = "<group>"; }; D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; }; D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; }; D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = "<group>"; }; @@ -1719,6 +1721,7 @@ 50DA11252A16A23F00236234 /* Launch.storyboard */, 5C513FCB2984ACA60072348F /* QRCodeView.swift */, 643EA5C7296B764E005081BB /* RelayFilterView.swift */, + D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */, ); path = Views; sourceTree = "<group>"; @@ -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 @@ -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 @@ -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 @@ -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<Pubkey>([]), { 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 + ) + } +} +