commit 935a6cae7a199871f06e42190919844c87266a1b
parent d4940d83869ef399dafdc1f2b7afd0b16a359fc4
Author: William Casarin <jb55@jb55.com>
Date: Tue, 25 Feb 2025 12:33:59 -0800
Merge conversation tab and other updates from Terry
I've tested these and they seem to be working!
Terry Yiu (3):
Fix reposts banner to be localizable
Add Conversations tab to profiles
Remove mystery tabs meant to fix tab switching bug that no longer exists
Diffstat:
10 files changed, 184 insertions(+), 62 deletions(-)
diff --git 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 = "<group>"; };
3A96D41B298DA94500388A2A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
3A96D41C298DA94500388A2A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = nl; path = nl.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
+ 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostedTests.swift; sourceTree = "<group>"; };
3A994C4C2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = th; path = th.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
3A994C4D2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = "<group>"; };
3A994C4E2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -3689,6 +3691,7 @@
D753CEA92BE9DE04001C3A5D /* MutingTests.swift */,
4C2D34402BDAF1B300F9FB44 /* NIP10Tests.swift */,
D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */,
+ 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */,
);
path = damusTests;
sourceTree = "<group>";
@@ -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
@@ -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/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
@@ -22,8 +22,10 @@ class ProfileModel: ObservableObject, Equatable {
var seen_event: Set<NoteId> = 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<NoteId> = 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
+ }
+
+ if !seen_event.contains(ev.id) {
+ guard relay_filtered_correctly(ev, subid: resp.subid) else {
+ break
+ }
- add_event(ev)
+ 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/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/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)
diff --git 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/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)
diff --git a/damus/en-US.lproj/Localizable.stringsdict b/damus/en-US.lproj/Localizable.stringsdict
@@ -66,6 +66,22 @@
<string>Imports</string>
</dict>
</dict>
+ <key>people_reposted_count</key>
+ <dict>
+ <key>NSStringLocalizedFormatKey</key>
+ <string>%#@REPOSTED@</string>
+ <key>REPOSTED</key>
+ <dict>
+ <key>NSStringFormatSpecTypeKey</key>
+ <string>NSStringPluralRuleType</string>
+ <key>NSStringFormatValueTypeKey</key>
+ <string>d</string>
+ <key>one</key>
+ <string>%2$@ and %1$d other reposted</string>
+ <key>other</key>
+ <string>%2$@ and %1$d others reposted</string>
+ </dict>
+ </dict>
<key>reacted_tagged_in_3</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
diff --git a/damusTests/RepostedTests.swift 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))
+ }
+ }
+ }
+
+}