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 <askeew@hotmail.com>
This commit is contained in:
Askia Linder
2025-08-27 09:23:20 +02:00
committed by Daniel D’Aquino
parent 6605c5e583
commit 61f695b7c6
22 changed files with 767 additions and 31 deletions

View File

@@ -8,6 +8,9 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
0E8A4BB72AE4359200065E81 /* NostrFilter+Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */; }; 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 */; }; 3165648B295B70D500C64604 /* LinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3165648A295B70D500C64604 /* LinkView.swift */; };
3169CAE6294E69C000EE4006 /* EmptyTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3169CAE5294E69C000EE4006 /* EmptyTimelineView.swift */; }; 3169CAE6294E69C000EE4006 /* EmptyTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3169CAE5294E69C000EE4006 /* EmptyTimelineView.swift */; };
3169CAED294FCCFC00EE4006 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3169CAEC294FCCFC00EE4006 /* Constants.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 */; }; BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; };
BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; }; BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; };
D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.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 */; }; D703D7192C66E47100A400EA /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D703D7182C66E47100A400EA /* UniformTypeIdentifiers.framework */; };
D703D71C2C66E47100A400EA /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D703D71B2C66E47100A400EA /* Media.xcassets */; }; D703D71C2C66E47100A400EA /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D703D71B2C66E47100A400EA /* Media.xcassets */; };
D703D71E2C66E47100A400EA /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D703D71D2C66E47100A400EA /* ActionViewController.swift */; }; D703D71E2C66E47100A400EA /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D703D71D2C66E47100A400EA /* ActionViewController.swift */; };
@@ -1879,6 +1895,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrFilter+Hashable.swift"; sourceTree = "<group>"; }; 0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrFilter+Hashable.swift"; sourceTree = "<group>"; };
2710433C2E6BFE2A0005C3B0 /* PostingTimelineSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingTimelineSwitcherView.swift; sourceTree = "<group>"; };
3165648A295B70D500C64604 /* LinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkView.swift; sourceTree = "<group>"; }; 3165648A295B70D500C64604 /* LinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkView.swift; sourceTree = "<group>"; };
3169CAE5294E69C000EE4006 /* EmptyTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyTimelineView.swift; sourceTree = "<group>"; }; 3169CAE5294E69C000EE4006 /* EmptyTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyTimelineView.swift; sourceTree = "<group>"; };
3169CAEC294FCCFC00EE4006 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Constants.swift; path = damus/Shared/Utilities/Constants.swift; sourceTree = SOURCE_ROOT; }; 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 = "<group>"; }; BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; };
BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; }; BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; };
D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; }; D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManager.swift; sourceTree = "<group>"; };
D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManagerMock.swift; sourceTree = "<group>"; };
D5C1AFC72E5E00690092F72F /* ContactCardManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManagerTests.swift; sourceTree = "<group>"; };
D5C1AFC92E5EE12B0092F72F /* ContactCardNotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardNotify.swift; sourceTree = "<group>"; };
D5C1AFD12E5EE2820092F72F /* FavoriteButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteButtonView.swift; sourceTree = "<group>"; };
D703D7172C66E47100A400EA /* HighlighterActionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HighlighterActionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 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; }; 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 = "<group>"; }; D703D71B2C66E47100A400EA /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
@@ -3206,6 +3228,7 @@
4CA3529C2A76AE47003BB08B /* Notify */ = { 4CA3529C2A76AE47003BB08B /* Notify */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D5C1AFC92E5EE12B0092F72F /* ContactCardNotify.swift */,
D706C5B62D602A050027C627 /* QueueableNotify.swift */, D706C5B62D602A050027C627 /* QueueableNotify.swift */,
D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */, D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */,
4C86F7C52A76C51100EC0817 /* AttachedWalletNotify.swift */, 4C86F7C52A76C51100EC0817 /* AttachedWalletNotify.swift */,
@@ -3986,6 +4009,7 @@
5C78A7792E22FDFE00CF177D /* Features */ = { 5C78A7792E22FDFE00CF177D /* Features */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D5C1AFC22E5DFF040092F72F /* ContactCard */,
5C78A7BC2E304D7400CF177D /* Translations */, 5C78A7BC2E304D7400CF177D /* Translations */,
5C78A7B52E3046F400CF177D /* NIP05 */, 5C78A7B52E3046F400CF177D /* NIP05 */,
5C78A7AA2E30428D00CF177D /* Actions */, 5C78A7AA2E30428D00CF177D /* Actions */,
@@ -4534,6 +4558,7 @@
5C78A7A92E30419B00CF177D /* Views */ = { 5C78A7A92E30419B00CF177D /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2710433C2E6BFE2A0005C3B0 /* PostingTimelineSwitcherView.swift */,
4CE0E2B529A3ED5500DB4CA2 /* InnerTimelineView.swift */, 4CE0E2B529A3ED5500DB4CA2 /* InnerTimelineView.swift */,
5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */, 5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */,
4CA2EF9F280E37AC0044ACD8 /* TimelineView.swift */, 4CA2EF9F280E37AC0044ACD8 /* TimelineView.swift */,
@@ -4850,6 +4875,24 @@
path = Camera; path = Camera;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D5C1AFC22E5DFF040092F72F /* ContactCard */ = {
isa = PBXGroup;
children = (
D5C1AFD22E5EE2820092F72F /* Views */,
D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */,
D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */,
);
path = ContactCard;
sourceTree = "<group>";
};
D5C1AFD22E5EE2820092F72F /* Views */ = {
isa = PBXGroup;
children = (
D5C1AFD12E5EE2820092F72F /* FavoriteButtonView.swift */,
);
path = Views;
sourceTree = "<group>";
};
D703D71A2C66E47100A400EA /* highlighter action extension */ = { D703D71A2C66E47100A400EA /* highlighter action extension */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -5028,6 +5071,7 @@
F944F56C29EA9CB20067B3BF /* Models */ = { F944F56C29EA9CB20067B3BF /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D5C1AFC72E5E00690092F72F /* ContactCardManagerTests.swift */,
F944F56D29EA9CCC0067B3BF /* DamusParseContentTests.swift */, F944F56D29EA9CCC0067B3BF /* DamusParseContentTests.swift */,
75AD872A2AA23A460085EF2C /* Block+Tests.swift */, 75AD872A2AA23A460085EF2C /* Block+Tests.swift */,
B5A75C292B546D94007AFBC0 /* MuteItemTests.swift */, B5A75C292B546D94007AFBC0 /* MuteItemTests.swift */,
@@ -5438,6 +5482,7 @@
D798D22C2B086C7400234419 /* NostrEvent+.swift in Sources */, D798D22C2B086C7400234419 /* NostrEvent+.swift in Sources */,
4C75EFB728049D990006080F /* RelayPool.swift in Sources */, 4C75EFB728049D990006080F /* RelayPool.swift in Sources */,
F757933A29D7AECD007DEAC1 /* MediaPicker.swift in Sources */, F757933A29D7AECD007DEAC1 /* MediaPicker.swift in Sources */,
D5C1AFBF2E5DF7E60092F72F /* ContactCardManager.swift in Sources */,
4CC6AA752CAB688500989CEF /* str.c in Sources */, 4CC6AA752CAB688500989CEF /* str.c in Sources */,
4CC6AA762CAB688500989CEF /* tal.c in Sources */, 4CC6AA762CAB688500989CEF /* tal.c in Sources */,
4CC6AA782CAB688500989CEF /* mem.c in Sources */, 4CC6AA782CAB688500989CEF /* mem.c in Sources */,
@@ -5454,6 +5499,7 @@
D7DB930B2D69486700DA1EE5 /* NIP65.swift in Sources */, D7DB930B2D69486700DA1EE5 /* NIP65.swift in Sources */,
4CB8838D296F710400DC99E7 /* Reposted.swift in Sources */, 4CB8838D296F710400DC99E7 /* Reposted.swift in Sources */,
4CE6DEE927F7A08100C66700 /* ContentView.swift in Sources */, 4CE6DEE927F7A08100C66700 /* ContentView.swift in Sources */,
2710433D2E6BFE340005C3B0 /* PostingTimelineSwitcherView.swift in Sources */,
4CEE2AF5280B29E600AB5EEF /* TimeAgo.swift in Sources */, 4CEE2AF5280B29E600AB5EEF /* TimeAgo.swift in Sources */,
4CC14FF12A73FCDB007AEB17 /* Pubkey.swift in Sources */, 4CC14FF12A73FCDB007AEB17 /* Pubkey.swift in Sources */,
5C8711DE2C460C06007879C2 /* PostingTimelineView.swift in Sources */, 5C8711DE2C460C06007879C2 /* PostingTimelineView.swift in Sources */,
@@ -5531,6 +5577,7 @@
4C285C8E28399BFE008A31F1 /* SaveKeysView.swift in Sources */, 4C285C8E28399BFE008A31F1 /* SaveKeysView.swift in Sources */,
F7F0BA25297892BD009531F3 /* SwipeToDismiss.swift in Sources */, F7F0BA25297892BD009531F3 /* SwipeToDismiss.swift in Sources */,
4C8D00CA29DF80350036AF10 /* TruncatedText.swift in Sources */, 4C8D00CA29DF80350036AF10 /* TruncatedText.swift in Sources */,
D5C1AFC62E5DFF700092F72F /* ContactCardManagerMock.swift in Sources */,
4C4DD3DB2A6CA7E8005B4E85 /* ContentParsing.swift in Sources */, 4C4DD3DB2A6CA7E8005B4E85 /* ContentParsing.swift in Sources */,
F71694F22A67314D001F4053 /* SuggestedUserView.swift in Sources */, F71694F22A67314D001F4053 /* SuggestedUserView.swift in Sources */,
4C9BB83429C12D9900FC4E37 /* EventProfileName.swift in Sources */, 4C9BB83429C12D9900FC4E37 /* EventProfileName.swift in Sources */,
@@ -5829,6 +5876,8 @@
D76BE18D2E0CF3DA004AD0C6 /* Interests.swift in Sources */, D76BE18D2E0CF3DA004AD0C6 /* Interests.swift in Sources */,
4C9BB83129C0ED4F00FC4E37 /* DisplayName.swift in Sources */, 4C9BB83129C0ED4F00FC4E37 /* DisplayName.swift in Sources */,
7CFF6317299FEFE5005D382A /* SelectableText.swift in Sources */, 7CFF6317299FEFE5005D382A /* SelectableText.swift in Sources */,
D5C1AFD42E5EE2820092F72F /* FavoriteButtonView.swift in Sources */,
D5C1AFCA2E5EE12B0092F72F /* ContactCardNotify.swift in Sources */,
4CA352A82A76B37E003BB08B /* NewMutesNotify.swift in Sources */, 4CA352A82A76B37E003BB08B /* NewMutesNotify.swift in Sources */,
4CFF8F6929CC9ED1008DB934 /* ImageContainerView.swift in Sources */, 4CFF8F6929CC9ED1008DB934 /* ImageContainerView.swift in Sources */,
7527271E2A93FF0100214108 /* Block.swift in Sources */, 7527271E2A93FF0100214108 /* Block.swift in Sources */,
@@ -5950,6 +5999,7 @@
4CB883AA297612FF00DC99E7 /* ZapTests.swift in Sources */, 4CB883AA297612FF00DC99E7 /* ZapTests.swift in Sources */,
D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */, D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */,
4CB8839A297322D200DC99E7 /* DMTests.swift in Sources */, 4CB8839A297322D200DC99E7 /* DMTests.swift in Sources */,
D5C1AFC82E5E00690092F72F /* ContactCardManagerTests.swift in Sources */,
D7315A2C2ACDF4DA0036E30A /* DamusCacheManagerTests.swift in Sources */, D7315A2C2ACDF4DA0036E30A /* DamusCacheManagerTests.swift in Sources */,
4C9054852A6AEAA000811EEC /* NdbTests.swift in Sources */, 4C9054852A6AEAA000811EEC /* NdbTests.swift in Sources */,
75AD872B2AA23A460085EF2C /* Block+Tests.swift in Sources */, 75AD872B2AA23A460085EF2C /* Block+Tests.swift in Sources */,
@@ -6046,6 +6096,7 @@
82D6FACA2CD99F7900C925F4 /* NostrScript.swift in Sources */, 82D6FACA2CD99F7900C925F4 /* NostrScript.swift in Sources */,
82D6FACB2CD99F7900C925F4 /* nostrscript.c in Sources */, 82D6FACB2CD99F7900C925F4 /* nostrscript.c in Sources */,
D73C7EDA2DE51690001F9392 /* OnboardingSuggestionsView.swift in Sources */, D73C7EDA2DE51690001F9392 /* OnboardingSuggestionsView.swift in Sources */,
D5C1AFC52E5DFF700092F72F /* ContactCardManagerMock.swift in Sources */,
82D6FADE2CD99F7900C925F4 /* ThreadReply.swift in Sources */, 82D6FADE2CD99F7900C925F4 /* ThreadReply.swift in Sources */,
82D6FADF2CD99F7900C925F4 /* AttachedWalletNotify.swift in Sources */, 82D6FADF2CD99F7900C925F4 /* AttachedWalletNotify.swift in Sources */,
82D6FAE02CD99F7900C925F4 /* DisplayTabBarNotify.swift in Sources */, 82D6FAE02CD99F7900C925F4 /* DisplayTabBarNotify.swift in Sources */,
@@ -6105,6 +6156,7 @@
D7DB93052D66A44100DA1EE5 /* Undistractor.swift in Sources */, D7DB93052D66A44100DA1EE5 /* Undistractor.swift in Sources */,
82D6FB0C2CD99F7900C925F4 /* GoldSupportGradient.swift in Sources */, 82D6FB0C2CD99F7900C925F4 /* GoldSupportGradient.swift in Sources */,
82D6FB0D2CD99F7900C925F4 /* PinkGradient.swift in Sources */, 82D6FB0D2CD99F7900C925F4 /* PinkGradient.swift in Sources */,
D5C1AFD32E5EE2820092F72F /* FavoriteButtonView.swift in Sources */,
82D6FB0E2CD99F7900C925F4 /* GrayGradient.swift in Sources */, 82D6FB0E2CD99F7900C925F4 /* GrayGradient.swift in Sources */,
82D6FB0F2CD99F7900C925F4 /* DamusLogoGradient.swift in Sources */, 82D6FB0F2CD99F7900C925F4 /* DamusLogoGradient.swift in Sources */,
82D6FB102CD99F7900C925F4 /* DamusBackground.swift in Sources */, 82D6FB102CD99F7900C925F4 /* DamusBackground.swift in Sources */,
@@ -6247,6 +6299,7 @@
82D6FB912CD99F7900C925F4 /* UserSettingsStore.swift in Sources */, 82D6FB912CD99F7900C925F4 /* UserSettingsStore.swift in Sources */,
82D6FB922CD99F7900C925F4 /* Wallet.swift in Sources */, 82D6FB922CD99F7900C925F4 /* Wallet.swift in Sources */,
82D6FB932CD99F7900C925F4 /* Report.swift in Sources */, 82D6FB932CD99F7900C925F4 /* Report.swift in Sources */,
2710433F2E6BFE340005C3B0 /* PostingTimelineSwitcherView.swift in Sources */,
82D6FB942CD99F7900C925F4 /* LibreTranslateServer.swift in Sources */, 82D6FB942CD99F7900C925F4 /* LibreTranslateServer.swift in Sources */,
D74EA08E2D2E271E002290DD /* ErrorView.swift in Sources */, D74EA08E2D2E271E002290DD /* ErrorView.swift in Sources */,
82D6FB952CD99F7900C925F4 /* TranslationService.swift in Sources */, 82D6FB952CD99F7900C925F4 /* TranslationService.swift in Sources */,
@@ -6418,6 +6471,7 @@
82D6FC312CD99F7900C925F4 /* ProxyView.swift in Sources */, 82D6FC312CD99F7900C925F4 /* ProxyView.swift in Sources */,
82D6FC322CD99F7900C925F4 /* SelectedEventView.swift in Sources */, 82D6FC322CD99F7900C925F4 /* SelectedEventView.swift in Sources */,
82D6FC332CD99F7900C925F4 /* EventBody.swift in Sources */, 82D6FC332CD99F7900C925F4 /* EventBody.swift in Sources */,
D5C1AFC02E5DF7E60092F72F /* ContactCardManager.swift in Sources */,
82D6FC342CD99F7900C925F4 /* BuilderEventView.swift in Sources */, 82D6FC342CD99F7900C925F4 /* BuilderEventView.swift in Sources */,
82D6FC352CD99F7900C925F4 /* EventProfile.swift in Sources */, 82D6FC352CD99F7900C925F4 /* EventProfile.swift in Sources */,
82D6FC362CD99F7900C925F4 /* EventMenu.swift in Sources */, 82D6FC362CD99F7900C925F4 /* EventMenu.swift in Sources */,
@@ -6493,6 +6547,7 @@
82D6FC772CD99F7900C925F4 /* SuggestedHashtagsView.swift in Sources */, 82D6FC772CD99F7900C925F4 /* SuggestedHashtagsView.swift in Sources */,
82D6FC782CD99F7900C925F4 /* ProfileActionSheetView.swift in Sources */, 82D6FC782CD99F7900C925F4 /* ProfileActionSheetView.swift in Sources */,
82D6FC792CD99F7900C925F4 /* damusApp.swift in Sources */, 82D6FC792CD99F7900C925F4 /* damusApp.swift in Sources */,
D5C1AFCC2E5EE12B0092F72F /* ContactCardNotify.swift in Sources */,
82D6FC7A2CD99F7900C925F4 /* ContentView.swift in Sources */, 82D6FC7A2CD99F7900C925F4 /* ContentView.swift in Sources */,
82D6FC7B2CD99F7900C925F4 /* TestData.swift in Sources */, 82D6FC7B2CD99F7900C925F4 /* TestData.swift in Sources */,
82D6FC7C2CD99F7900C925F4 /* ContentParsing.swift in Sources */, 82D6FC7C2CD99F7900C925F4 /* ContentParsing.swift in Sources */,
@@ -6517,6 +6572,7 @@
4C3624732D5EA1BE00DD066E /* nostrdb.c in Sources */, 4C3624732D5EA1BE00DD066E /* nostrdb.c in Sources */,
4C3624602D5E9EB800DD066E /* NdbProfile.swift in Sources */, 4C3624602D5E9EB800DD066E /* NdbProfile.swift in Sources */,
4C36245F2D5E9B5F00DD066E /* NdbBlock.swift in Sources */, 4C36245F2D5E9B5F00DD066E /* NdbBlock.swift in Sources */,
D5C1AFD52E5EE2820092F72F /* FavoriteButtonView.swift in Sources */,
D73E5E202C6A97F4007EB227 /* AttachedWalletNotify.swift in Sources */, D73E5E202C6A97F4007EB227 /* AttachedWalletNotify.swift in Sources */,
D73E5E212C6A97F4007EB227 /* DisplayTabBarNotify.swift in Sources */, D73E5E212C6A97F4007EB227 /* DisplayTabBarNotify.swift in Sources */,
D73E5E222C6A97F4007EB227 /* BroadcastNotify.swift in Sources */, D73E5E222C6A97F4007EB227 /* BroadcastNotify.swift in Sources */,
@@ -6577,6 +6633,7 @@
D73E5E582C6A97F4007EB227 /* ThiccDivider.swift in Sources */, D73E5E582C6A97F4007EB227 /* ThiccDivider.swift in Sources */,
D73E5E592C6A97F4007EB227 /* IconLabel.swift in Sources */, D73E5E592C6A97F4007EB227 /* IconLabel.swift in Sources */,
D73E5E5A2C6A97F4007EB227 /* TruncatedText.swift in Sources */, D73E5E5A2C6A97F4007EB227 /* TruncatedText.swift in Sources */,
D5C1AFC42E5DFF700092F72F /* ContactCardManagerMock.swift in Sources */,
D73E5E5B2C6A97F4007EB227 /* SupporterBadge.swift in Sources */, D73E5E5B2C6A97F4007EB227 /* SupporterBadge.swift in Sources */,
D73E5E5C2C6A97F4007EB227 /* GradientButtonStyle.swift in Sources */, D73E5E5C2C6A97F4007EB227 /* GradientButtonStyle.swift in Sources */,
D73E5E5D2C6A97F4007EB227 /* NeutralButtonStyle.swift in Sources */, D73E5E5D2C6A97F4007EB227 /* NeutralButtonStyle.swift in Sources */,
@@ -6706,6 +6763,7 @@
D73E5EB92C6A97F4007EB227 /* RelayLog.swift in Sources */, D73E5EB92C6A97F4007EB227 /* RelayLog.swift in Sources */,
D73E5EBA2C6A97F4007EB227 /* NostrFilter.swift in Sources */, D73E5EBA2C6A97F4007EB227 /* NostrFilter.swift in Sources */,
D73E5EBB2C6A97F4007EB227 /* Nip98HTTPAuth.swift in Sources */, D73E5EBB2C6A97F4007EB227 /* Nip98HTTPAuth.swift in Sources */,
D5C1AFCB2E5EE12B0092F72F /* ContactCardNotify.swift in Sources */,
3A92C0FF2DE16E9800CEEBAC /* FaviconCache.swift in Sources */, 3A92C0FF2DE16E9800CEEBAC /* FaviconCache.swift in Sources */,
D73E5EBC2C6A97F4007EB227 /* Relay.swift in Sources */, D73E5EBC2C6A97F4007EB227 /* Relay.swift in Sources */,
D73E5EBD2C6A97F4007EB227 /* NostrRequest.swift in Sources */, D73E5EBD2C6A97F4007EB227 /* NostrRequest.swift in Sources */,
@@ -6764,6 +6822,7 @@
D73E5EF62C6A97F4007EB227 /* SearchingEventView.swift in Sources */, D73E5EF62C6A97F4007EB227 /* SearchingEventView.swift in Sources */,
D73E5EF72C6A97F4007EB227 /* PullDownSearch.swift in Sources */, D73E5EF72C6A97F4007EB227 /* PullDownSearch.swift in Sources */,
D73E5EF82C6A97F4007EB227 /* NotificationsView.swift in Sources */, D73E5EF82C6A97F4007EB227 /* NotificationsView.swift in Sources */,
D5C1AFC12E5DF7E60092F72F /* ContactCardManager.swift in Sources */,
D73B74E32D8365BA0067BDBC /* ExtraFonts.swift in Sources */, D73B74E32D8365BA0067BDBC /* ExtraFonts.swift in Sources */,
D73E5EF92C6A97F4007EB227 /* EventGroupView.swift in Sources */, D73E5EF92C6A97F4007EB227 /* EventGroupView.swift in Sources */,
D73E5EFA2C6A97F4007EB227 /* NotificationItemView.swift in Sources */, D73E5EFA2C6A97F4007EB227 /* NotificationItemView.swift in Sources */,
@@ -6965,6 +7024,7 @@
D703D7A42C670E3C00A400EA /* midl.c in Sources */, D703D7A42C670E3C00A400EA /* midl.c in Sources */,
D7DB1FE02D5A78CE00CF06DA /* NIP44.swift in Sources */, D7DB1FE02D5A78CE00CF06DA /* NIP44.swift in Sources */,
D706C5B02D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */, D706C5B02D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */,
2710433E2E6BFE340005C3B0 /* PostingTimelineSwitcherView.swift in Sources */,
D703D78B2C670C9500A400EA /* MakeZapRequest.swift in Sources */, D703D78B2C670C9500A400EA /* MakeZapRequest.swift in Sources */,
D703D7862C670C6500A400EA /* NewUnmutesNotify.swift in Sources */, D703D7862C670C6500A400EA /* NewUnmutesNotify.swift in Sources */,
D703D7662C670AFC00A400EA /* AsciiCharacter.swift in Sources */, D703D7662C670AFC00A400EA /* AsciiCharacter.swift in Sources */,

View File

@@ -447,6 +447,9 @@ struct ContentView: View {
.onReceive(handle_notify(.present_full_screen_item)) { item in .onReceive(handle_notify(.present_full_screen_item)) { item in
self.active_full_screen_item = item self.active_full_screen_item = item
} }
.onReceive(handle_notify(.favoriteUpdated)) { _ in
home.refresh_home_filters()
}
.onReceive(handle_notify(.zapping)) { zap_ev in .onReceive(handle_notify(.zapping)) { zap_ev in
guard !zap_ev.is_custom else { guard !zap_ev.is_custom else {
return return
@@ -681,6 +684,7 @@ struct ContentView: View {
likes: EventCounter(our_pubkey: pubkey), likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey), boosts: EventCounter(our_pubkey: pubkey),
contacts: Contacts(our_pubkey: pubkey), contacts: Contacts(our_pubkey: pubkey),
contactCards: ContactCardManager(),
mutelist_manager: MutelistManager(user_keypair: keypair), mutelist_manager: MutelistManager(user_keypair: keypair),
profiles: Profiles(ndb: ndb), profiles: Profiles(ndb: ndb),
dms: home.dms, dms: home.dms,

View File

@@ -872,4 +872,31 @@ extension NostrEvent {
return nil 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
} }

View File

@@ -31,5 +31,6 @@ enum NostrKind: UInt32, Codable {
case nwc_response = 23195 case nwc_response = 23195
case http_auth = 27235 case http_auth = 27235
case status = 30315 case status = 30315
case contact_card = 30_382
case follow_list = 39089 case follow_list = 39089
} }

View File

@@ -15,6 +15,7 @@ class DamusState: HeadlessDamusState {
let boosts: EventCounter let boosts: EventCounter
let quote_reposts: EventCounter let quote_reposts: EventCounter
let contacts: Contacts let contacts: Contacts
let contactCards: ContactCard
let mutelist_manager: MutelistManager let mutelist_manager: MutelistManager
let profiles: Profiles let profiles: Profiles
let dms: DirectMessagesModel let dms: DirectMessagesModel
@@ -39,11 +40,12 @@ class DamusState: HeadlessDamusState {
let favicon_cache: FaviconCache let favicon_cache: FaviconCache
private(set) var nostrNetwork: NostrNetworkManager 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.keypair = keypair
self.likes = likes self.likes = likes
self.boosts = boosts self.boosts = boosts
self.contacts = contacts self.contacts = contacts
self.contactCards = contactCards
self.mutelist_manager = mutelist_manager self.mutelist_manager = mutelist_manager
self.profiles = profiles self.profiles = profiles
self.dms = dms self.dms = dms
@@ -109,6 +111,7 @@ class DamusState: HeadlessDamusState {
likes: EventCounter(our_pubkey: pubkey), likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey), boosts: EventCounter(our_pubkey: pubkey),
contacts: Contacts(our_pubkey: pubkey), contacts: Contacts(our_pubkey: pubkey),
contactCards: ContactCardManager(),
mutelist_manager: MutelistManager(user_keypair: keypair), mutelist_manager: MutelistManager(user_keypair: keypair),
profiles: Profiles(ndb: ndb), profiles: Profiles(ndb: ndb),
dms: home.dms, dms: home.dms,
@@ -178,6 +181,7 @@ class DamusState: HeadlessDamusState {
likes: EventCounter(our_pubkey: empty_pub), likes: EventCounter(our_pubkey: empty_pub),
boosts: EventCounter(our_pubkey: empty_pub), boosts: EventCounter(our_pubkey: empty_pub),
contacts: Contacts(our_pubkey: empty_pub), contacts: Contacts(our_pubkey: empty_pub),
contactCards: ContactCardManagerMock(),
mutelist_manager: MutelistManager(user_keypair: kp), mutelist_manager: MutelistManager(user_keypair: kp),
profiles: Profiles(ndb: .empty), profiles: Profiles(ndb: .empty),
dms: DirectMessagesModel(our_pubkey: empty_pub), dms: DirectMessagesModel(our_pubkey: empty_pub),

View File

@@ -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<Pubkey> = []
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<Pubkey> { get }
}

View File

@@ -0,0 +1,26 @@
import Foundation
class ContactCardManagerMock: ContactCard {
var event: NostrEvent?
var favorites: Set<Pubkey> = []
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) }
}
}

View File

@@ -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
)
}
}

View File

@@ -74,7 +74,7 @@ class LoadableNostrEventViewModel: ObservableObject {
case .zap, .zap_request: case .zap, .zap_request:
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found } guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
return .loaded(route: Route.Zaps(target: zap.target)) 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 return .unknown_or_unsupported_kind
} }
case .naddr(let naddr): case .naddr(let naddr):

View File

@@ -96,6 +96,13 @@ class Contacts {
func get_friended_followers(_ pubkey: Pubkey) -> [Pubkey] { func get_friended_followers(_ pubkey: Pubkey) -> [Pubkey] {
return Array((pubkey_to_our_friends[pubkey] ?? Set())) 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 /// Delegate protocol for `Contacts`. Use this to listen to significant updates from a `Contacts` instance

View File

@@ -14,6 +14,7 @@ struct ProfileActionSheetView: View {
@StateObject var profile: ProfileModel @StateObject var profile: ProfileModel
@StateObject var zap_button_model: ZapButtonModel = ZapButtonModel() @StateObject var zap_button_model: ZapButtonModel = ZapButtonModel()
@State private var sheetHeight: CGFloat = .zero @State private var sheetHeight: CGFloat = .zero
@State private var favorite: Bool
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@@ -25,6 +26,7 @@ struct ProfileActionSheetView: View {
self.damus_state = damus_state self.damus_state = damus_state
self._profile = StateObject(wrappedValue: ProfileModel(pubkey: pubkey, damus: damus_state)) self._profile = StateObject(wrappedValue: ProfileModel(pubkey: pubkey, damus: damus_state))
self.navigationHandler = navigationHandler self.navigationHandler = navigationHandler
self.favorite = damus_state.contactCards.isFavorite(pubkey)
} }
func imageBorderColor() -> Color { func imageBorderColor() -> Color {
@@ -68,7 +70,31 @@ struct ProfileActionSheetView: View {
.font(.caption) .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 { var dmButton: some View {
let dm_model = damus_state.dms.lookup_or_create(profile.pubkey) let dm_model = damus_state.dms.lookup_or_create(profile.pubkey)
return VStack(alignment: .center, spacing: 10) { 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) AboutView(state: damus_state, about: about, max_about_length: 140, text_alignment: .center)
.padding(.top) .padding(.top)
} }
ScrollView(.horizontal) {
HStack(spacing: 20) { HStack(spacing: 20) {
self.followButton followButton
self.zapButton favoriteButton
self.dmButton zapButton
if damus_state.keypair.pubkey != profile.pubkey && damus_state.keypair.privkey != nil { dmButton
self.muteButton if damus_state.keypair.pubkey != profile.pubkey && damus_state.keypair.privkey != nil {
muteButton
}
} }
.padding()
} }
.padding()
Button( Button(
action: { action: {
self.navigate(route: Route.ProfileByKey(pubkey: profile.pubkey)) self.navigate(route: Route.ProfileByKey(pubkey: profile.pubkey))

View File

@@ -270,6 +270,7 @@ struct ProfileView: View {
func actionSection(record: ProfileRecord?, pubkey: Pubkey) -> some View { func actionSection(record: ProfileRecord?, pubkey: Pubkey) -> some View {
return Group { return Group {
FavoriteButtonView(pubkey: profile.pubkey, damus_state: damus_state)
if let record, if let record,
let profile = record.profile, let profile = record.profile,
let lnurl = record.lnurl, let lnurl = record.lnurl,

View File

@@ -13,14 +13,15 @@ struct SignalView: View {
var body: some View { var body: some View {
Group { Group {
NavigationLink(value: Route.RelayConfig) { if signal.signal != signal.max_signal {
Text("\(signal.signal)/\(signal.max_signal)", comment: "Fraction of how many of the user's relay servers that are operational.") NavigationLink(value: Route.RelayConfig) {
.font(.callout) Text("\(signal.signal)/\(signal.max_signal)", comment: "Fraction of how many of the user's relay servers that are operational.")
.foregroundColor(.gray) .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)
} }
} }

View File

@@ -7,6 +7,20 @@
import Foundation 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. /// Simple filter to determine whether to show posts or all posts and replies.
enum FilterState : Int { enum FilterState : Int {

View File

@@ -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 @MainActor
func process_event(sub_id: String, relay_id: RelayURL, ev: NostrEvent) { func process_event(sub_id: String, relay_id: RelayURL, ev: NostrEvent) {
if has_sub_id_event(sub_id: sub_id, ev_id: ev.id) { 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) handle_old_list_event(ev)
case .mute_list: case .mute_list:
handle_mute_list_event(ev) handle_mute_list_event(ev)
case .contact_card:
damus_state.contactCards.loadEvent(ev, pubkey: damus_state.pubkey)
case .boost: case .boost:
handle_boost_event(sub_id: sub_id, ev) handle_boost_event(sub_id: sub_id, ev)
case .like: case .like:
@@ -560,6 +571,9 @@ class HomeModel: ContactsDelegate {
our_old_blocklist_filter.parameter = ["mute"] our_old_blocklist_filter.parameter = ["mute"]
our_old_blocklist_filter.authors = [damus_state.pubkey] 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]) var our_blocklist_filter = NostrFilter(kinds: [.mute_list])
our_blocklist_filter.authors = [damus_state.pubkey] our_blocklist_filter.authors = [damus_state.pubkey]
@@ -587,7 +601,7 @@ class HomeModel: ContactsDelegate {
var notifications_filters = [notifications_filter] var notifications_filters = [notifications_filter]
let contacts_filter_chunks = contacts_filter.chunked(on: .authors, into: MAX_CONTACTS_ON_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] var dms_filters = [dms_filter, our_dms_filter]
let last_of_kind = get_last_of_kind(relay_id: relay_id) let last_of_kind = get_last_of_kind(relay_id: relay_id)
@@ -649,6 +663,16 @@ class HomeModel: ContactsDelegate {
home_filters.append(hashtag_filter) 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] } 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) 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) let sub = NostrSubscribe(filters: home_filters, sub_id: home_subid)

View File

@@ -30,10 +30,10 @@ struct InnerTimelineView: View {
var body: some View { var body: some View {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
let events = self.events.events let events = self.events.events
if events.isEmpty { let evs = events.filter(filter)
if evs.isEmpty {
EmptyTimelineView() EmptyTimelineView()
} else { } else {
let evs = events.filter(filter)
let indexed = Array(zip(evs, 0...)) let indexed = Array(zip(evs, 0...))
ForEach(indexed, id: \.0.id) { tup in ForEach(indexed, id: \.0.id) { tup in
let ev = tup.0 let ev = tup.0

View File

@@ -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<TimelineSource>) {
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)
)
}
}

View File

@@ -25,10 +25,17 @@ struct PostingTimelineView: View {
@State var headerHeight: CGFloat = 0 @State var headerHeight: CGFloat = 0
@Binding var headerOffset: CGFloat @Binding var headerOffset: CGFloat
@SceneStorage("PostingTimelineView.filter_state") var filter_state : FilterState = .posts_and_replies @SceneStorage("PostingTimelineView.filter_state") var filter_state : FilterState = .posts_and_replies
@State var timeline_source: TimelineSource = .follows
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) { func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: damus_state) var filters = ContentFilters.defaults(damus_state: damus_state)
filters.append(fstate.filter) 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 return ContentFilters(filters: filters).filter
} }
@@ -48,6 +55,22 @@ struct PostingTimelineView: View {
Spacer() 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") Image("damus-home")
.resizable() .resizable()
.frame(width:30,height:30) .frame(width:30,height:30)
@@ -57,15 +80,7 @@ struct PostingTimelineView: View {
.onTapGesture { .onTapGesture {
isSideBarOpened.toggle() isSideBarOpened.toggle()
} }
.padding(.leading)
Spacer()
HStack(alignment: .center) {
SignalView(state: damus_state, signal: home.signal)
}
} }
.frame(maxWidth: .infinity, alignment: .trailing)
} }
.padding(.horizontal, 20) .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)
)
}
}

View File

@@ -0,0 +1,16 @@
struct FavoriteNotify: Notify {
typealias Payload = Void
var payload: Void
}
extension NotifyHandler {
static var favoriteUpdated: NotifyHandler<FavoriteNotify> {
.init()
}
}
extension Notifications {
static func favoriteUpdated() -> Notifications<FavoriteNotify> {
.init(.init(payload: ()))
}
}

View File

@@ -87,6 +87,7 @@ var test_damus_state: DamusState = ({
likes: .init(our_pubkey: our_pubkey), likes: .init(our_pubkey: our_pubkey),
boosts: .init(our_pubkey: our_pubkey), boosts: .init(our_pubkey: our_pubkey),
contacts: .init(our_pubkey: our_pubkey), contacts: .init(our_pubkey: our_pubkey),
contactCards: ContactCardManagerMock(),
mutelist_manager: MutelistManager(user_keypair: test_keypair), mutelist_manager: MutelistManager(user_keypair: test_keypair),
profiles: .init(ndb: ndb), profiles: .init(ndb: ndb),
dms: .init(our_pubkey: our_pubkey), dms: .init(our_pubkey: our_pubkey),

View File

@@ -30,7 +30,9 @@ func generate_test_damus_state(
let damus = DamusState(keypair: test_keypair, let damus = DamusState(keypair: test_keypair,
likes: .init(our_pubkey: our_pubkey), likes: .init(our_pubkey: our_pubkey),
boosts: .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, profiles: profiles,
dms: .init(our_pubkey: our_pubkey), dms: .init(our_pubkey: our_pubkey),
previews: .init(), previews: .init(),

View File

@@ -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))
}
}