From a87ba731605890d521b0892ef80bfd8f2bd0ff01 Mon Sep 17 00:00:00 2001 From: Terry Yiu Date: Sun, 23 Feb 2025 16:17:37 -0500 Subject: [PATCH 1/3] Fix reposts banner to be localizable Changelog-Fixed: Fixed reposts banner to be localizable Signed-off-by: Terry Yiu --- damus.xcodeproj/project.pbxproj | 4 ++ damus/Components/Reposted.swift | 56 ++++++++++++++++------- damus/Views/Reposts/RepostedEvent.swift | 2 +- damus/en-US.lproj/Localizable.stringsdict | 16 +++++++ damusTests/RepostedTests.swift | 37 +++++++++++++++ 5 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 damusTests/RepostedTests.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index d52ddb1b..2278519d 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 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 */; }; + 3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */; }; 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FE297E3D900090C62D /* RepostsView.swift */; }; 3AA24802297E3DC20090C62D /* RepostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA24801297E3DC20090C62D /* RepostView.swift */; }; 3AA59D1D2999B0400061C48E /* DraftsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA59D1C2999B0400061C48E /* DraftsModel.swift */; }; @@ -1805,6 +1806,7 @@ 3A96D41A298DA94500388A2A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; 3A96D41B298DA94500388A2A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 3A96D41C298DA94500388A2A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = nl; path = nl.lproj/Localizable.stringsdict; sourceTree = ""; }; + 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostedTests.swift; sourceTree = ""; }; 3A994C4C2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = th; path = th.lproj/Localizable.stringsdict; sourceTree = ""; }; 3A994C4D2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = ""; }; 3A994C4E2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = ""; }; @@ -3689,6 +3691,7 @@ D753CEA92BE9DE04001C3A5D /* MutingTests.swift */, 4C2D34402BDAF1B300F9FB44 /* NIP10Tests.swift */, D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */, + 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */, ); path = damusTests; sourceTree = ""; @@ -4882,6 +4885,7 @@ D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */, 4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */, 4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */, + 3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */, 4C7D097E2A0C58B900943473 /* WalletConnectTests.swift in Sources */, 4CB883AA297612FF00DC99E7 /* ZapTests.swift in Sources */, D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */, diff --git a/damus/Components/Reposted.swift b/damus/Components/Reposted.swift index 2c9df82f..3387e36c 100644 --- a/damus/Components/Reposted.swift +++ b/damus/Components/Reposted.swift @@ -10,36 +10,42 @@ import SwiftUI struct Reposted: View { let damus: DamusState let pubkey: Pubkey - let target: NoteId + let target: NostrEvent @State var reposts: Int - init(damus: DamusState, pubkey: Pubkey, target: NoteId) { + init(damus: DamusState, pubkey: Pubkey, target: NostrEvent) { self.damus = damus self.pubkey = pubkey self.target = target - self.reposts = damus.boosts.counts[target] ?? 1 + self.reposts = damus.boosts.counts[target.id] ?? 1 } var body: some View { HStack(alignment: .center) { Image("repost") .foregroundColor(Color.gray) - ProfileName(pubkey: pubkey, damus: damus, show_nip5_domain: false) - .foregroundColor(Color.gray) - NavigationLink(value: Route.Reposts(reposts: .reposts(state: damus, target: target))) { - let other_reposts = reposts - 1 - if other_reposts > 0 { - Text(" and \(other_reposts) others reposted", comment: "Text indicating that the note was reposted (i.e. re-shared) by multiple people") - .foregroundColor(Color.gray) - } else { - Text("reposted", comment: "Text indicating that the note was reposted (i.e. re-shared).") - .foregroundColor(Color.gray) - } + + // Show profile picture of the reposter only if the reposter is not the author of the reposted note. + if pubkey != target.pubkey { + ProfilePicView(pubkey: pubkey, size: eventview_pfp_size(.small), highlight: .none, profiles: damus.profiles, disable_animation: damus.settings.disable_animation) + .onTapGesture { + show_profile_action_sheet_if_enabled(damus_state: damus, pubkey: pubkey) + } + .onLongPressGesture(minimumDuration: 0.1) { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + damus.nav.push(route: Route.ProfileByKey(pubkey: pubkey)) + } + } + + NavigationLink(value: Route.Reposts(reposts: .reposts(state: damus, target: target.id))) { + Text(people_reposted_text(profiles: damus.profiles, pubkey: pubkey, reposts: reposts)) + .font(.subheadline) + .foregroundColor(.gray) } } .onReceive(handle_notify(.update_stats), perform: { note_id in - guard note_id == target else { return } - let repost_count = damus.boosts.counts[target] + guard note_id == target.id else { return } + let repost_count = damus.boosts.counts[target.id] if let repost_count, reposts != repost_count { reposts = repost_count } @@ -47,9 +53,25 @@ struct Reposted: View { } } +func people_reposted_text(profiles: Profiles, pubkey: Pubkey, reposts: Int, locale: Locale = Locale.current) -> String { + guard reposts > 0 else { + return "" + } + + let bundle = bundleForLocale(locale: locale) + let other_reposts = reposts - 1 + let display_name = event_author_name(profiles: profiles, pubkey: pubkey) + + if other_reposts == 0 { + return String(format: NSLocalizedString("%@ reposted", bundle: bundle, comment: "Text indicating that the note was reposted (i.e. re-shared)."), locale: locale, display_name) + } else { + return String(format: localizedStringFormat(key: "people_reposted_count", locale: locale), locale: locale, other_reposts, display_name) + } +} + struct Reposted_Previews: PreviewProvider { static var previews: some View { let test_state = test_damus_state - Reposted(damus: test_state, pubkey: test_state.pubkey, target: test_note.id) + Reposted(damus: test_state, pubkey: test_state.pubkey, target: test_note) } } diff --git a/damus/Views/Reposts/RepostedEvent.swift b/damus/Views/Reposts/RepostedEvent.swift index a9d1a7f2..b3406d3d 100644 --- a/damus/Views/Reposts/RepostedEvent.swift +++ b/damus/Views/Reposts/RepostedEvent.swift @@ -16,7 +16,7 @@ struct RepostedEvent: View { var body: some View { VStack(alignment: .leading) { NavigationLink(value: Route.ProfileByKey(pubkey: event.pubkey)) { - Reposted(damus: damus, pubkey: event.pubkey, target: inner_ev.id) + Reposted(damus: damus, pubkey: event.pubkey, target: inner_ev) .padding(.horizontal) } .buttonStyle(PlainButtonStyle()) diff --git a/damus/en-US.lproj/Localizable.stringsdict b/damus/en-US.lproj/Localizable.stringsdict index bef83fac..ad6c6feb 100644 --- a/damus/en-US.lproj/Localizable.stringsdict +++ b/damus/en-US.lproj/Localizable.stringsdict @@ -66,6 +66,22 @@ Imports + people_reposted_count + + NSStringLocalizedFormatKey + %#@REPOSTED@ + REPOSTED + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %2$@ and %1$d other reposted + other + %2$@ and %1$d others reposted + + reacted_tagged_in_3 NSStringLocalizedFormatKey diff --git a/damusTests/RepostedTests.swift b/damusTests/RepostedTests.swift new file mode 100644 index 00000000..0fce7f72 --- /dev/null +++ b/damusTests/RepostedTests.swift @@ -0,0 +1,37 @@ +// +// RepostedTests.swift +// damusTests +// +// Created by Terry Yiu on 2/23/25. +// + +import XCTest +@testable import damus + +final class RepostedTests: XCTestCase { + + func testPeopleRepostedText() throws { + let enUsLocale = Locale(identifier: "en-US") + let damusState = test_damus_state + let pubkey = test_pubkey + + // reposts must be greater than 0. Empty string is returned as a fallback if not. + XCTAssertEqual(people_reposted_text(profiles: damusState.profiles, pubkey: pubkey, reposts: -1, locale: enUsLocale), "") + XCTAssertEqual(people_reposted_text(profiles: damusState.profiles, pubkey: pubkey, reposts: 0, locale: enUsLocale), "") + + // Verify the English pluralization variations. + XCTAssertEqual(people_reposted_text(profiles: damusState.profiles, pubkey: pubkey, reposts: 1, locale: enUsLocale), "17ldvg64:nq5mhr77 reposted") + XCTAssertEqual(people_reposted_text(profiles: damusState.profiles, pubkey: pubkey, reposts: 2, locale: enUsLocale), "17ldvg64:nq5mhr77 and 1 other reposted") + XCTAssertEqual(people_reposted_text(profiles: damusState.profiles, pubkey: pubkey, reposts: 3, locale: enUsLocale), "17ldvg64:nq5mhr77 and 2 others reposted") + + // Sanity check that the non-English translations are likely not malformed. + Bundle.main.localizations.map { Locale(identifier: $0) }.forEach { + // -1...11 covers a lot (but not all) pluralization rules for different languages. + // However, it is good enough for a sanity check. + for reposts in -1...11 { + XCTAssertNoThrow(people_reposted_text(profiles: damusState.profiles, pubkey: pubkey, reposts: reposts, locale: $0)) + } + } + } + +} From caa4bfe864da09db6c999fe64dddb025470f6a2d Mon Sep 17 00:00:00 2001 From: Terry Yiu Date: Sun, 23 Feb 2025 22:18:48 -0500 Subject: [PATCH 2/3] Add Conversations tab to profiles Changelog-Added: Added Conversations tab to profiles Signed-off-by: Terry Yiu --- damus/Models/ContentFilters.swift | 5 +- damus/Models/ProfileModel.swift | 83 +++++++++++++++++++++------ damus/Views/Profile/ProfileView.swift | 22 +++++-- 3 files changed, 87 insertions(+), 23 deletions(-) diff --git a/damus/Models/ContentFilters.swift b/damus/Models/ContentFilters.swift index 425d2b35..12bb03d5 100644 --- a/damus/Models/ContentFilters.swift +++ b/damus/Models/ContentFilters.swift @@ -10,8 +10,9 @@ import Foundation /// Simple filter to determine whether to show posts or all posts and replies. enum FilterState : Int { - case posts_and_replies = 1 case posts = 0 + case posts_and_replies = 1 + case conversations = 2 func filter(ev: NostrEvent) -> Bool { switch self { @@ -19,6 +20,8 @@ enum FilterState : Int { return ev.known_kind == .boost || ev.known_kind == .highlight || !ev.is_reply() case .posts_and_replies: return true + case .conversations: + return true } } } diff --git a/damus/Models/ProfileModel.swift b/damus/Models/ProfileModel.swift index 90376383..4b30d0e2 100644 --- a/damus/Models/ProfileModel.swift +++ b/damus/Models/ProfileModel.swift @@ -22,8 +22,10 @@ class ProfileModel: ObservableObject, Equatable { var seen_event: Set = Set() var sub_id = UUID().description var prof_subid = UUID().description + var conversations_subid = UUID().description var findRelay_subid = UUID().description - + var conversation_events: Set = Set() + init(pubkey: Pubkey, damus: DamusState) { self.pubkey = pubkey self.damus = damus @@ -59,6 +61,9 @@ class ProfileModel: ObservableObject, Equatable { print("unsubscribing from profile \(pubkey) with sub_id \(sub_id)") damus.pool.unsubscribe(sub_id: sub_id) damus.pool.unsubscribe(sub_id: prof_subid) + if pubkey != damus.pubkey { + damus.pool.unsubscribe(sub_id: conversations_subid) + } } func subscribe() { @@ -69,13 +74,29 @@ class ProfileModel: ObservableObject, Equatable { text_filter.authors = [pubkey] text_filter.limit = 500 - - print("subscribing to profile \(pubkey) with sub_id \(sub_id)") + + print("subscribing to textlike events from profile \(pubkey) with sub_id \(sub_id)") //print_filters(relay_id: "profile", filters: [[text_filter], [profile_filter]]) damus.pool.subscribe(sub_id: sub_id, filters: [text_filter], handler: handle_event) damus.pool.subscribe(sub_id: prof_subid, filters: [profile_filter], handler: handle_event) + + subscribe_to_conversations() } - + + private func subscribe_to_conversations() { + // Only subscribe to conversation events if the profile is not us. + guard pubkey != damus.pubkey else { + return + } + + let conversation_kinds: [NostrKind] = [.text, .longform, .highlight] + let limit: UInt32 = 500 + let conversations_filter_them = NostrFilter(kinds: conversation_kinds, pubkeys: [damus.pubkey], limit: limit, authors: [pubkey]) + let conversations_filter_us = NostrFilter(kinds: conversation_kinds, pubkeys: [pubkey], limit: limit, authors: [damus.pubkey]) + print("subscribing to conversation events from and to profile \(pubkey) with sub_id \(conversations_subid)") + damus.pool.subscribe(sub_id: conversations_subid, filters: [conversations_filter_them, conversations_filter_us], handler: handle_event) + } + func handle_profile_contact_event(_ ev: NostrEvent) { process_contact_event(state: damus, ev: ev) @@ -90,15 +111,8 @@ class ProfileModel: ObservableObject, Equatable { self.following = count_pubkeys(ev.tags) self.relays = decode_json_relays(ev.content) } - - func add_event(_ ev: NostrEvent) { - guard ev.should_show_event else { - return - } - if seen_event.contains(ev.id) { - return - } + private func add_event(_ ev: NostrEvent) { if ev.is_textlike || ev.known_kind == .boost { if self.events.insert(ev) { self.objectWillChange.send() @@ -109,24 +123,57 @@ class ProfileModel: ObservableObject, Equatable { seen_event.insert(ev.id) } + // Ensure the event public key matches the public key(s) we are querying. + // This is done to protect against a relay not properly filtering events by the pubkey + // See https://github.com/damus-io/damus/issues/1846 for more information + private func relay_filtered_correctly(_ ev: NostrEvent, subid: String?) -> Bool { + if subid == self.conversations_subid { + switch ev.pubkey { + case self.pubkey: + return ev.referenced_pubkeys.contains(damus.pubkey) + case damus.pubkey: + return ev.referenced_pubkeys.contains(self.pubkey) + default: + return false + } + } + + return self.pubkey == ev.pubkey + } + private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) { switch ev { case .ws_event: return case .nostr_event(let resp): - guard resp.subid == self.sub_id || resp.subid == self.prof_subid else { + guard resp.subid == self.sub_id || resp.subid == self.prof_subid || resp.subid == self.conversations_subid else { return } switch resp { case .ok: break case .event(_, let ev): - // Ensure the event public key matches this profiles public key - // This is done to protect against a relay not properly filtering events by the pubkey - // See https://github.com/damus-io/damus/issues/1846 for more information - guard self.pubkey == ev.pubkey else { break } + guard ev.should_show_event else { + break + } - add_event(ev) + if !seen_event.contains(ev.id) { + guard relay_filtered_correctly(ev, subid: resp.subid) else { + break + } + + add_event(ev) + + if resp.subid == self.conversations_subid { + conversation_events.insert(ev.id) + } + } else if resp.subid == self.conversations_subid && !conversation_events.contains(ev.id) { + guard relay_filtered_correctly(ev, subid: resp.subid) else { + break + } + + conversation_events.insert(ev.id) + } case .notice: break //notify(.notice, notice) diff --git a/damus/Views/Profile/ProfileView.swift b/damus/Views/Profile/ProfileView.swift index a6b310eb..91a21b2e 100644 --- a/damus/Views/Profile/ProfileView.swift +++ b/damus/Views/Profile/ProfileView.swift @@ -122,6 +122,9 @@ struct ProfileView: View { func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) { var filters = ContentFilters.defaults(damus_state: damus_state) filters.append(fstate.filter) + if fstate == .conversations { + filters.append({ profile.conversation_events.contains($0.id) } ) + } return ContentFilters(filters: filters).filter } @@ -429,6 +432,17 @@ struct ProfileView: View { .padding(.horizontal) } + var tabs: [(String, FilterState)] { + var tabs = [ + (NSLocalizedString("Notes", comment: "Label for filter for seeing only notes (instead of notes and replies)."), FilterState.posts), + (NSLocalizedString("Notes & Replies", comment: "Label for filter for seeing notes and replies (instead of only notes)."), FilterState.posts_and_replies) + ] + if profile.pubkey != damus_state.pubkey && !profile.conversation_events.isEmpty { + tabs.append((NSLocalizedString("Conversations", comment: "Label for filter for seeing notes and replies that involve conversations between the signed in user and the current profile."), FilterState.conversations)) + } + return tabs + } + var body: some View { ZStack { ScrollView(.vertical) { @@ -440,10 +454,7 @@ struct ProfileView: View { aboutSection VStack(spacing: 0) { - CustomPicker(tabs: [ - (NSLocalizedString("Notes", comment: "Label for filter for seeing only notes (instead of notes and replies)."), FilterState.posts), - (NSLocalizedString("Notes & Replies", comment: "Label for filter for seeing notes and replies (instead of only notes)."), FilterState.posts_and_replies) - ], selection: $filter_state) + CustomPicker(tabs: tabs, selection: $filter_state) Divider() .frame(height: 1) } @@ -455,6 +466,9 @@ struct ProfileView: View { if filter_state == FilterState.posts_and_replies { InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.posts_and_replies)) } + if filter_state == FilterState.conversations && !profile.conversation_events.isEmpty { + InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.conversations)) + } } .padding(.horizontal, Theme.safeAreaInsets?.left) .zIndex(-yOffset > navbarHeight ? 0 : 1) From 71ec18f6c6f30a0aaab6088b15c430ab69ae5c20 Mon Sep 17 00:00:00 2001 From: Terry Yiu Date: Tue, 25 Feb 2025 09:21:31 -0500 Subject: [PATCH 3/3] Remove mystery tabs meant to fix tab switching bug that no longer exists Changelog-Removed: Removed mystery tabs meant to fix tab switching bug that no longer exists Signed-off-by: Terry Yiu --- damus/Views/Notifications/NotificationsView.swift | 13 ------------- damus/Views/Timeline/PostingTimelineView.swift | 8 -------- 2 files changed, 21 deletions(-) diff --git a/damus/Views/Notifications/NotificationsView.swift b/damus/Views/Notifications/NotificationsView.swift index 309513ea..1d7a6c07 100644 --- a/damus/Views/Notifications/NotificationsView.swift +++ b/damus/Views/Notifications/NotificationsView.swift @@ -60,21 +60,8 @@ struct NotificationsView: View { @Environment(\.colorScheme) var colorScheme - var mystery: some View { - let profile_txn = state.profiles.lookup(id: state.pubkey) - let profile = profile_txn?.unsafeUnownedValue - return VStack(spacing: 20) { - Text("Wake up, \(Profile.displayName(profile: profile, pubkey: state.pubkey).displayName.truncate(maxLength: 50))", comment: "Text telling the user to wake up, where the argument is their display name.") - Text("You are dreaming...", comment: "Text telling the user that they are dreaming.") - } - .id("what") - } - var body: some View { TabView(selection: $filter_state) { - // This is needed or else there is a bug when switching from the 3rd or 2nd tab to first. no idea why. - mystery - NotificationTab( NotificationFilter( state: .all, diff --git a/damus/Views/Timeline/PostingTimelineView.swift b/damus/Views/Timeline/PostingTimelineView.swift index a8ea05fa..8a37b5be 100644 --- a/damus/Views/Timeline/PostingTimelineView.swift +++ b/damus/Views/Timeline/PostingTimelineView.swift @@ -25,11 +25,6 @@ struct PostingTimelineView: View { @State var headerHeight: CGFloat = 0 @Binding var headerOffset: CGFloat @SceneStorage("PostingTimelineView.filter_state") var filter_state : FilterState = .posts_and_replies - - var mystery: some View { - Text("Are you lost?", comment: "Text asking the user if they are lost in the app.") - .id("what") - } func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) { var filters = ContentFilters.defaults(damus_state: damus_state) @@ -95,9 +90,6 @@ struct PostingTimelineView: View { VStack { ZStack { TabView(selection: $filter_state) { - // This is needed or else there is a bug when switching from the 3rd or 2nd tab to first. no idea why. - mystery - contentTimelineView(filter: content_filter(.posts)) .tag(FilterState.posts) .id(FilterState.posts)