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:
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$@ & %1$d other</string>
+ <key>other</key>
+ <string>Followed by %2$@, %3$@, %4$@ & %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))
+ }
+ }
+ }
+
}