damus

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

commit 422167f7aaac9f78bb2c7406012255fc4daea6a8
parent de84456a5759dad9d2dc346238d6a68aabfff033
Author: Terry Yiu <963907+tyiu@users.noreply.github.com>
Date:   Tue, 20 Jun 2023 00:54:26 -0400

Add indication of followers you know in a profile

Changelog-Added: Add indication of followers you know in a profile

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 4++++
Mdamus/Models/Contacts.swift | 20++++++++++++++++++++
Mdamus/Models/HomeModel.swift | 2+-
Mdamus/Views/FollowingView.swift | 21+++++++++++++++++++--
Adamus/Views/Profile/CondensedProfilePicturesView.swift | 38++++++++++++++++++++++++++++++++++++++
Mdamus/Views/Profile/ProfileView.swift | 45+++++++++++++++++++++++++++++++++++++++++++--
Mdamus/en-US.lproj/Localizable.stringsdict | 16++++++++++++++++
MdamusTests/ProfileViewTests.swift | 17+++++++++++++++++
8 files changed, 158 insertions(+), 5 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 3A3040F329A91366008A0F29 /* ProfileViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A3040F229A91366008A0F29 /* ProfileViewTests.swift */; }; 3A30410129AB12AA008A0F29 /* EventGroupViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A30410029AB12AA008A0F29 /* EventGroupViewTests.swift */; }; 3A4325A82961E11400BFCD9D /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 3A4325AA2961E11400BFCD9D /* Localizable.stringsdict */; }; + 3A4647CF2A413ADC00386AD8 /* CondensedProfilePicturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A4647CE2A413ADC00386AD8 /* CondensedProfilePicturesView.swift */; }; 3A48E7B029DFBE9D006E787E /* MutedThreadsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A48E7AF29DFBE9D006E787E /* MutedThreadsManager.swift */; }; 3A8CC6CC2A2CFEF900940F5F /* StringUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8CC6CB2A2CFEF900940F5F /* StringUtil.swift */; }; 3AA247FD297E3CFF0090C62D /* RepostsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FC297E3CFF0090C62D /* RepostsModel.swift */; }; @@ -369,6 +370,7 @@ 3A41E559299D52BE001FA465 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/InfoPlist.strings; sourceTree = "<group>"; }; 3A41E55A299D52BE001FA465 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; }; 3A41E55B299D52BE001FA465 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = id; path = id.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; + 3A4647CE2A413ADC00386AD8 /* CondensedProfilePicturesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CondensedProfilePicturesView.swift; sourceTree = "<group>"; }; 3A48E7AF29DFBE9D006E787E /* MutedThreadsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutedThreadsManager.swift; sourceTree = "<group>"; }; 3A5C4575296A879E0032D398 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "es-419"; path = "es-419.lproj/Localizable.stringsdict"; sourceTree = "<group>"; }; 3A5CAE1D298DC0DB00B5334F /* zh-CN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-CN"; path = "zh-CN.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; @@ -1226,6 +1228,7 @@ 4C9F18E329ABDE6D008C55EC /* MaybeAnonPfpView.swift */, 4C9BB83329C12D9900FC4E37 /* EventProfileName.swift */, 4C8D1A6B29F1DFC200ACDF75 /* FriendIcon.swift */, + 3A4647CE2A413ADC00386AD8 /* CondensedProfilePicturesView.swift */, ); path = Profile; sourceTree = "<group>"; @@ -1857,6 +1860,7 @@ 4C30AC8029A6A53F00E2BD5A /* ProfilePicturesView.swift in Sources */, 5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */, 4C8EC52529D1FA6C0085D9A8 /* DamusColors.swift in Sources */, + 3A4647CF2A413ADC00386AD8 /* CondensedProfilePicturesView.swift in Sources */, 4C5C7E6A284EDE2E00A22DF5 /* SearchResultsView.swift in Sources */, 4CE1399429F0669900AC6A0B /* BigButton.swift in Sources */, 7C60CAEF298471A1009C80D6 /* CoreSVG.swift in Sources */, diff --git a/damus/Models/Contacts.swift b/damus/Models/Contacts.swift @@ -11,6 +11,8 @@ import Foundation class Contacts { private var friends: Set<String> = Set() private var friend_of_friends: Set<String> = Set() + /// Tracks which friends are friends of a given pubkey. + private var pubkey_to_our_friends = [String : Set<String>]() private var muted: Set<String> = Set() let our_pubkey: String @@ -58,6 +60,10 @@ class Contacts { func remove_friend(_ pubkey: String) { friends.remove(pubkey) + + pubkey_to_our_friends.forEach { + pubkey_to_our_friends[$0.key]?.remove(pubkey) + } } func get_friend_list() -> [String] { @@ -73,6 +79,15 @@ class Contacts { for tag in contact.tags { if tag.count >= 2 && tag[0] == "p" { friend_of_friends.insert(tag[1]) + + // Exclude themself and us. + if contact.pubkey != our_pubkey && contact.pubkey != tag[1] { + if pubkey_to_our_friends[tag[1]] == nil { + pubkey_to_our_friends[tag[1]] = Set<String>() + } + + pubkey_to_our_friends[tag[1]]?.insert(contact.pubkey) + } } } } @@ -96,6 +111,11 @@ class Contacts { func follow_state(_ pubkey: String) -> FollowState { return is_friend(pubkey) ? .follows : .unfollows } + + /// Gets the list of pubkeys of our friends who follow the given pubkey. + func get_friended_followers(_ pubkey: String) -> [String] { + return Array((pubkey_to_our_friends[pubkey] ?? Set())) + } } func follow_user(pool: RelayPool, our_contacts: NostrEvent?, pubkey: String, privkey: String, follow: ReferencedId) -> NostrEvent? { diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -386,7 +386,7 @@ class HomeModel { var contacts_filter = NostrFilter(kinds: [.metadata]) contacts_filter.authors = friends - + var our_contacts_filter = NostrFilter(kinds: [.contacts, .metadata]) our_contacts_filter.authors = [damus_state.pubkey] diff --git a/damus/Views/FollowingView.swift b/damus/Views/FollowingView.swift @@ -29,9 +29,27 @@ struct FollowUserView: View { } } +struct FollowersYouKnowView: View { + let damus_state: DamusState + let friended_followers: [String] + + @EnvironmentObject var followers: FollowersModel + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(friended_followers, id: \.self) { pk in + FollowUserView(target: .pubkey(pk), damus_state: damus_state) + } + } + .padding(.horizontal) + } + .navigationBarTitle(NSLocalizedString("Followers You Know", comment: "Navigation bar title for view that shows who is following a user.")) + } +} + struct FollowersView: View { let damus_state: DamusState - let whos: String @EnvironmentObject var followers: FollowersModel @@ -58,7 +76,6 @@ struct FollowingView: View { let damus_state: DamusState let following: FollowingModel - let whos: String var body: some View { ScrollView { diff --git a/damus/Views/Profile/CondensedProfilePicturesView.swift b/damus/Views/Profile/CondensedProfilePicturesView.swift @@ -0,0 +1,38 @@ +// +// CondensedProfilePicturesView.swift +// damus +// +// Created by Terry Yiu on 6/19/23. +// + +import SwiftUI + +struct CondensedProfilePicturesView: View { + let state: DamusState + let pubkeys: [String] + let maxPictures: Int + + init(state: DamusState, pubkeys: [String], maxPictures: Int) { + self.state = state + self.pubkeys = pubkeys + self.maxPictures = min(maxPictures, pubkeys.count) + } + + var body: some View { + // Using ZStack to make profile pictures floating and stacked on top of each other. + ZStack { + ForEach((0..<maxPictures).reversed(), id: \.self) { index in + ProfilePicView(pubkey: pubkeys[index], size: 32.0, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation) + .offset(x: CGFloat(index) * 20) + } + } + // Padding is needed so that other components drawn adjacent to this view don't get drawn on top. + .padding(.trailing, CGFloat((maxPictures - 1) * 20)) + } +} + +struct CondensedProfilePicturesView_Previews: PreviewProvider { + static var previews: some View { + CondensedProfilePicturesView(state: test_damus_state(), pubkeys: ["a", "b", "c", "d"], maxPictures: 3) + } +} diff --git a/damus/Views/Profile/ProfileView.swift b/damus/Views/Profile/ProfileView.swift @@ -46,6 +46,31 @@ func relaysCountString(_ count: Int, locale: Locale = Locale.current) -> String return String(format: format, locale: locale, count) } +func followedByString(_ friend_intersection: [String], profiles: Profiles, locale: Locale = Locale.current) -> String { + let bundle = bundleForLocale(locale: locale) + let names: [String] = friend_intersection.prefix(3).map { + let profile = profiles.lookup(id: $0) + return Profile.displayName(profile: profile, pubkey: $0).username.truncate(maxLength: 20) + } + + switch friend_intersection.count { + case 0: + return "" + case 1: + let format = NSLocalizedString("Followed by %@", bundle: bundle, comment: "Text to indicate that the user is followed by one of our follows.") + return String(format: format, locale: locale, names[0]) + case 2: + let format = NSLocalizedString("Followed by %@ & %@", bundle: bundle, comment: "Text to indicate that the user is followed by two of our follows.") + return String(format: format, locale: locale, names[0], names[1]) + case 3: + let format = NSLocalizedString("Followed by %@, %@ & %@", bundle: bundle, comment: "Text to indicate that the user is followed by three of our follows.") + return String(format: format, locale: locale, names[0], names[1], names[2]) + default: + let format = localizedStringFormat(key: "followed_by_three_and_others", locale: locale) + return String(format: format, locale: locale, friend_intersection.count - 3, names[0], names[1], names[2]) + } +} + struct EditButton: View { let damus_state: DamusState @@ -379,7 +404,7 @@ struct ProfileView: View { if let contact = profile.contacts { let contacts = contact.referenced_pubkeys.map { $0.ref_id } let following_model = FollowingModel(damus_state: damus_state, contacts: contacts) - NavigationLink(destination: FollowingView(damus_state: damus_state, following: following_model, whos: profile.pubkey)) { + NavigationLink(destination: FollowingView(damus_state: damus_state, following: following_model)) { HStack { let noun_text = Text(verbatim: "\(followingCountString(profile.following))").font(.subheadline).foregroundColor(.gray) Text("\(Text(verbatim: profile.following.formatted()).font(.subheadline.weight(.medium))) \(noun_text)", comment: "Sentence composed of 2 variables to describe how many profiles a user is following. In source English, the first variable is the number of profiles being followed, and the second variable is 'Following'.") @@ -387,7 +412,7 @@ struct ProfileView: View { } .buttonStyle(PlainButtonStyle()) } - let fview = FollowersView(damus_state: damus_state, whos: profile.pubkey) + let fview = FollowersView(damus_state: damus_state) .environmentObject(followers) if followers.contacts != nil { NavigationLink(destination: fview) { @@ -420,6 +445,22 @@ struct ProfileView: View { } } } + + if profile.pubkey != damus_state.pubkey { + let friended_followers = damus_state.contacts.get_friended_followers(profile.pubkey) + if !friended_followers.isEmpty { + Spacer() + + NavigationLink(destination: FollowersYouKnowView(damus_state: damus_state, friended_followers: friended_followers)) { + HStack { + CondensedProfilePicturesView(state: damus_state, pubkeys: friended_followers, maxPictures: 3) + Text(followedByString(friended_followers, profiles: damus_state.profiles)) + .font(.subheadline).foregroundColor(.gray) + .multilineTextAlignment(.leading) + } + } + } + } } .padding(.horizontal) } diff --git a/damus/en-US.lproj/Localizable.stringsdict b/damus/en-US.lproj/Localizable.stringsdict @@ -18,6 +18,22 @@ <string>... %d other notes ...</string> </dict> </dict> + <key>followed_by_three_and_others</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@OTHERS@</string> + <key>OTHERS</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>d</string> + <key>one</key> + <string>Followed by %2$@, %3$@, %4$@ &amp; %1$d other</string> + <key>other</key> + <string>Followed by %2$@, %3$@, %4$@ &amp; %1$d others</string> + </dict> + </dict> <key>followers_count</key> <dict> <key>NSStringLocalizedFormatKey</key> diff --git a/damusTests/ProfileViewTests.swift b/damusTests/ProfileViewTests.swift @@ -53,4 +53,21 @@ final class ProfileViewTests: XCTestCase { } } + func testFollowedByString() throws { + let profiles = test_damus_state().profiles + + XCTAssertEqual(followedByString(["pk1"], profiles: profiles, locale: enUsLocale), "Followed by pk1:pk1") + XCTAssertEqual(followedByString(["pk1", "pk2"], profiles: profiles, locale: enUsLocale), "Followed by pk1:pk1 & pk2:pk2") + XCTAssertEqual(followedByString(["pk1", "pk2", "pk3"], profiles: profiles, locale: enUsLocale), "Followed by pk1:pk1, pk2:pk2 & pk3:pk3") + XCTAssertEqual(followedByString(["pk1", "pk2", "pk3", "pk4",], profiles: profiles, locale: enUsLocale), "Followed by pk1:pk1, pk2:pk2, pk3:pk3 & 1 other") + XCTAssertEqual(followedByString(["pk1", "pk2", "pk3", "pk4", "pk5"], profiles: profiles, locale: enUsLocale), "Followed by pk1:pk1, pk2:pk2, pk3:pk3 & 2 others") + + let pubkeys = ["pk1", "pk2", "pk3", "pk4", "pk5", "pk6", "pk7", "pk8", "pk9", "pk10"] + Bundle.main.localizations.map { Locale(identifier: $0) }.forEach { + for count in 1...10 { + XCTAssertNoThrow(followedByString(pubkeys.prefix(count).map { $0 }, profiles: profiles, locale: $0)) + } + } + } + }