From f2870b9a38ea63284c93bbc532f4d0520ca77381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Wed, 17 Sep 2025 17:17:43 -0700 Subject: [PATCH 1/5] Fix OS 26 build errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel D’Aquino --- damus.xcodeproj/project.pbxproj | 10 ++++++++++ damus/Core/NIPs/NIP04/NIP04.swift | 4 ++-- damus/Core/Nostr/NostrEvent.swift | 8 ++++---- damus/Features/Posting/Models/DraftsModel.swift | 2 +- .../Media/Video/DamusVideoCoordinator.swift | 2 +- damus/Shared/Utilities/DataExtensions.swift | 17 +++++++++++++++++ damusTests/NIP44v2EncryptionTests.swift | 4 ++-- nostrdb/NdbNote.swift | 2 +- 8 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 damus/Shared/Utilities/DataExtensions.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 8f086bcd..68262250 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -1587,6 +1587,10 @@ D76BE18C2E0CF3DA004AD0C6 /* Interests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76BE18B2E0CF3D5004AD0C6 /* Interests.swift */; }; D76BE18D2E0CF3DA004AD0C6 /* Interests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76BE18B2E0CF3D5004AD0C6 /* Interests.swift */; }; D76BE18E2E0CF3DA004AD0C6 /* Interests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76BE18B2E0CF3D5004AD0C6 /* Interests.swift */; }; + D77135D32E7B766B00E7639F /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77135D22E7B766300E7639F /* DataExtensions.swift */; }; + D77135D42E7B766B00E7639F /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77135D22E7B766300E7639F /* DataExtensions.swift */; }; + D77135D52E7B766B00E7639F /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77135D22E7B766300E7639F /* DataExtensions.swift */; }; + D77135D62E7B78D700E7639F /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77135D22E7B766300E7639F /* DataExtensions.swift */; }; D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; }; D773BC602C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; }; D77BFA0B2AE3051200621634 /* ProfileActionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */; }; @@ -2640,6 +2644,7 @@ D767066E2C8BB3CE00F09726 /* URLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHandler.swift; sourceTree = ""; }; D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileZapLinkView.swift; sourceTree = ""; }; D76BE18B2E0CF3D5004AD0C6 /* Interests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interests.swift; sourceTree = ""; }; + D77135D22E7B766300E7639F /* DataExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = ""; }; D773BC5E2C6D538500349F0A /* CommentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentItem.swift; sourceTree = ""; }; D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = ""; }; D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = ""; }; @@ -4661,6 +4666,7 @@ 5C78A7B82E3047DE00CF177D /* Utilities */ = { isa = PBXGroup; children = ( + D77135D22E7B766300E7639F /* DataExtensions.swift */, 4CF0ABEA29844B2F00D66079 /* AnyCodable */, D73B74E02D8365B40067BDBC /* ExtraFonts.swift */, D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */, @@ -5901,6 +5907,7 @@ D7100C5C2B77016700C59298 /* IAPProductStateView.swift in Sources */, 4CB9D4A72992D02B00A9A7E4 /* ProfileNameView.swift in Sources */, D733F9E82D92C76100317B11 /* UnownedNdbNote.swift in Sources */, + D77135D42E7B766B00E7639F /* DataExtensions.swift in Sources */, D74EA0902D2E271E002290DD /* ErrorView.swift in Sources */, 4CE4F0F429D779B5005914DB /* PostBox.swift in Sources */, BA37598E2ABCCE500018D73B /* VideoCaptureProcessor.swift in Sources */, @@ -5987,6 +5994,7 @@ 4C36246F2D5EA16A00DD066E /* str.c in Sources */, 4C36246E2D5EA10400DD066E /* hash_u5.c in Sources */, 4C36246C2D5EA0E500DD066E /* bolt11.c in Sources */, + D77135D52E7B766B00E7639F /* DataExtensions.swift in Sources */, 4C36246B2D5EA0D700DD066E /* invoice.c in Sources */, 4C36246A2D5EA0CB00DD066E /* content_parser.c in Sources */, 4C3624692D5EA0C200DD066E /* list.c in Sources */, @@ -6559,6 +6567,7 @@ D73E5E4F2C6A97F4007EB227 /* WebsiteLink.swift in Sources */, D73E5E502C6A97F4007EB227 /* Highlight.swift in Sources */, D73E5E512C6A97F4007EB227 /* CustomPicker.swift in Sources */, + D77135D32E7B766B00E7639F /* DataExtensions.swift in Sources */, D73E5E522C6A97F4007EB227 /* UserView.swift in Sources */, D73E5E532C6A97F4007EB227 /* ZoomableScrollView.swift in Sources */, D73E5E542C6A97F4007EB227 /* NoteZapButton.swift in Sources */, @@ -7152,6 +7161,7 @@ D798D22D2B086DC400234419 /* NostrEvent.swift in Sources */, D798D22E2B086E4800234419 /* NostrResponse.swift in Sources */, D7EDED162B1177840018B19C /* LNUrls.swift in Sources */, + D77135D62E7B78D700E7639F /* DataExtensions.swift in Sources */, D7CCFC132B05887C00323D86 /* ProofOfWork.swift in Sources */, D7CE1B392B0BE719002EDAD4 /* Table.swift in Sources */, D7CE1B452B0BE719002EDAD4 /* Root.swift in Sources */, diff --git a/damus/Core/NIPs/NIP04/NIP04.swift b/damus/Core/NIPs/NIP04/NIP04.swift index d9ec959e..78702712 100644 --- a/damus/Core/NIPs/NIP04/NIP04.swift +++ b/damus/Core/NIPs/NIP04/NIP04.swift @@ -12,11 +12,11 @@ struct NIP04 {} extension NIP04 { /// Encrypts a message using NIP-04. static func encrypt_message(message: String, privkey: Privkey, to_pk: Pubkey, encoding: EncEncoding = .base64) -> String? { - let iv = random_bytes(count: 16).bytes + let iv = random_bytes(count: 16).byteArray guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: to_pk) else { return nil } - let utf8_message = Data(message.utf8).bytes + let utf8_message = Data(message.utf8).byteArray guard let enc_message = aes_encrypt(data: utf8_message, iv: iv, shared_sec: shared_sec) else { return nil } diff --git a/damus/Core/Nostr/NostrEvent.swift b/damus/Core/Nostr/NostrEvent.swift index d3ea206d..0e6cb3e0 100644 --- a/damus/Core/Nostr/NostrEvent.swift +++ b/damus/Core/Nostr/NostrEvent.swift @@ -321,7 +321,7 @@ func sign_id(privkey: String, id: String) -> String { // Extra params for custom signing - var aux_rand = random_bytes(count: 64).bytes + var aux_rand = random_bytes(count: 64).byteArray var digest = try! id.bytes // API allows for signing variable length messages @@ -786,15 +786,15 @@ func validate_event(ev: NostrEvent) -> ValidationResult { let ctx = secp256k1.Context.raw var xonly_pubkey = secp256k1_xonly_pubkey.init() - var ev_pubkey = ev.pubkey.id.bytes + var ev_pubkey = ev.pubkey.id.byteArray var ok = secp256k1_xonly_pubkey_parse(ctx, &xonly_pubkey, &ev_pubkey) != 0 if !ok { return .bad_sig } - var sig = ev.sig.data.bytes - var idbytes = id.id.bytes + var sig = ev.sig.data.byteArray + var idbytes = id.id.byteArray ok = secp256k1_schnorrsig_verify(ctx, &sig, &idbytes, 32, &xonly_pubkey) > 0 return ok ? .ok : .bad_sig diff --git a/damus/Features/Posting/Models/DraftsModel.swift b/damus/Features/Posting/Models/DraftsModel.swift index b80ae633..074cf69c 100644 --- a/damus/Features/Posting/Models/DraftsModel.swift +++ b/damus/Features/Posting/Models/DraftsModel.swift @@ -6,7 +6,7 @@ // import Foundation -import SwiftUICore +import SwiftUI import UIKit /// Represents artifacts in a post draft, which is rendered by `PostView` diff --git a/damus/Shared/Media/Video/DamusVideoCoordinator.swift b/damus/Shared/Media/Video/DamusVideoCoordinator.swift index d0de60df..3e23948f 100644 --- a/damus/Shared/Media/Video/DamusVideoCoordinator.swift +++ b/damus/Shared/Media/Video/DamusVideoCoordinator.swift @@ -7,7 +7,7 @@ import Combine import Foundation -import SwiftUICore +import SwiftUI import AVFoundation /// DamusVideoCoordinator is responsible for coordinating the various video players throughout the app, and providing a nicely orchestrated experience. diff --git a/damus/Shared/Utilities/DataExtensions.swift b/damus/Shared/Utilities/DataExtensions.swift new file mode 100644 index 00000000..11bc3ced --- /dev/null +++ b/damus/Shared/Utilities/DataExtensions.swift @@ -0,0 +1,17 @@ +// +// DataExtensions.swift +// damus +// +// Created by Daniel D’Aquino on 2025-09-17. +// +import Foundation + +extension Data { + var byteArray: [UInt8] { + var bytesToReturn: [UInt8] = [] + for i in self.bytes.byteOffsets { + bytesToReturn.append(self[i]) + } + return bytesToReturn + } +} diff --git a/damusTests/NIP44v2EncryptionTests.swift b/damusTests/NIP44v2EncryptionTests.swift index 406d35dc..bcb59a0f 100644 --- a/damusTests/NIP44v2EncryptionTests.swift +++ b/damusTests/NIP44v2EncryptionTests.swift @@ -132,7 +132,7 @@ final class NIP44v2EncryptingTests: XCTestCase { try encryptDecryptVectors.forEach { vector in let conversationKey = vector.conversationKey let conversationKeyData = try XCTUnwrap(conversationKey.hexDecoded) - let conversationKeyBytes = conversationKeyData.bytes + let conversationKeyBytes = conversationKeyData.byteArray let nonce = try XCTUnwrap(vector.nonce.hexDecoded) let expectedPlaintextSHA256 = vector.plaintextSHA256 @@ -211,7 +211,7 @@ final class NIP44v2EncryptingTests: XCTestCase { func testInvalidDecrypt() throws { let decryptVectors = try XCTUnwrap(vectors.v2.invalid.decrypt) try decryptVectors.forEach { vector in - let conversationKey = try XCTUnwrap(vector.conversationKey.hexDecoded).bytes + let conversationKey = try XCTUnwrap(vector.conversationKey.hexDecoded).byteArray let payload = vector.payload XCTAssertThrowsError(try NIP44v2Encryption.decrypt(payload: payload, conversationKey: conversationKey), vector.note) } diff --git a/nostrdb/NdbNote.swift b/nostrdb/NdbNote.swift index 2ce94553..1cc11566 100644 --- a/nostrdb/NdbNote.swift +++ b/nostrdb/NdbNote.swift @@ -291,7 +291,7 @@ class NdbNote: Codable, Equatable, Hashable { return nil } case .manual(_, let signature, _): - var raw_sig = signature.data.bytes + var raw_sig = signature.data.byteArray ndb_builder_set_sig(&builder, &raw_sig) do { From 6605c5e5835fc32183764e4c11a6d6ebac19ca18 Mon Sep 17 00:00:00 2001 From: alltheseas <64376233+alltheseas@users.noreply.github.com> Date: Wed, 15 Oct 2025 00:03:20 -0500 Subject: [PATCH 2/5] Removes notifications from muted npubs --- damus/Features/Notifications/Views/NotificationsView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/damus/Features/Notifications/Views/NotificationsView.swift b/damus/Features/Notifications/Views/NotificationsView.swift index bfa9d520..f6e4c671 100644 --- a/damus/Features/Notifications/Views/NotificationsView.swift +++ b/damus/Features/Notifications/Views/NotificationsView.swift @@ -33,7 +33,7 @@ class NotificationFilter: ObservableObject, Equatable { self.hellthread_notification_max_pubkeys = hellthread_notification_max_pubkeys } - func filter(contacts: Contacts, items: [NotificationItem]) -> [NotificationItem] { + func filter(contacts: Contacts, mutelist_manager: MutelistManager, items: [NotificationItem]) -> [NotificationItem] { return items.reduce(into: []) { acc, item in if !self.state.filter(item) { @@ -41,6 +41,7 @@ class NotificationFilter: ObservableObject, Equatable { } if let item = item.filter({ ev in + !mutelist_manager.is_event_muted(ev) && self.friend_filter.filter(contacts: contacts, pubkey: ev.pubkey) && (!hellthread_notifications_disabled || !ev.is_hellthread(max_pubkeys: hellthread_notification_max_pubkeys)) && // Allow notes that are created no more than 3 seconds in the future @@ -169,7 +170,7 @@ struct NotificationsView: View { func NotificationTab(_ filter: NotificationFilter) -> some View { ScrollViewReader { scroller in ScrollView { - let notifs = Array(zip(1..., filter.filter(contacts: state.contacts, items: notifications.notifications))) + let notifs = Array(zip(1..., filter.filter(contacts: state.contacts, mutelist_manager: state.mutelist_manager, items: notifications.notifications))) if notifs.isEmpty { EmptyTimelineView() } else { From 61f695b7c63bfd32576935c1f2996b3869b2f516 Mon Sep 17 00:00:00 2001 From: Askia Linder Date: Wed, 27 Aug 2025 09:23:20 +0200 Subject: [PATCH 3/5] Add Timeline switcher button in PostingTimelineView. Switch between your following or NIP-81 favorites. User can favorite a user via ProfileActionSheetView or ProfileView. Closes: https://github.com/damus-io/damus/issues/2438 Changelog-Added: Add Timeline switcher button for NIP-81-favorites Signed-off-by: Askeew --- damus.xcodeproj/project.pbxproj | 60 +++++ damus/ContentView.swift | 4 + damus/Core/Nostr/NostrEvent.swift | 27 ++ damus/Core/Nostr/NostrKind.swift | 1 + damus/Core/Storage/DamusState.swift | 6 +- .../ContactCard/ContactCardManager.swift | 128 +++++++++ .../ContactCard/ContactCardManagerMock.swift | 26 ++ .../Views/FavoriteButtonView.swift | 40 +++ .../Models/LoadableNostrEventView.swift | 2 +- damus/Features/Follows/Models/Contacts.swift | 7 + .../Views/ProfileActionSheetView.swift | 47 +++- .../Features/Profile/Views/ProfileView.swift | 1 + damus/Features/Relays/Views/SignalView.swift | 15 +- .../Timeline/Models/ContentFilters.swift | 14 + .../Features/Timeline/Models/HomeModel.swift | 26 +- .../Timeline/Views/InnerTimelineView.swift | 4 +- .../Views/PostingTimelineSwitcherView.swift | 77 ++++++ .../Timeline/Views/PostingTimelineView.swift | 43 ++- damus/Notify/ContactCardNotify.swift | 16 ++ damus/TestData.swift | 1 + damusTests/Mocking/MockDamusState.swift | 4 +- .../Models/ContactCardManagerTests.swift | 249 ++++++++++++++++++ 22 files changed, 767 insertions(+), 31 deletions(-) create mode 100644 damus/Features/ContactCard/ContactCardManager.swift create mode 100644 damus/Features/ContactCard/ContactCardManagerMock.swift create mode 100644 damus/Features/ContactCard/Views/FavoriteButtonView.swift create mode 100644 damus/Features/Timeline/Views/PostingTimelineSwitcherView.swift create mode 100644 damus/Notify/ContactCardNotify.swift create mode 100644 damusTests/Models/ContactCardManagerTests.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 68262250..7a4488d8 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXBuildFile section */ 0E8A4BB72AE4359200065E81 /* NostrFilter+Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */; }; + 2710433D2E6BFE340005C3B0 /* PostingTimelineSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2710433C2E6BFE2A0005C3B0 /* PostingTimelineSwitcherView.swift */; }; + 2710433E2E6BFE340005C3B0 /* PostingTimelineSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2710433C2E6BFE2A0005C3B0 /* PostingTimelineSwitcherView.swift */; }; + 2710433F2E6BFE340005C3B0 /* PostingTimelineSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2710433C2E6BFE2A0005C3B0 /* PostingTimelineSwitcherView.swift */; }; 3165648B295B70D500C64604 /* LinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3165648A295B70D500C64604 /* LinkView.swift */; }; 3169CAE6294E69C000EE4006 /* EmptyTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3169CAE5294E69C000EE4006 /* EmptyTimelineView.swift */; }; 3169CAED294FCCFC00EE4006 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3169CAEC294FCCFC00EE4006 /* Constants.swift */; }; @@ -1029,6 +1032,19 @@ BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; }; BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; }; D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; }; + D5C1AFBF2E5DF7E60092F72F /* ContactCardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */; }; + D5C1AFC02E5DF7E60092F72F /* ContactCardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */; }; + D5C1AFC12E5DF7E60092F72F /* ContactCardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */; }; + D5C1AFC42E5DFF700092F72F /* ContactCardManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */; }; + D5C1AFC52E5DFF700092F72F /* ContactCardManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */; }; + D5C1AFC62E5DFF700092F72F /* ContactCardManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */; }; + D5C1AFC82E5E00690092F72F /* ContactCardManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFC72E5E00690092F72F /* ContactCardManagerTests.swift */; }; + D5C1AFCA2E5EE12B0092F72F /* ContactCardNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFC92E5EE12B0092F72F /* ContactCardNotify.swift */; }; + D5C1AFCB2E5EE12B0092F72F /* ContactCardNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFC92E5EE12B0092F72F /* ContactCardNotify.swift */; }; + D5C1AFCC2E5EE12B0092F72F /* ContactCardNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFC92E5EE12B0092F72F /* ContactCardNotify.swift */; }; + D5C1AFD32E5EE2820092F72F /* FavoriteButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFD12E5EE2820092F72F /* FavoriteButtonView.swift */; }; + D5C1AFD42E5EE2820092F72F /* FavoriteButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFD12E5EE2820092F72F /* FavoriteButtonView.swift */; }; + D5C1AFD52E5EE2820092F72F /* FavoriteButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFD12E5EE2820092F72F /* FavoriteButtonView.swift */; }; D703D7192C66E47100A400EA /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D703D7182C66E47100A400EA /* UniformTypeIdentifiers.framework */; }; D703D71C2C66E47100A400EA /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D703D71B2C66E47100A400EA /* Media.xcassets */; }; D703D71E2C66E47100A400EA /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D703D71D2C66E47100A400EA /* ActionViewController.swift */; }; @@ -1879,6 +1895,7 @@ /* Begin PBXFileReference section */ 0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrFilter+Hashable.swift"; sourceTree = ""; }; + 2710433C2E6BFE2A0005C3B0 /* PostingTimelineSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingTimelineSwitcherView.swift; sourceTree = ""; }; 3165648A295B70D500C64604 /* LinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkView.swift; sourceTree = ""; }; 3169CAE5294E69C000EE4006 /* EmptyTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyTimelineView.swift; sourceTree = ""; }; 3169CAEC294FCCFC00EE4006 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Constants.swift; path = damus/Shared/Utilities/Constants.swift; sourceTree = SOURCE_ROOT; }; @@ -2581,6 +2598,11 @@ BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = ""; }; BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = ""; }; D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; + D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManager.swift; sourceTree = ""; }; + D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManagerMock.swift; sourceTree = ""; }; + D5C1AFC72E5E00690092F72F /* ContactCardManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManagerTests.swift; sourceTree = ""; }; + D5C1AFC92E5EE12B0092F72F /* ContactCardNotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardNotify.swift; sourceTree = ""; }; + D5C1AFD12E5EE2820092F72F /* FavoriteButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteButtonView.swift; sourceTree = ""; }; D703D7172C66E47100A400EA /* HighlighterActionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HighlighterActionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D703D7182C66E47100A400EA /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; D703D71B2C66E47100A400EA /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = ""; }; @@ -3206,6 +3228,7 @@ 4CA3529C2A76AE47003BB08B /* Notify */ = { isa = PBXGroup; children = ( + D5C1AFC92E5EE12B0092F72F /* ContactCardNotify.swift */, D706C5B62D602A050027C627 /* QueueableNotify.swift */, D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */, 4C86F7C52A76C51100EC0817 /* AttachedWalletNotify.swift */, @@ -3986,6 +4009,7 @@ 5C78A7792E22FDFE00CF177D /* Features */ = { isa = PBXGroup; children = ( + D5C1AFC22E5DFF040092F72F /* ContactCard */, 5C78A7BC2E304D7400CF177D /* Translations */, 5C78A7B52E3046F400CF177D /* NIP05 */, 5C78A7AA2E30428D00CF177D /* Actions */, @@ -4534,6 +4558,7 @@ 5C78A7A92E30419B00CF177D /* Views */ = { isa = PBXGroup; children = ( + 2710433C2E6BFE2A0005C3B0 /* PostingTimelineSwitcherView.swift */, 4CE0E2B529A3ED5500DB4CA2 /* InnerTimelineView.swift */, 5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */, 4CA2EF9F280E37AC0044ACD8 /* TimelineView.swift */, @@ -4850,6 +4875,24 @@ path = Camera; sourceTree = ""; }; + D5C1AFC22E5DFF040092F72F /* ContactCard */ = { + isa = PBXGroup; + children = ( + D5C1AFD22E5EE2820092F72F /* Views */, + D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */, + D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */, + ); + path = ContactCard; + sourceTree = ""; + }; + D5C1AFD22E5EE2820092F72F /* Views */ = { + isa = PBXGroup; + children = ( + D5C1AFD12E5EE2820092F72F /* FavoriteButtonView.swift */, + ); + path = Views; + sourceTree = ""; + }; D703D71A2C66E47100A400EA /* highlighter action extension */ = { isa = PBXGroup; children = ( @@ -5028,6 +5071,7 @@ F944F56C29EA9CB20067B3BF /* Models */ = { isa = PBXGroup; children = ( + D5C1AFC72E5E00690092F72F /* ContactCardManagerTests.swift */, F944F56D29EA9CCC0067B3BF /* DamusParseContentTests.swift */, 75AD872A2AA23A460085EF2C /* Block+Tests.swift */, B5A75C292B546D94007AFBC0 /* MuteItemTests.swift */, @@ -5438,6 +5482,7 @@ D798D22C2B086C7400234419 /* NostrEvent+.swift in Sources */, 4C75EFB728049D990006080F /* RelayPool.swift in Sources */, F757933A29D7AECD007DEAC1 /* MediaPicker.swift in Sources */, + D5C1AFBF2E5DF7E60092F72F /* ContactCardManager.swift in Sources */, 4CC6AA752CAB688500989CEF /* str.c in Sources */, 4CC6AA762CAB688500989CEF /* tal.c in Sources */, 4CC6AA782CAB688500989CEF /* mem.c in Sources */, @@ -5454,6 +5499,7 @@ D7DB930B2D69486700DA1EE5 /* NIP65.swift in Sources */, 4CB8838D296F710400DC99E7 /* Reposted.swift in Sources */, 4CE6DEE927F7A08100C66700 /* ContentView.swift in Sources */, + 2710433D2E6BFE340005C3B0 /* PostingTimelineSwitcherView.swift in Sources */, 4CEE2AF5280B29E600AB5EEF /* TimeAgo.swift in Sources */, 4CC14FF12A73FCDB007AEB17 /* Pubkey.swift in Sources */, 5C8711DE2C460C06007879C2 /* PostingTimelineView.swift in Sources */, @@ -5531,6 +5577,7 @@ 4C285C8E28399BFE008A31F1 /* SaveKeysView.swift in Sources */, F7F0BA25297892BD009531F3 /* SwipeToDismiss.swift in Sources */, 4C8D00CA29DF80350036AF10 /* TruncatedText.swift in Sources */, + D5C1AFC62E5DFF700092F72F /* ContactCardManagerMock.swift in Sources */, 4C4DD3DB2A6CA7E8005B4E85 /* ContentParsing.swift in Sources */, F71694F22A67314D001F4053 /* SuggestedUserView.swift in Sources */, 4C9BB83429C12D9900FC4E37 /* EventProfileName.swift in Sources */, @@ -5829,6 +5876,8 @@ D76BE18D2E0CF3DA004AD0C6 /* Interests.swift in Sources */, 4C9BB83129C0ED4F00FC4E37 /* DisplayName.swift in Sources */, 7CFF6317299FEFE5005D382A /* SelectableText.swift in Sources */, + D5C1AFD42E5EE2820092F72F /* FavoriteButtonView.swift in Sources */, + D5C1AFCA2E5EE12B0092F72F /* ContactCardNotify.swift in Sources */, 4CA352A82A76B37E003BB08B /* NewMutesNotify.swift in Sources */, 4CFF8F6929CC9ED1008DB934 /* ImageContainerView.swift in Sources */, 7527271E2A93FF0100214108 /* Block.swift in Sources */, @@ -5950,6 +5999,7 @@ 4CB883AA297612FF00DC99E7 /* ZapTests.swift in Sources */, D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */, 4CB8839A297322D200DC99E7 /* DMTests.swift in Sources */, + D5C1AFC82E5E00690092F72F /* ContactCardManagerTests.swift in Sources */, D7315A2C2ACDF4DA0036E30A /* DamusCacheManagerTests.swift in Sources */, 4C9054852A6AEAA000811EEC /* NdbTests.swift in Sources */, 75AD872B2AA23A460085EF2C /* Block+Tests.swift in Sources */, @@ -6046,6 +6096,7 @@ 82D6FACA2CD99F7900C925F4 /* NostrScript.swift in Sources */, 82D6FACB2CD99F7900C925F4 /* nostrscript.c in Sources */, D73C7EDA2DE51690001F9392 /* OnboardingSuggestionsView.swift in Sources */, + D5C1AFC52E5DFF700092F72F /* ContactCardManagerMock.swift in Sources */, 82D6FADE2CD99F7900C925F4 /* ThreadReply.swift in Sources */, 82D6FADF2CD99F7900C925F4 /* AttachedWalletNotify.swift in Sources */, 82D6FAE02CD99F7900C925F4 /* DisplayTabBarNotify.swift in Sources */, @@ -6105,6 +6156,7 @@ D7DB93052D66A44100DA1EE5 /* Undistractor.swift in Sources */, 82D6FB0C2CD99F7900C925F4 /* GoldSupportGradient.swift in Sources */, 82D6FB0D2CD99F7900C925F4 /* PinkGradient.swift in Sources */, + D5C1AFD32E5EE2820092F72F /* FavoriteButtonView.swift in Sources */, 82D6FB0E2CD99F7900C925F4 /* GrayGradient.swift in Sources */, 82D6FB0F2CD99F7900C925F4 /* DamusLogoGradient.swift in Sources */, 82D6FB102CD99F7900C925F4 /* DamusBackground.swift in Sources */, @@ -6247,6 +6299,7 @@ 82D6FB912CD99F7900C925F4 /* UserSettingsStore.swift in Sources */, 82D6FB922CD99F7900C925F4 /* Wallet.swift in Sources */, 82D6FB932CD99F7900C925F4 /* Report.swift in Sources */, + 2710433F2E6BFE340005C3B0 /* PostingTimelineSwitcherView.swift in Sources */, 82D6FB942CD99F7900C925F4 /* LibreTranslateServer.swift in Sources */, D74EA08E2D2E271E002290DD /* ErrorView.swift in Sources */, 82D6FB952CD99F7900C925F4 /* TranslationService.swift in Sources */, @@ -6418,6 +6471,7 @@ 82D6FC312CD99F7900C925F4 /* ProxyView.swift in Sources */, 82D6FC322CD99F7900C925F4 /* SelectedEventView.swift in Sources */, 82D6FC332CD99F7900C925F4 /* EventBody.swift in Sources */, + D5C1AFC02E5DF7E60092F72F /* ContactCardManager.swift in Sources */, 82D6FC342CD99F7900C925F4 /* BuilderEventView.swift in Sources */, 82D6FC352CD99F7900C925F4 /* EventProfile.swift in Sources */, 82D6FC362CD99F7900C925F4 /* EventMenu.swift in Sources */, @@ -6493,6 +6547,7 @@ 82D6FC772CD99F7900C925F4 /* SuggestedHashtagsView.swift in Sources */, 82D6FC782CD99F7900C925F4 /* ProfileActionSheetView.swift in Sources */, 82D6FC792CD99F7900C925F4 /* damusApp.swift in Sources */, + D5C1AFCC2E5EE12B0092F72F /* ContactCardNotify.swift in Sources */, 82D6FC7A2CD99F7900C925F4 /* ContentView.swift in Sources */, 82D6FC7B2CD99F7900C925F4 /* TestData.swift in Sources */, 82D6FC7C2CD99F7900C925F4 /* ContentParsing.swift in Sources */, @@ -6517,6 +6572,7 @@ 4C3624732D5EA1BE00DD066E /* nostrdb.c in Sources */, 4C3624602D5E9EB800DD066E /* NdbProfile.swift in Sources */, 4C36245F2D5E9B5F00DD066E /* NdbBlock.swift in Sources */, + D5C1AFD52E5EE2820092F72F /* FavoriteButtonView.swift in Sources */, D73E5E202C6A97F4007EB227 /* AttachedWalletNotify.swift in Sources */, D73E5E212C6A97F4007EB227 /* DisplayTabBarNotify.swift in Sources */, D73E5E222C6A97F4007EB227 /* BroadcastNotify.swift in Sources */, @@ -6577,6 +6633,7 @@ D73E5E582C6A97F4007EB227 /* ThiccDivider.swift in Sources */, D73E5E592C6A97F4007EB227 /* IconLabel.swift in Sources */, D73E5E5A2C6A97F4007EB227 /* TruncatedText.swift in Sources */, + D5C1AFC42E5DFF700092F72F /* ContactCardManagerMock.swift in Sources */, D73E5E5B2C6A97F4007EB227 /* SupporterBadge.swift in Sources */, D73E5E5C2C6A97F4007EB227 /* GradientButtonStyle.swift in Sources */, D73E5E5D2C6A97F4007EB227 /* NeutralButtonStyle.swift in Sources */, @@ -6706,6 +6763,7 @@ D73E5EB92C6A97F4007EB227 /* RelayLog.swift in Sources */, D73E5EBA2C6A97F4007EB227 /* NostrFilter.swift in Sources */, D73E5EBB2C6A97F4007EB227 /* Nip98HTTPAuth.swift in Sources */, + D5C1AFCB2E5EE12B0092F72F /* ContactCardNotify.swift in Sources */, 3A92C0FF2DE16E9800CEEBAC /* FaviconCache.swift in Sources */, D73E5EBC2C6A97F4007EB227 /* Relay.swift in Sources */, D73E5EBD2C6A97F4007EB227 /* NostrRequest.swift in Sources */, @@ -6764,6 +6822,7 @@ D73E5EF62C6A97F4007EB227 /* SearchingEventView.swift in Sources */, D73E5EF72C6A97F4007EB227 /* PullDownSearch.swift in Sources */, D73E5EF82C6A97F4007EB227 /* NotificationsView.swift in Sources */, + D5C1AFC12E5DF7E60092F72F /* ContactCardManager.swift in Sources */, D73B74E32D8365BA0067BDBC /* ExtraFonts.swift in Sources */, D73E5EF92C6A97F4007EB227 /* EventGroupView.swift in Sources */, D73E5EFA2C6A97F4007EB227 /* NotificationItemView.swift in Sources */, @@ -6965,6 +7024,7 @@ D703D7A42C670E3C00A400EA /* midl.c in Sources */, D7DB1FE02D5A78CE00CF06DA /* NIP44.swift in Sources */, D706C5B02D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */, + 2710433E2E6BFE340005C3B0 /* PostingTimelineSwitcherView.swift in Sources */, D703D78B2C670C9500A400EA /* MakeZapRequest.swift in Sources */, D703D7862C670C6500A400EA /* NewUnmutesNotify.swift in Sources */, D703D7662C670AFC00A400EA /* AsciiCharacter.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 23969458..95c4e9bc 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -447,6 +447,9 @@ struct ContentView: View { .onReceive(handle_notify(.present_full_screen_item)) { item in self.active_full_screen_item = item } + .onReceive(handle_notify(.favoriteUpdated)) { _ in + home.refresh_home_filters() + } .onReceive(handle_notify(.zapping)) { zap_ev in guard !zap_ev.is_custom else { return @@ -681,6 +684,7 @@ struct ContentView: View { likes: EventCounter(our_pubkey: pubkey), boosts: EventCounter(our_pubkey: pubkey), contacts: Contacts(our_pubkey: pubkey), + contactCards: ContactCardManager(), mutelist_manager: MutelistManager(user_keypair: keypair), profiles: Profiles(ndb: ndb), dms: home.dms, diff --git a/damus/Core/Nostr/NostrEvent.swift b/damus/Core/Nostr/NostrEvent.swift index 0e6cb3e0..9ead4f6c 100644 --- a/damus/Core/Nostr/NostrEvent.swift +++ b/damus/Core/Nostr/NostrEvent.swift @@ -872,4 +872,31 @@ extension NostrEvent { return nil } } + +#if DEBUG + var debugDescription: String { + var output = "πŸ” NostrEvent Debug Info\n" + output += "═══════════════════════════\n" + output += "πŸ“ ID: \(id)\n" + output += "πŸ‘€ Pubkey: \(pubkey)\n" + output += "πŸ“… Created: \(Date(timeIntervalSince1970: TimeInterval(created_at))) (\(created_at))\n" + output += "🏷️ Kind: \(kind) (\(String(describing: known_kind))\n" + output += "✍️ Signature: \(sig)\n" + output += "πŸ“„ Content (\(content.count) chars):\n" + output += " \"\(content.prefix(100))\(content.count > 100 ? "..." : "")\"\n" + + output += "\n🏷️ Tags (\(tags.count) total):\n" + for (index, tag) in tags.enumerated() { + output += " [\(index)]: [" + for (tagIndex, tagElem) in tag.enumerated() { + if tagIndex > 0 { output += ", " } + output += "\"\(tagElem.string())\"" + } + output += "]\n" + } + + output += "═══════════════════════════\n" + return output + } +#endif } diff --git a/damus/Core/Nostr/NostrKind.swift b/damus/Core/Nostr/NostrKind.swift index 7634aeff..ffc16a39 100644 --- a/damus/Core/Nostr/NostrKind.swift +++ b/damus/Core/Nostr/NostrKind.swift @@ -31,5 +31,6 @@ enum NostrKind: UInt32, Codable { case nwc_response = 23195 case http_auth = 27235 case status = 30315 + case contact_card = 30_382 case follow_list = 39089 } diff --git a/damus/Core/Storage/DamusState.swift b/damus/Core/Storage/DamusState.swift index dbabdf3f..702ca4fe 100644 --- a/damus/Core/Storage/DamusState.swift +++ b/damus/Core/Storage/DamusState.swift @@ -15,6 +15,7 @@ class DamusState: HeadlessDamusState { let boosts: EventCounter let quote_reposts: EventCounter let contacts: Contacts + let contactCards: ContactCard let mutelist_manager: MutelistManager let profiles: Profiles let dms: DirectMessagesModel @@ -39,11 +40,12 @@ class DamusState: HeadlessDamusState { let favicon_cache: FaviconCache private(set) var nostrNetwork: NostrNetworkManager - init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache) { + init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, contactCards: ContactCard, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache) { self.keypair = keypair self.likes = likes self.boosts = boosts self.contacts = contacts + self.contactCards = contactCards self.mutelist_manager = mutelist_manager self.profiles = profiles self.dms = dms @@ -109,6 +111,7 @@ class DamusState: HeadlessDamusState { likes: EventCounter(our_pubkey: pubkey), boosts: EventCounter(our_pubkey: pubkey), contacts: Contacts(our_pubkey: pubkey), + contactCards: ContactCardManager(), mutelist_manager: MutelistManager(user_keypair: keypair), profiles: Profiles(ndb: ndb), dms: home.dms, @@ -178,6 +181,7 @@ class DamusState: HeadlessDamusState { likes: EventCounter(our_pubkey: empty_pub), boosts: EventCounter(our_pubkey: empty_pub), contacts: Contacts(our_pubkey: empty_pub), + contactCards: ContactCardManagerMock(), mutelist_manager: MutelistManager(user_keypair: kp), profiles: Profiles(ndb: .empty), dms: DirectMessagesModel(our_pubkey: empty_pub), diff --git a/damus/Features/ContactCard/ContactCardManager.swift b/damus/Features/ContactCard/ContactCardManager.swift new file mode 100644 index 00000000..f9a17403 --- /dev/null +++ b/damus/Features/ContactCard/ContactCardManager.swift @@ -0,0 +1,128 @@ +import Foundation +import SwiftUI + +/// Manages user's favorites using NIP-81 contact cards +class ContactCardManager: ContactCard { + private(set) var favorites: Set = [] + private var latestContactCardEvents: [Pubkey: NostrEvent] = [:] + public static let FAVORITE_TAG = "favorite" + public static let CONTACT_SET = "n" + public static let TARGET_PUBLIC_KEY = "d" + + public init() {} + + func isFavorite(_ pubkey: Pubkey) -> Bool { + favorites.contains(pubkey) + } + + func toggleFavorite(_ pubkey: Pubkey, postbox: PostBox, keyPair: FullKeypair?) { + if favorites.contains(pubkey) { + favorites.remove(pubkey) + handleFavorite(target: pubkey, favorite: false, postbox: postbox, keypair: keyPair) + } else { + favorites.insert(pubkey) + handleFavorite(target: pubkey, favorite: true, postbox: postbox, keypair: keyPair) + } + } + + func loadEvent(_ ev: NostrEvent, pubkey: Pubkey) { + guard let kind = ev.known_kind, kind == .contact_card else { + return + } + // we only care about our contact cards + guard ev.pubkey == pubkey else { + return + } + + var targetPubkey: Pubkey? + var isFavorite = false + for tag in ev.tags { + guard tag.count >= 2 else { continue } + let tagType = tag[0].string() + let tagValue = tag[1].string() + if tagType == Self.TARGET_PUBLIC_KEY { + targetPubkey = Pubkey(hex: tagValue) + } else if tagType == Self.CONTACT_SET && tagValue == Self.FAVORITE_TAG { + isFavorite = true + } + } + + guard let targetPubkey else { + return + } + + // Only process if this event is new + if let existingEvent = latestContactCardEvents[targetPubkey] { + guard ev.created_at > existingEvent.created_at else { + return + } + } + + if isFavorite { + favorites.insert(targetPubkey) + } else { + favorites.remove(targetPubkey) + } + + latestContactCardEvents[targetPubkey] = ev + notify(.favoriteUpdated()) + } + + var filter: (NostrEvent) -> Bool { + { [weak self] ev in + guard let self else { return false } + return self.isFavorite(ev.pubkey) + } + } + + private func createFavoriteContactCard(keypair: FullKeypair, target: Pubkey) -> NostrEvent? { + let kind = NostrKind.contact_card.rawValue + let tags = [ + [Self.TARGET_PUBLIC_KEY, target.hex()], + [Self.CONTACT_SET, Self.FAVORITE_TAG] + ] + return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: kind, tags: tags) + } + + private func createUnfavoriteContactCard(keypair: FullKeypair, target: Pubkey) -> NostrEvent? { + let kind = NostrKind.contact_card.rawValue + let tags = [ + [Self.TARGET_PUBLIC_KEY, target.hex()] + ] + return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: kind, tags: tags) + } + + private func handleFavorite(target: Pubkey, favorite: Bool, postbox: PostBox, keypair: FullKeypair?) { + guard let keypair else { + return + } + let ev: NostrEvent? + if favorite { + ev = createFavoriteContactCard(keypair: keypair, target: target) + } else { + ev = createUnfavoriteContactCard(keypair: keypair, target: target) + } + + guard let ev else { + return + } + + if favorite { + favorites.insert(target) + } else { + favorites.remove(target) + } + + postbox.send(ev) + latestContactCardEvents[target] = ev + notify(.favoriteUpdated()) + } +} + +protocol ContactCard { + func isFavorite(_ pubkey: Pubkey) -> Bool + func toggleFavorite(_ pubkey: Pubkey, postbox: PostBox, keyPair: FullKeypair?) + func loadEvent(_ ev: NostrEvent, pubkey: Pubkey) + var filter: (NostrEvent) -> Bool { get } + var favorites: Set { get } +} diff --git a/damus/Features/ContactCard/ContactCardManagerMock.swift b/damus/Features/ContactCard/ContactCardManagerMock.swift new file mode 100644 index 00000000..782c4b85 --- /dev/null +++ b/damus/Features/ContactCard/ContactCardManagerMock.swift @@ -0,0 +1,26 @@ +import Foundation + +class ContactCardManagerMock: ContactCard { + var event: NostrEvent? + var favorites: Set = [] + + func isFavorite(_ pubkey: Pubkey) -> Bool { + favorites.contains(pubkey) + } + + func toggleFavorite(_ pubkey: Pubkey, postbox: PostBox, keyPair: FullKeypair?) { + if favorites.contains(pubkey) { + favorites.remove(pubkey) + } else { + favorites.insert(pubkey) + } + } + + func loadEvent(_ ev: NostrEvent, pubkey: Pubkey) { + event = ev + } + + var filter: ((_ ev: NostrEvent) -> Bool) { + { ev in self.favorites.contains(ev.pubkey) } + } +} diff --git a/damus/Features/ContactCard/Views/FavoriteButtonView.swift b/damus/Features/ContactCard/Views/FavoriteButtonView.swift new file mode 100644 index 00000000..32f52a5f --- /dev/null +++ b/damus/Features/ContactCard/Views/FavoriteButtonView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct FavoriteButtonView: View { + let pubkey: Pubkey + let damus_state: DamusState + + @State private var favorite: Bool + + init(pubkey: Pubkey, damus_state: DamusState) { + self.pubkey = pubkey + self.damus_state = damus_state + self._favorite = State(initialValue: damus_state.contactCards.isFavorite(pubkey)) + } + + var body: some View { + Button( + action: { + damus_state.contactCards.toggleFavorite( + pubkey, + postbox: damus_state.nostrNetwork.postbox, + keyPair: damus_state.keypair.to_full() + ) + favorite.toggle() + }) { + Image(favorite ? "heart.fill" : "heart") + .foregroundColor(favorite ? DamusColors.purple : .primary) + .font(.system(size: 16, weight: .medium)) + } + .buttonStyle(PlainButtonStyle()) + } +} + +struct FavoriteButtonView_Previews: PreviewProvider { + static var previews: some View { + FavoriteButtonView( + pubkey: test_pubkey, + damus_state: test_damus_state + ) + } +} diff --git a/damus/Features/Events/Models/LoadableNostrEventView.swift b/damus/Features/Events/Models/LoadableNostrEventView.swift index 6d4b4ed3..2156c7d8 100644 --- a/damus/Features/Events/Models/LoadableNostrEventView.swift +++ b/damus/Features/Events/Models/LoadableNostrEventView.swift @@ -74,7 +74,7 @@ class LoadableNostrEventViewModel: ObservableObject { case .zap, .zap_request: guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found } return .loaded(route: Route.Zaps(target: zap.target)) - case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list: + case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list, .contact_card: return .unknown_or_unsupported_kind } case .naddr(let naddr): diff --git a/damus/Features/Follows/Models/Contacts.swift b/damus/Features/Follows/Models/Contacts.swift index 6989bed4..ba7b816a 100644 --- a/damus/Features/Follows/Models/Contacts.swift +++ b/damus/Features/Follows/Models/Contacts.swift @@ -96,6 +96,13 @@ class Contacts { func get_friended_followers(_ pubkey: Pubkey) -> [Pubkey] { return Array((pubkey_to_our_friends[pubkey] ?? Set())) } + + var friend_filter: (NostrEvent) -> Bool { + { [weak self] ev in + guard let self else { return false } + return self.is_friend(ev.pubkey) + } + } } /// Delegate protocol for `Contacts`. Use this to listen to significant updates from a `Contacts` instance diff --git a/damus/Features/Profile/Views/ProfileActionSheetView.swift b/damus/Features/Profile/Views/ProfileActionSheetView.swift index 437db548..11eea3d8 100644 --- a/damus/Features/Profile/Views/ProfileActionSheetView.swift +++ b/damus/Features/Profile/Views/ProfileActionSheetView.swift @@ -14,6 +14,7 @@ struct ProfileActionSheetView: View { @StateObject var profile: ProfileModel @StateObject var zap_button_model: ZapButtonModel = ZapButtonModel() @State private var sheetHeight: CGFloat = .zero + @State private var favorite: Bool @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme @@ -25,6 +26,7 @@ struct ProfileActionSheetView: View { self.damus_state = damus_state self._profile = StateObject(wrappedValue: ProfileModel(pubkey: pubkey, damus: damus_state)) self.navigationHandler = navigationHandler + self.favorite = damus_state.contactCards.isFavorite(pubkey) } func imageBorderColor() -> Color { @@ -68,7 +70,31 @@ struct ProfileActionSheetView: View { .font(.caption) } } - + + var favoriteButton: some View { + VStack(alignment: .center, spacing: 10) { + Button( + action: { + damus_state.contactCards.toggleFavorite( + profile.pubkey, + postbox: damus_state.nostrNetwork.postbox, + keyPair: damus_state.keypair.to_full() + ) + favorite = damus_state.contactCards.isFavorite(profile.pubkey) + }, + label: { + Image("heart.fill") + .foregroundColor(favorite ? DamusColors.deepPurple : .primary) + .profile_button_style(scheme: colorScheme) + } + ) + .buttonStyle(NeutralButtonShape.circle.style) + Text("Favorite", comment: "Button label that allows the user to favorite the user shown on-screen") + .foregroundStyle(.secondary) + .font(.caption) + } + } + var dmButton: some View { let dm_model = damus_state.dms.lookup_or_create(profile.pubkey) return VStack(alignment: .center, spacing: 10) { @@ -121,17 +147,18 @@ struct ProfileActionSheetView: View { AboutView(state: damus_state, about: about, max_about_length: 140, text_alignment: .center) .padding(.top) } - - HStack(spacing: 20) { - self.followButton - self.zapButton - self.dmButton - if damus_state.keypair.pubkey != profile.pubkey && damus_state.keypair.privkey != nil { - self.muteButton + ScrollView(.horizontal) { + HStack(spacing: 20) { + followButton + favoriteButton + zapButton + dmButton + if damus_state.keypair.pubkey != profile.pubkey && damus_state.keypair.privkey != nil { + muteButton + } } + .padding() } - .padding() - Button( action: { self.navigate(route: Route.ProfileByKey(pubkey: profile.pubkey)) diff --git a/damus/Features/Profile/Views/ProfileView.swift b/damus/Features/Profile/Views/ProfileView.swift index 2c6f60d0..62ca2a9c 100644 --- a/damus/Features/Profile/Views/ProfileView.swift +++ b/damus/Features/Profile/Views/ProfileView.swift @@ -270,6 +270,7 @@ struct ProfileView: View { func actionSection(record: ProfileRecord?, pubkey: Pubkey) -> some View { return Group { + FavoriteButtonView(pubkey: profile.pubkey, damus_state: damus_state) if let record, let profile = record.profile, let lnurl = record.lnurl, diff --git a/damus/Features/Relays/Views/SignalView.swift b/damus/Features/Relays/Views/SignalView.swift index 629d5a8d..1cea7c46 100644 --- a/damus/Features/Relays/Views/SignalView.swift +++ b/damus/Features/Relays/Views/SignalView.swift @@ -13,14 +13,15 @@ struct SignalView: View { var body: some View { Group { - NavigationLink(value: Route.RelayConfig) { - Text("\(signal.signal)/\(signal.max_signal)", comment: "Fraction of how many of the user's relay servers that are operational.") - .font(.callout) - .foregroundColor(.gray) + if signal.signal != signal.max_signal { + NavigationLink(value: Route.RelayConfig) { + Text("\(signal.signal)/\(signal.max_signal)", comment: "Fraction of how many of the user's relay servers that are operational.") + .font(.callout) + .foregroundColor(.gray) + } + .frame(width:50,height:30) + .disabled(signal.signal == signal.max_signal) } - .frame(width:50,height:30) - .opacity(signal.signal != signal.max_signal ? 1 : 0) - .disabled(signal.signal == signal.max_signal) } } diff --git a/damus/Features/Timeline/Models/ContentFilters.swift b/damus/Features/Timeline/Models/ContentFilters.swift index a950b0db..20a498c6 100644 --- a/damus/Features/Timeline/Models/ContentFilters.swift +++ b/damus/Features/Timeline/Models/ContentFilters.swift @@ -7,6 +7,20 @@ import Foundation +/// Timeline source determines whether to show content from follows or favorites +enum TimelineSource: CustomStringConvertible { + case follows + case favorites + + var description: String { + switch self { + case .follows: + return NSLocalizedString("Follows", comment: "Show Notes from your following") + case .favorites: + return NSLocalizedString("Favorites", comment: "Show Notes from your favorites") + } + } +} /// Simple filter to determine whether to show posts or all posts and replies. enum FilterState : Int { diff --git a/damus/Features/Timeline/Models/HomeModel.swift b/damus/Features/Timeline/Models/HomeModel.swift index a18fd438..245a3d93 100644 --- a/damus/Features/Timeline/Models/HomeModel.swift +++ b/damus/Features/Timeline/Models/HomeModel.swift @@ -174,6 +174,15 @@ class HomeModel: ContactsDelegate { } } + /// Force refresh of home timeline filters, bypassing startup debounce + /// Used when favorites are fetched from network during startup to ensure unfollowed favorited users are included + /// This is needed because the normal resubscribe path is blocked during initial load. + /// TODO: Will this be a performance problem? + func refresh_home_filters() { + unsubscribe_to_home_filters() + subscribe_to_home_filters() + } + @MainActor func process_event(sub_id: String, relay_id: RelayURL, ev: NostrEvent) { if has_sub_id_event(sub_id: sub_id, ev_id: ev.id) { @@ -201,6 +210,8 @@ class HomeModel: ContactsDelegate { handle_old_list_event(ev) case .mute_list: handle_mute_list_event(ev) + case .contact_card: + damus_state.contactCards.loadEvent(ev, pubkey: damus_state.pubkey) case .boost: handle_boost_event(sub_id: sub_id, ev) case .like: @@ -560,6 +571,9 @@ class HomeModel: ContactsDelegate { our_old_blocklist_filter.parameter = ["mute"] our_old_blocklist_filter.authors = [damus_state.pubkey] + var contact_cards_filter = NostrFilter(kinds: [.contact_card]) + contact_cards_filter.authors = [damus_state.pubkey] + var our_blocklist_filter = NostrFilter(kinds: [.mute_list]) our_blocklist_filter.authors = [damus_state.pubkey] @@ -587,7 +601,7 @@ class HomeModel: ContactsDelegate { var notifications_filters = [notifications_filter] let contacts_filter_chunks = contacts_filter.chunked(on: .authors, into: MAX_CONTACTS_ON_FILTER) - var contacts_filters = contacts_filter_chunks + [our_contacts_filter, our_blocklist_filter, our_old_blocklist_filter] + var contacts_filters = contacts_filter_chunks + [our_contacts_filter, our_blocklist_filter, our_old_blocklist_filter, contact_cards_filter] var dms_filters = [dms_filter, our_dms_filter] let last_of_kind = get_last_of_kind(relay_id: relay_id) @@ -649,6 +663,16 @@ class HomeModel: ContactsDelegate { home_filters.append(hashtag_filter) } + // Add filter for favorited users who we dont follow + let all_favorites = damus_state.contactCards.favorites + let favorited_not_followed = Array(all_favorites.subtracting(Set(friends))) + if !favorited_not_followed.isEmpty { + var favorites_filter = NostrFilter(kinds: home_filter_kinds) + favorites_filter.authors = favorited_not_followed + favorites_filter.limit = 500 + home_filters.append(favorites_filter) + } + let relay_ids = relay_id.map { [$0] } home_filters = update_filters_with_since(last_of_kind: get_last_of_kind(relay_id: relay_id), filters: home_filters) let sub = NostrSubscribe(filters: home_filters, sub_id: home_subid) diff --git a/damus/Features/Timeline/Views/InnerTimelineView.swift b/damus/Features/Timeline/Views/InnerTimelineView.swift index 0e572f8f..73a54114 100644 --- a/damus/Features/Timeline/Views/InnerTimelineView.swift +++ b/damus/Features/Timeline/Views/InnerTimelineView.swift @@ -30,10 +30,10 @@ struct InnerTimelineView: View { var body: some View { LazyVStack(spacing: 0) { let events = self.events.events - if events.isEmpty { + let evs = events.filter(filter) + if evs.isEmpty { EmptyTimelineView() } else { - let evs = events.filter(filter) let indexed = Array(zip(evs, 0...)) ForEach(indexed, id: \.0.id) { tup in let ev = tup.0 diff --git a/damus/Features/Timeline/Views/PostingTimelineSwitcherView.swift b/damus/Features/Timeline/Views/PostingTimelineSwitcherView.swift new file mode 100644 index 00000000..cfdea909 --- /dev/null +++ b/damus/Features/Timeline/Views/PostingTimelineSwitcherView.swift @@ -0,0 +1,77 @@ +// +// PostingTimelineSwitcherView.swift +// damus +// +// Created by Askia Linder on 2025-09-06. +// + +import SwiftUI +import TipKit + +struct PostingTimelineSwitcherView: View { + let damusState: DamusState + @Binding var timelineSource: TimelineSource + + init(damusState: DamusState, timelineSource: Binding) { + self.damusState = damusState + self._timelineSource = timelineSource + if #available(iOS 17.0, *) { + try? Tips.configure([.displayFrequency(.daily)]) + } + } + + var body: some View { + Menu { + Picker(selection: $timelineSource) { + Label(TimelineSource.follows.description, image: "user-added") + .tag(TimelineSource.follows) + Label(TimelineSource.favorites.description, image: "heart") + .tag(TimelineSource.favorites) + } label: { + EmptyView() + } + .onAppear() { + if #available(iOS 17.0, *) { + TimelineSwitcherTip.shared.invalidate(reason: .actionPerformed) + } + } + } label: { + Image(systemName: "square.stack") + .foregroundColor(DamusColors.purple) + .frame(height: 35) + } + .menuOrder(.fixed) + .accessibilityLabel(NSLocalizedString("Timeline switcher, select \(TimelineSource.follows.description) or \(TimelineSource.favorites.description)", comment: "Accessibility label for the timeline switcher button at the topbar")) + } + + @available(iOS 17, *) + struct TimelineSwitcherTip: Tip { + static let shared = TimelineSwitcherTip() + + var options: [Option] { + [MaxDisplayCount(1)] + } + + var title: Text { + Text("Timeline switcher", comment: "Title of tip that informs users that they can switch timelines.") + } + + var message: Text? { + Text("Switch between posts from your follows or your favorites.", comment: "Description of the tip that informs users that they can switch between posts from your follows or your favorites.") + } + + var image: Image? { + Image(systemName: "square.stack") + } + } +} + +struct PostingTimelineSwitcherView_Previews: PreviewProvider { + static var previews: some View { + PostingTimelineSwitcherView( + damusState: test_damus_state, + timelineSource: .constant(.follows) + ) + } +} + diff --git a/damus/Features/Timeline/Views/PostingTimelineView.swift b/damus/Features/Timeline/Views/PostingTimelineView.swift index 8a37b5be..3be0a40a 100644 --- a/damus/Features/Timeline/Views/PostingTimelineView.swift +++ b/damus/Features/Timeline/Views/PostingTimelineView.swift @@ -25,10 +25,17 @@ struct PostingTimelineView: View { @State var headerHeight: CGFloat = 0 @Binding var headerOffset: CGFloat @SceneStorage("PostingTimelineView.filter_state") var filter_state : FilterState = .posts_and_replies + @State var timeline_source: TimelineSource = .follows func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) { var filters = ContentFilters.defaults(damus_state: damus_state) filters.append(fstate.filter) + switch timeline_source { + case .follows: + filters.append(damus_state.contacts.friend_filter) + case .favorites: + filters.append(damus_state.contactCards.filter) + } return ContentFilters(filters: filters).filter } @@ -48,6 +55,22 @@ struct PostingTimelineView: View { Spacer() + HStack(alignment: .center) { + SignalView(state: damus_state, signal: home.signal) + let switchView = PostingTimelineSwitcherView( + damusState: damus_state, + timelineSource: $timeline_source + ) + if #available(iOS 17.0, *) { + switchView + .popoverTip(PostingTimelineSwitcherView.TimelineSwitcherTip.shared) + } else { + switchView + } + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + .overlay { Image("damus-home") .resizable() .frame(width:30,height:30) @@ -57,15 +80,7 @@ struct PostingTimelineView: View { .onTapGesture { isSideBarOpened.toggle() } - .padding(.leading) - - Spacer() - - HStack(alignment: .center) { - SignalView(state: damus_state, signal: home.signal) - } } - .frame(maxWidth: .infinity, alignment: .trailing) } .padding(.horizontal, 20) @@ -126,3 +141,15 @@ struct PostingTimelineView: View { } } } + +struct PostingTimelineView_Previews: PreviewProvider { + static var previews: some View { + PostingTimelineView( + damus_state: test_damus_state, + home: HomeModel(), + isSideBarOpened: .constant(false), + active_sheet: .constant(nil), + headerOffset: .constant(0) + ) + } +} diff --git a/damus/Notify/ContactCardNotify.swift b/damus/Notify/ContactCardNotify.swift new file mode 100644 index 00000000..c92b6eaa --- /dev/null +++ b/damus/Notify/ContactCardNotify.swift @@ -0,0 +1,16 @@ +struct FavoriteNotify: Notify { + typealias Payload = Void + var payload: Void +} + +extension NotifyHandler { + static var favoriteUpdated: NotifyHandler { + .init() + } +} + +extension Notifications { + static func favoriteUpdated() -> Notifications { + .init(.init(payload: ())) + } +} diff --git a/damus/TestData.swift b/damus/TestData.swift index 716e3bfc..a312d136 100644 --- a/damus/TestData.swift +++ b/damus/TestData.swift @@ -87,6 +87,7 @@ var test_damus_state: DamusState = ({ likes: .init(our_pubkey: our_pubkey), boosts: .init(our_pubkey: our_pubkey), contacts: .init(our_pubkey: our_pubkey), + contactCards: ContactCardManagerMock(), mutelist_manager: MutelistManager(user_keypair: test_keypair), profiles: .init(ndb: ndb), dms: .init(our_pubkey: our_pubkey), diff --git a/damusTests/Mocking/MockDamusState.swift b/damusTests/Mocking/MockDamusState.swift index fa8c1323..2a6be7aa 100644 --- a/damusTests/Mocking/MockDamusState.swift +++ b/damusTests/Mocking/MockDamusState.swift @@ -30,7 +30,9 @@ func generate_test_damus_state( let damus = DamusState(keypair: test_keypair, likes: .init(our_pubkey: our_pubkey), boosts: .init(our_pubkey: our_pubkey), - contacts: .init(our_pubkey: our_pubkey), mutelist_manager: mutelist_manager, + contacts: .init(our_pubkey: our_pubkey), + contactCards: ContactCardManagerMock(), + mutelist_manager: mutelist_manager, profiles: profiles, dms: .init(our_pubkey: our_pubkey), previews: .init(), diff --git a/damusTests/Models/ContactCardManagerTests.swift b/damusTests/Models/ContactCardManagerTests.swift new file mode 100644 index 00000000..84e714ac --- /dev/null +++ b/damusTests/Models/ContactCardManagerTests.swift @@ -0,0 +1,249 @@ +import XCTest +@testable import damus + +final class ContactCardManagerTests: XCTestCase { + + func testInitialization() { + // Given: The shared ContactCardManager instance + let manager = ContactCardManager() + + // Then: It should have an empty favorites set + XCTAssertTrue(manager.favorites.isEmpty) + } + + func testIsFavorite_WhenEmpty_ReturnsFalse() { + // Given: An empty favorites manager + let sut = ContactCardManager() + let pubkey = Pubkey(hex: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")! + + // When: Checking if a pubkey is favorite + let result = sut.isFavorite(pubkey) + + // Then: Should return false + XCTAssertFalse(result) + } + + func testIsFavorite_WhenPubkeyExists_ReturnsTrue() { + // Given: A pubkey added to favorites + let sut = ContactCardManager() + let pubkey = Pubkey(hex: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")! + sut.toggleFavorite(pubkey, postbox: test_damus_state.nostrNetwork.postbox, keyPair: generate_new_keypair()) + + // When: Checking if the pubkey is favorite + let result = sut.isFavorite(pubkey) + + // Then: Should return true + XCTAssertTrue(result) + } + + func testIsFavorite_WhenPubkeyDoesNotExist_ReturnsFalse() { + // Given: A different pubkey added to favorites + let sut = ContactCardManager() + let expected = Pubkey(hex: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")! + let differentPubkey = Pubkey(hex: "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e")! + sut.toggleFavorite(expected, postbox: test_damus_state.nostrNetwork.postbox, keyPair: generate_new_keypair()) + + // When: Checking if a different pubkey is favorite + let result = sut.isFavorite(differentPubkey) + + // Then: Should return false + XCTAssertFalse(result) + } + + func testToggleFavorite_WhenNotFavorite_AddsToFavorites() { + // Given: A pubkey not in favorites + let sut = ContactCardManager() + let pubkey = Pubkey(hex: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")! + XCTAssertFalse(sut.isFavorite(pubkey)) + + // When: Toggling the pubkey + sut.toggleFavorite(pubkey, postbox: test_damus_state.nostrNetwork.postbox, keyPair: generate_new_keypair()) + + // Then: Should be added to favorites + XCTAssertTrue(sut.isFavorite(pubkey)) + XCTAssertEqual(sut.favorites.count, 1) + } + + func testToggleFavorite_WhenAlreadyFavorite_RemovesFromFavorites() { + // Given: A pubkey already in favorites + let sut = ContactCardManager() + let pubkey = Pubkey(hex: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")! + let keypair = generate_new_keypair() + sut.toggleFavorite(pubkey, postbox: test_damus_state.nostrNetwork.postbox, keyPair: keypair) + XCTAssertTrue(sut.isFavorite(pubkey)) + + // When: Toggling the pubkey again + sut.toggleFavorite(pubkey, postbox: test_damus_state.nostrNetwork.postbox, keyPair: keypair) + + // Then: Should be removed from favorites + XCTAssertFalse(sut.isFavorite(pubkey)) + XCTAssertEqual(sut.favorites.count, 0) + } + + func testloadEvent_WithContactCard_AddsToFavorites() { + // Given: A contact card event for favorites + let sut = ContactCardManager() + let targetPubkey = Pubkey(hex: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")! + let userPubkey = Pubkey(hex: "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e")! + + let tags = [ + [ContactCardManager.TARGET_PUBLIC_KEY, targetPubkey.hex()], + [ContactCardManager.CONTACT_SET, ContactCardManager.FAVORITE_TAG] + ] + + let event = NostrEvent( + content: "", + keypair: Keypair(pubkey: userPubkey, privkey: nil), + kind: NostrKind.contact_card.rawValue, + tags: tags + )! + + // When: Handling the contact card event + sut.loadEvent(event, pubkey: userPubkey) + + // Then: Should add the target pubkey to favorites + XCTAssertTrue(sut.isFavorite(targetPubkey)) + } + + func testloadEvent_WithContactCard_RemovesFromFavorites() { + // Given: A contact card event without favorite tag (unfavorite) + let sut = ContactCardManager() + let targetPubkey = Pubkey(hex: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")! + let userPubkey = Pubkey(hex: "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e")! + + // First add to favorites + sut.toggleFavorite(targetPubkey, postbox: test_damus_state.nostrNetwork.postbox, keyPair: generate_new_keypair()) + XCTAssertTrue(sut.isFavorite(targetPubkey)) + + // Create unfavorite contact card (only target public key tag, no contact set tag) + let tags = [ + [ContactCardManager.TARGET_PUBLIC_KEY, targetPubkey.hex()] + ] + + let event = NostrEvent( + content: "", + keypair: Keypair(pubkey: userPubkey, privkey: nil), + kind: NostrKind.contact_card.rawValue, + tags: tags, + createdAt: UInt32(Date().timeIntervalSince1970) + 1 + )! + + // When: Handling the unfavorite contact card event + sut.loadEvent(event, pubkey: userPubkey) + + // Then: Should remove the target pubkey from favorites + XCTAssertFalse(sut.isFavorite(targetPubkey)) + } + + func testloadEvent_WithMissingTargetPubkey_ReturnsEarly() { + // Given: A contact card event without d tag (missing target pubkey) + let sut = ContactCardManager() + let userPubkey = Pubkey(hex: "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e")! + let initialFavoritesCount = sut.favorites.count + let tags = [ + [ContactCardManager.CONTACT_SET, ContactCardManager.FAVORITE_TAG] + ] + let event = NostrEvent( + content: "", + keypair: Keypair(pubkey: userPubkey, privkey: nil), + kind: NostrKind.contact_card.rawValue, + tags: tags + )! + + // When: Handling the event with missing target pubkey + sut.loadEvent(event, pubkey: userPubkey) + + // Then: Should return early without changing favorites + XCTAssertEqual(sut.favorites.count, initialFavoritesCount) + } + + func testloadEvent_WithInvalidTargetPubkey_ReturnsEarly() { + // Given: A contact card event with invalid d tag (invalid pubkey hex) + let sut = ContactCardManager() + let userPubkey = Pubkey(hex: "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e")! + let initialFavoritesCount = sut.favorites.count + // Create contact card with invalid pubkey hex + let tags = [ + [ContactCardManager.TARGET_PUBLIC_KEY, "invalid_hex"], + [ContactCardManager.CONTACT_SET, ContactCardManager.FAVORITE_TAG] + ] + let event = NostrEvent( + content: "", + keypair: Keypair(pubkey: userPubkey, privkey: nil), + kind: NostrKind.contact_card.rawValue, + tags: tags + )! + + // When: Handling the event with invalid target pubkey + sut.loadEvent(event, pubkey: userPubkey) + + // Then: Should return early without changing favorites + XCTAssertEqual(sut.favorites.count, initialFavoritesCount) + } + + func testloadEvent_WithOlderEvent_ReturnsEarly() { + // Given: An existing newer contact card event + let sut = ContactCardManager() + let targetPubkey = Pubkey(hex: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")! + let userPubkey = Pubkey(hex: "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e")! + // Create newer favorite event first + let newerTags = [ + [ContactCardManager.TARGET_PUBLIC_KEY, targetPubkey.hex()], + [ContactCardManager.CONTACT_SET, ContactCardManager.FAVORITE_TAG] + ] + let newerEvent = NostrEvent( + content: "", + keypair: Keypair(pubkey: userPubkey, privkey: nil), + kind: NostrKind.contact_card.rawValue, + tags: newerTags, + createdAt: 1000 + )! + sut.loadEvent(newerEvent, pubkey: userPubkey) + XCTAssertTrue(sut.isFavorite(targetPubkey)) + // Create older unfavorite event + let olderTags = [ + [ContactCardManager.TARGET_PUBLIC_KEY, targetPubkey.hex()] + ] + let olderEvent = NostrEvent( + content: "", + keypair: Keypair(pubkey: userPubkey, privkey: nil), + kind: NostrKind.contact_card.rawValue, + tags: olderTags, + createdAt: 500 // Older timestamp + )! + + // When: Handling the older event + sut.loadEvent(olderEvent, pubkey: userPubkey) + + // Then: Should ignore the older event and keep the favorite status + XCTAssertTrue(sut.isFavorite(targetPubkey)) + } + + func testFilter_WithFavoritePubkey_ReturnsTrue() { + // Given: A pubkey in favorites + let sut = ContactCardManager() + let favoritePubkey = Pubkey(hex: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")! + let otherPubkey = Pubkey(hex: "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e")! + sut.toggleFavorite(favoritePubkey, postbox: test_damus_state.nostrNetwork.postbox, keyPair: generate_new_keypair()) + // Create events from both pubkeys + let favoriteEvent = NostrEvent( + content: "Hello from favorite", + keypair: Keypair(pubkey: favoritePubkey, privkey: nil), + kind: NostrKind.text.rawValue, + tags: [] + )! + let otherEvent = NostrEvent( + content: "Hello from other", + keypair: Keypair(pubkey: otherPubkey, privkey: nil), + kind: NostrKind.text.rawValue, + tags: [] + )! + + // When: Using the filter + let filter = sut.filter + + // Then: Should return true for favorite, false for other + XCTAssertTrue(filter(favoriteEvent)) + XCTAssertFalse(filter(otherEvent)) + } +} From 91426a79b916e83f83dc9c727d6079296878ed1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Wed, 1 Oct 2025 11:26:11 -0700 Subject: [PATCH 4/5] Add performance profiling requirement to PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changelog-None Closes: https://github.com/damus-io/damus/issues/3247 Signed-off-by: Daniel D’Aquino --- .github/pull_request_template.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 727178a8..4d0ff64c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,6 +6,9 @@ _[Please provide a summary of the changes in this PR.]_ - [ ] I have read (or I am familiar with) the [Contribution Guidelines](../docs/CONTRIBUTING.md) - [ ] I have tested the changes in this PR +- [ ] I have profiled the changes to ensure there are no performance regressions, or I do not need to profile the changes. + - Utilize Xcode profiler to measure performance impact of code changes. See https://developer.apple.com/videos/play/wwdc2025/306 + - If not needed, provide reason: - [ ] I have opened or referred to an existing github issue related to this change. - [ ] My PR is either small, or I have split it into smaller logical commits that are easier to review - [ ] I have added the signoff line to all my commits. See [Signing off your work](../docs/CONTRIBUTING.md#sign-your-work---the-developers-certificate-of-origin) From 8122a8a580d035220218f675957a166641c9e18c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Wed, 15 Oct 2025 14:41:20 -0700 Subject: [PATCH 5/5] Revert "Removes notifications from muted npubs" This reverts commit 6605c5e5835fc32183764e4c11a6d6ebac19ca18. --- damus/Features/Notifications/Views/NotificationsView.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/damus/Features/Notifications/Views/NotificationsView.swift b/damus/Features/Notifications/Views/NotificationsView.swift index f6e4c671..bfa9d520 100644 --- a/damus/Features/Notifications/Views/NotificationsView.swift +++ b/damus/Features/Notifications/Views/NotificationsView.swift @@ -33,7 +33,7 @@ class NotificationFilter: ObservableObject, Equatable { self.hellthread_notification_max_pubkeys = hellthread_notification_max_pubkeys } - func filter(contacts: Contacts, mutelist_manager: MutelistManager, items: [NotificationItem]) -> [NotificationItem] { + func filter(contacts: Contacts, items: [NotificationItem]) -> [NotificationItem] { return items.reduce(into: []) { acc, item in if !self.state.filter(item) { @@ -41,7 +41,6 @@ class NotificationFilter: ObservableObject, Equatable { } if let item = item.filter({ ev in - !mutelist_manager.is_event_muted(ev) && self.friend_filter.filter(contacts: contacts, pubkey: ev.pubkey) && (!hellthread_notifications_disabled || !ev.is_hellthread(max_pubkeys: hellthread_notification_max_pubkeys)) && // Allow notes that are created no more than 3 seconds in the future @@ -170,7 +169,7 @@ struct NotificationsView: View { func NotificationTab(_ filter: NotificationFilter) -> some View { ScrollViewReader { scroller in ScrollView { - let notifs = Array(zip(1..., filter.filter(contacts: state.contacts, mutelist_manager: state.mutelist_manager, items: notifications.notifications))) + let notifs = Array(zip(1..., filter.filter(contacts: state.contacts, items: notifications.notifications))) if notifs.isEmpty { EmptyTimelineView() } else {