commit eeea9d3266adda856bc9732812d07240c9630ed0
parent b8bf5df7bce77e0a2116630b36ad75bce867baa4
Author: Daniel D’Aquino <daniel@daquino.me>
Date: Fri, 16 May 2025 14:45:22 -0700
Integrate follow packs into onboarding suggestions
Closes: https://github.com/damus-io/damus/issues/3007
Changelog-Added: Added new onboarding suggestions based on user-selected interests
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Diffstat:
20 files changed, 1186 insertions(+), 205 deletions(-)
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj
@@ -780,7 +780,6 @@
82D6FBCB2CD99F7900C925F4 /* VisibilityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */; };
82D6FBCC2CD99F7900C925F4 /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759962ABCCF360018D73B /* CameraPreview.swift */; };
82D6FBCD2CD99F7900C925F4 /* CameraController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E02429942B7E97740088B16C /* CameraController.swift */; };
- 82D6FBCE2CD99F7900C925F4 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; };
82D6FBCF2CD99F7900C925F4 /* SuggestedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F12A67314D001F4053 /* SuggestedUserView.swift */; };
82D6FBD02CD99F7900C925F4 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */; };
82D6FBD12CD99F7900C925F4 /* LoadScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C190F242A547D2000027FD5 /* LoadScript.swift */; };
@@ -1115,6 +1114,10 @@
D7100C5A2B76FD5100C59298 /* LogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C592B76FD5100C59298 /* LogoView.swift */; };
D7100C5C2B77016700C59298 /* IAPProductStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5B2B77016700C59298 /* IAPProductStateView.swift */; };
D7100C5E2B7709ED00C59298 /* PurpleStoreKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */; };
+ D71527F42E0A2DCA00C893D6 /* follow-packs.jsonl in Resources */ = {isa = PBXBuildFile; fileRef = D71527F32E0A2DC900C893D6 /* follow-packs.jsonl */; };
+ D71527FF2E0A3D6900C893D6 /* InterestList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71527FE2E0A3D5F00C893D6 /* InterestList.swift */; };
+ D71528002E0A3D6900C893D6 /* InterestList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71527FE2E0A3D5F00C893D6 /* InterestList.swift */; };
+ D71528012E0A3D6900C893D6 /* InterestList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71527FE2E0A3D5F00C893D6 /* InterestList.swift */; };
D71AC4CC2BA8E3480076268E /* VisibilityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */; };
D71AD8FD2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71AD8FC2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift */; };
D71AD8FE2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71AD8FC2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift */; };
@@ -1158,6 +1161,10 @@
D73BDB182D71311900D69970 /* UserRelayListErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */; };
D73BDB192D71311900D69970 /* UserRelayListErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */; };
D73BDB1A2D71311900D69970 /* UserRelayListErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */; };
+ D73C7ED92DE51690001F9392 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; };
+ D73C7EDA2DE51690001F9392 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; };
+ D73C7EDC2DE51699001F9392 /* OnboardingContentSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73FA9E02DDC129E00C706E1 /* OnboardingContentSettings.swift */; };
+ D73C7EDD2DE517A1001F9392 /* OnboardingContentSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73FA9E02DDC129E00C706E1 /* OnboardingContentSettings.swift */; };
D73E5E162C6A9619007EB227 /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFA327FA577B0006080F /* PostView.swift */; };
D73E5E172C6A962A007EB227 /* ImageUploadModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */; };
D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */; };
@@ -1324,7 +1331,6 @@
D73E5EC72C6A97F4007EB227 /* VisibilityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */; };
D73E5EC82C6A97F4007EB227 /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759962ABCCF360018D73B /* CameraPreview.swift */; };
D73E5EC92C6A97F4007EB227 /* CameraController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E02429942B7E97740088B16C /* CameraController.swift */; };
- D73E5ECA2C6A97F4007EB227 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; };
D73E5ECB2C6A97F4007EB227 /* SuggestedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F12A67314D001F4053 /* SuggestedUserView.swift */; };
D73E5ECC2C6A97F4007EB227 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */; };
D73E5ED22C6A97F4007EB227 /* WalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D095D2A098C5D00943473 /* WalletView.swift */; };
@@ -1508,6 +1514,7 @@
D73E5F9B2C6AA8B0007EB227 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = D73E5F9A2C6AA8B0007EB227 /* Kingfisher */; };
D73E5F9D2C6AA8E3007EB227 /* SwipeActions in Frameworks */ = {isa = PBXBuildFile; productRef = D73E5F9C2C6AA8E3007EB227 /* SwipeActions */; };
D73E5F9E2C6AA9F7007EB227 /* nostrscript.c in Sources */ = {isa = PBXBuildFile; fileRef = 4C4F14A92A2A71AB0045A0B9 /* nostrscript.c */; };
+ D73FA9E12DDC12AA00C706E1 /* OnboardingContentSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73FA9E02DDC129E00C706E1 /* OnboardingContentSettings.swift */; };
D74AAFC22B153395006CF0F4 /* HeadlessDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */; };
D74AAFC32B153395006CF0F4 /* HeadlessDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */; };
D74AAFC52B1538DF006CF0F4 /* NotificationExtensionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */; };
@@ -1543,6 +1550,9 @@
D76556D62B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */; };
D767066F2C8BB3CF00F09726 /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D767066E2C8BB3CE00F09726 /* URLHandler.swift */; };
D76874F32AE3632B00FB0F68 /* ProfileZapLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */; };
+ 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 */; };
D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; };
D773BC602C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; };
D773BC612C6D58A700349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; };
@@ -1552,6 +1562,9 @@
D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; };
D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; };
D789D1202AFEFBF20083A7AB /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = D789D11F2AFEFBF20083A7AB /* secp256k1 */; };
+ D78BA6652DD7DFB9000AE62C /* InterestSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */; };
+ D78BA6662DD7DFB9000AE62C /* InterestSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */; };
+ D78BA6672DD7DFB9000AE62C /* InterestSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */; };
D78CD5982B8990300014D539 /* DamusAppNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */; };
D78DB8592C1CE9CA00F0AB12 /* SwipeActions in Frameworks */ = {isa = PBXBuildFile; productRef = D78DB8582C1CE9CA00F0AB12 /* SwipeActions */; };
D78DB85B2C20FE5000F0AB12 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; };
@@ -1775,7 +1788,6 @@
E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E990020E2955F837003BBC5A /* EditMetadataView.swift */; };
F71694EA2A662232001F4053 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; };
F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */; };
- F71694EE2A6624F9001F4053 /* suggested_users.json in Resources */ = {isa = PBXBuildFile; fileRef = F71694ED2A6624F9001F4053 /* suggested_users.json */; };
F71694F22A67314D001F4053 /* SuggestedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F12A67314D001F4053 /* SuggestedUserView.swift */; };
F71694F42A6732B7001F4053 /* GradientFollowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F32A6732B7001F4053 /* GradientFollowButton.swift */; };
F71694F82A6983AF001F4053 /* GrayGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F72A6983AF001F4053 /* GrayGradient.swift */; };
@@ -2553,6 +2565,8 @@
D7100C592B76FD5100C59298 /* LogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoView.swift; sourceTree = "<group>"; };
D7100C5B2B77016700C59298 /* IAPProductStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPProductStateView.swift; sourceTree = "<group>"; };
D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleStoreKitManager.swift; sourceTree = "<group>"; };
+ D71527F32E0A2DC900C893D6 /* follow-packs.jsonl */ = {isa = PBXFileReference; lastKnownFileType = text; path = "follow-packs.jsonl"; sourceTree = "<group>"; };
+ D71527FE2E0A3D5F00C893D6 /* InterestList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterestList.swift; sourceTree = "<group>"; };
D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityTracker.swift; sourceTree = "<group>"; };
D71AD8FC2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccessibilityIdentifiers.swift; sourceTree = "<group>"; };
D71DC1EB2A9129C3006E207C /* PostViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostViewTests.swift; sourceTree = "<group>"; };
@@ -2579,6 +2593,7 @@
D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRelayListErrors.swift; sourceTree = "<group>"; };
D73E5F7E2C6AA066007EB227 /* DamusAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAliases.swift; sourceTree = "<group>"; };
D73E5F802C6AA07A007EB227 /* HighlighterExtensionAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlighterExtensionAliases.swift; sourceTree = "<group>"; };
+ D73FA9E02DDC129E00C706E1 /* OnboardingContentSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingContentSettings.swift; sourceTree = "<group>"; };
D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessDamusState.swift; sourceTree = "<group>"; };
D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationExtensionState.swift; sourceTree = "<group>"; };
D74AAFCB2B155D07006CF0F4 /* MakeZapRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeZapRequest.swift; sourceTree = "<group>"; };
@@ -2595,12 +2610,14 @@
D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = "<group>"; };
D767066E2C8BB3CE00F09726 /* URLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHandler.swift; sourceTree = "<group>"; };
D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileZapLinkView.swift; sourceTree = "<group>"; };
+ D76BE18B2E0CF3D5004AD0C6 /* Interests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interests.swift; sourceTree = "<group>"; };
D773BC5E2C6D538500349F0A /* CommentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentItem.swift; sourceTree = "<group>"; };
D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = "<group>"; };
D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = "<group>"; };
D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; };
D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; };
D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = "<group>"; };
+ D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterestSelectionView.swift; sourceTree = "<group>"; };
D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAppNotificationView.swift; sourceTree = "<group>"; };
D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorMath.swift; sourceTree = "<group>"; };
D78DB85E2C20FED300F0AB12 /* ChatBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleView.swift; sourceTree = "<group>"; };
@@ -2664,7 +2681,6 @@
E990020E2955F837003BBC5A /* EditMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditMetadataView.swift; sourceTree = "<group>"; };
F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSuggestionsView.swift; sourceTree = "<group>"; };
F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedUsersViewModel.swift; sourceTree = "<group>"; };
- F71694ED2A6624F9001F4053 /* suggested_users.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = suggested_users.json; sourceTree = "<group>"; };
F71694F12A67314D001F4053 /* SuggestedUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedUserView.swift; sourceTree = "<group>"; };
F71694F32A6732B7001F4053 /* GradientFollowButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientFollowButton.swift; sourceTree = "<group>"; };
F71694F72A6983AF001F4053 /* GrayGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrayGradient.swift; sourceTree = "<group>"; };
@@ -3778,6 +3794,8 @@
4CE6DEE527F7A08100C66700 /* damus */ = {
isa = PBXGroup;
children = (
+ D76BE18A2E0CF3BF004AD0C6 /* DIP06 */,
+ D71527FD2E0A3D5800C893D6 /* NIP51 */,
D7DB93082D69478400DA1EE5 /* NIP65 */,
D7DB1FDC2D5A77E500CF06DA /* NIP44 */,
D755B28B2D3E7D6500BBEEFA /* NIP37 */,
@@ -4075,6 +4093,14 @@
path = Detail;
sourceTree = "<group>";
};
+ D71527FD2E0A3D5800C893D6 /* NIP51 */ = {
+ isa = PBXGroup;
+ children = (
+ D71527FE2E0A3D5F00C893D6 /* InterestList.swift */,
+ );
+ path = NIP51;
+ sourceTree = "<group>";
+ };
D71AC4CA2BA8E3320076268E /* Extensions */ = {
isa = PBXGroup;
children = (
@@ -4133,6 +4159,14 @@
path = NIP37;
sourceTree = "<group>";
};
+ D76BE18A2E0CF3BF004AD0C6 /* DIP06 */ = {
+ isa = PBXGroup;
+ children = (
+ D76BE18B2E0CF3D5004AD0C6 /* Interests.swift */,
+ );
+ path = DIP06;
+ sourceTree = "<group>";
+ };
D78DB85D2C20FE9E00F0AB12 /* Chat */ = {
isa = PBXGroup;
children = (
@@ -4220,10 +4254,12 @@
F71694E82A66221E001F4053 /* Onboarding */ = {
isa = PBXGroup;
children = (
+ D73FA9E02DDC129E00C706E1 /* OnboardingContentSettings.swift */,
F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */,
F71694F12A67314D001F4053 /* SuggestedUserView.swift */,
F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */,
- F71694ED2A6624F9001F4053 /* suggested_users.json */,
+ D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */,
+ D71527F32E0A2DC900C893D6 /* follow-packs.jsonl */,
);
path = Onboarding;
sourceTree = "<group>";
@@ -4508,6 +4544,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ D71527F42E0A2DCA00C893D6 /* follow-packs.jsonl in Resources */,
4C1D4FB42A7967990024F453 /* build-git-hash.txt in Resources */,
D7FB14222BE5970000398331 /* PrivacyInfo.xcprivacy in Resources */,
3ACB685F297633BC00C46468 /* Localizable.strings in Resources */,
@@ -4517,7 +4554,6 @@
4CE6DEEB27F7A08200C66700 /* Assets.xcassets in Resources */,
4C198DF129F88C6B004C165C /* License.txt in Resources */,
4C198DF029F88C6B004C165C /* Readme.md in Resources */,
- F71694EE2A6624F9001F4053 /* suggested_users.json in Resources */,
3A4325A82961E11400BFCD9D /* Localizable.stringsdict in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -4737,6 +4773,7 @@
4C7D09602A098C5D00943473 /* WalletView.swift in Sources */,
4CB8838F296F781C00DC99E7 /* ReactionsView.swift in Sources */,
B5C60C202B530D5100C5ECA7 /* MuteItem.swift in Sources */,
+ D78BA6652DD7DFB9000AE62C /* InterestSelectionView.swift in Sources */,
4C75EFB328049D640006080F /* NostrEvent.swift in Sources */,
4C32B9582A9AD44700DC3548 /* VeriferOptions.swift in Sources */,
D74AAFC22B153395006CF0F4 /* HeadlessDamusState.swift in Sources */,
@@ -4926,6 +4963,7 @@
4C19AE512A5CEF7C00C90DB7 /* NostrScript.swift in Sources */,
4C32B95E2A9AD44700DC3548 /* FlatBufferObject.swift in Sources */,
D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */,
+ D73FA9E12DDC12AA00C706E1 /* OnboardingContentSettings.swift in Sources */,
4C3EA64F28FF59F200C48A62 /* tal.c in Sources */,
5C42E78C29DB76D90086AAC1 /* EmptyUserSearchView.swift in Sources */,
4CB88396296F7F8B00DC99E7 /* ReactionView.swift in Sources */,
@@ -4940,6 +4978,7 @@
5C0567582C8FBC560073F23A /* NDBSearchView.swift in Sources */,
D72341192B6864F200E1E135 /* DamusPurpleEnvironment.swift in Sources */,
4CF0ABD629817F5B00D66079 /* ReportView.swift in Sources */,
+ D71528002E0A3D6900C893D6 /* InterestList.swift in Sources */,
4C1A9A2729DDE31900516EAC /* TranslationSettingsView.swift in Sources */,
BA3759942ABCCEBA0018D73B /* CameraService.swift in Sources */,
4CB8838629656C8B00DC99E7 /* NIP05.swift in Sources */,
@@ -5027,6 +5066,7 @@
4C3AC79B28306D7B00E1F516 /* Contacts.swift in Sources */,
D71AD8FF2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */,
4C3EA63D28FF52D600C48A62 /* bolt11.c in Sources */,
+ D76BE18D2E0CF3DA004AD0C6 /* Interests.swift in Sources */,
4C9BB83129C0ED4F00FC4E37 /* DisplayName.swift in Sources */,
7CFF6317299FEFE5005D382A /* SelectableText.swift in Sources */,
4CA352A82A76B37E003BB08B /* NewMutesNotify.swift in Sources */,
@@ -5227,6 +5267,7 @@
82D6FACB2CD99F7900C925F4 /* nostrscript.c in Sources */,
82D6FACC2CD99F7900C925F4 /* error.c in Sources */,
82D6FACD2CD99F7900C925F4 /* wasm.c in Sources */,
+ D73C7EDA2DE51690001F9392 /* OnboardingSuggestionsView.swift in Sources */,
82D6FACE2CD99F7900C925F4 /* damus.c in Sources */,
82D6FACF2CD99F7900C925F4 /* utf8.c in Sources */,
82D6FAD02CD99F7900C925F4 /* bolt11.c in Sources */,
@@ -5335,6 +5376,7 @@
82D6FB2A2CD99F7900C925F4 /* VersionInfo.swift in Sources */,
82D6FB2B2CD99F7900C925F4 /* WalletConnect.swift in Sources */,
82D6FB2C2CD99F7900C925F4 /* ImageMetadata.swift in Sources */,
+ D71527FF2E0A3D6900C893D6 /* InterestList.swift in Sources */,
82D6FB2D2CD99F7900C925F4 /* ImageProcessing.swift in Sources */,
82D6FB2E2CD99F7900C925F4 /* BlurHashEncode.swift in Sources */,
82D6FB2F2CD99F7900C925F4 /* BlurHashDecode.swift in Sources */,
@@ -5439,6 +5481,7 @@
82D6FB8D2CD99F7900C925F4 /* FollowersModel.swift in Sources */,
82D6FB8E2CD99F7900C925F4 /* SearchHomeModel.swift in Sources */,
82D6FB8F2CD99F7900C925F4 /* DirectMessagesModel.swift in Sources */,
+ D73C7EDD2DE517A1001F9392 /* OnboardingContentSettings.swift in Sources */,
82D6FB902CD99F7900C925F4 /* DirectMessageModel.swift in Sources */,
82D6FB912CD99F7900C925F4 /* UserSettingsStore.swift in Sources */,
82D6FB922CD99F7900C925F4 /* Wallet.swift in Sources */,
@@ -5450,6 +5493,7 @@
82D6FB972CD99F7900C925F4 /* ZapsModel.swift in Sources */,
82D6FB982CD99F7900C925F4 /* DraftsModel.swift in Sources */,
82D6FB992CD99F7900C925F4 /* NotificationsModel.swift in Sources */,
+ D78BA6662DD7DFB9000AE62C /* InterestSelectionView.swift in Sources */,
82D6FB9A2CD99F7900C925F4 /* ImageUploadModel.swift in Sources */,
82D6FB9B2CD99F7900C925F4 /* MutedThreadsManager.swift in Sources */,
82D6FB9C2CD99F7900C925F4 /* WalletModel.swift in Sources */,
@@ -5494,6 +5538,7 @@
82D6FBBF2CD99F7900C925F4 /* ReferencedId.swift in Sources */,
82D6FBC02CD99F7900C925F4 /* Id.swift in Sources */,
82D6FBC12CD99F7900C925F4 /* RelayURL.swift in Sources */,
+ D76BE18C2E0CF3DA004AD0C6 /* Interests.swift in Sources */,
82D6FBC22CD99F7900C925F4 /* NostrEvent+.swift in Sources */,
82D6FBC32CD99F7900C925F4 /* NIP98AuthenticatedRequest.swift in Sources */,
82D6FBC42CD99F7900C925F4 /* NostrAuth.swift in Sources */,
@@ -5506,7 +5551,6 @@
82D6FBCB2CD99F7900C925F4 /* VisibilityTracker.swift in Sources */,
82D6FBCC2CD99F7900C925F4 /* CameraPreview.swift in Sources */,
82D6FBCD2CD99F7900C925F4 /* CameraController.swift in Sources */,
- 82D6FBCE2CD99F7900C925F4 /* OnboardingSuggestionsView.swift in Sources */,
3AA2F4EA2DF1467A00B18606 /* TrustedNetworkButtonTip.swift in Sources */,
82D6FBCF2CD99F7900C925F4 /* SuggestedUserView.swift in Sources */,
82D6FBD02CD99F7900C925F4 /* SuggestedUsersViewModel.swift in Sources */,
@@ -5770,6 +5814,7 @@
5C09FD122DF283D700823661 /* FollowPackModel.swift in Sources */,
D73E5E632C6A97F4007EB227 /* BlurHashDecode.swift in Sources */,
D73E5F952C6AA753007EB227 /* FullScreenCarouselView.swift in Sources */,
+ D76BE18E2E0CF3DA004AD0C6 /* Interests.swift in Sources */,
D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */,
D73E5E652C6A97F4007EB227 /* KFOptionSetter+.swift in Sources */,
D73E5E662C6A97F4007EB227 /* FillAndStroke.swift in Sources */,
@@ -5790,6 +5835,7 @@
5C4FA7EE2DC29AE900CE658C /* FollowPackEvent.swift in Sources */,
D73E5F922C6AA720007EB227 /* QRCodeView.swift in Sources */,
D73E5E742C6A97F4007EB227 /* Lists.swift in Sources */,
+ D73C7ED92DE51690001F9392 /* OnboardingSuggestionsView.swift in Sources */,
D73E5E752C6A97F4007EB227 /* CoreSVG.swift in Sources */,
D73E5E762C6A97F4007EB227 /* AccountDeletion.swift in Sources */,
D73E5E772C6A97F4007EB227 /* Translator.swift in Sources */,
@@ -5889,7 +5935,6 @@
D73E5EC72C6A97F4007EB227 /* VisibilityTracker.swift in Sources */,
D73E5EC82C6A97F4007EB227 /* CameraPreview.swift in Sources */,
D73E5EC92C6A97F4007EB227 /* CameraController.swift in Sources */,
- D73E5ECA2C6A97F4007EB227 /* OnboardingSuggestionsView.swift in Sources */,
D73E5ECB2C6A97F4007EB227 /* SuggestedUserView.swift in Sources */,
D73E5ECC2C6A97F4007EB227 /* SuggestedUsersViewModel.swift in Sources */,
D73E5ED22C6A97F4007EB227 /* WalletView.swift in Sources */,
@@ -6042,6 +6087,7 @@
D73E5F512C6A97F5007EB227 /* EventDetailView.swift in Sources */,
D73E5F522C6A97F5007EB227 /* FollowButtonView.swift in Sources */,
D73E5F532C6A97F5007EB227 /* FollowingView.swift in Sources */,
+ D71528012E0A3D6900C893D6 /* InterestList.swift in Sources */,
D7DF58322DFCF18D00E9AD28 /* SendPaymentView.swift in Sources */,
D73E5F542C6A97F5007EB227 /* LoginView.swift in Sources */,
D73E5F552C6A97F5007EB227 /* QRScanNSECView.swift in Sources */,
@@ -6109,6 +6155,7 @@
D703D7522C670A1400A400EA /* Log.swift in Sources */,
D73E5E1B2C6A9672007EB227 /* LikeCounter.swift in Sources */,
D703D7A92C670E5A00A400EA /* refmap.c in Sources */,
+ D73C7EDC2DE51699001F9392 /* OnboardingContentSettings.swift in Sources */,
D703D77B2C670BF000A400EA /* TableVerifier.swift in Sources */,
3ACF94442DA9FCAB00971A4E /* NIP05DomainTimelineView.swift in Sources */,
D703D76D2C670B4500A400EA /* ZapDataModel.swift in Sources */,
@@ -6125,6 +6172,7 @@
5C05675A2C8FBDE70073F23A /* NDBSearchView.swift in Sources */,
D703D76E2C670B4900A400EA /* NdbTagsIterator.swift in Sources */,
D703D7A02C670E1500A400EA /* take.c in Sources */,
+ D78BA6672DD7DFB9000AE62C /* InterestSelectionView.swift in Sources */,
D703D7692C670B2600A400EA /* Block.swift in Sources */,
D703D77D2C670C0300A400EA /* FlatbuffersErrors.swift in Sources */,
D703D7A62C670E5200A400EA /* builder.c in Sources */,
diff --git a/damus/ContentView.swift b/damus/ContentView.swift
@@ -334,7 +334,20 @@ struct ContentView: View {
.presentationDetents([.height(550)])
.presentationDragIndicator(.visible)
case .onboardingSuggestions:
- OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
+ if let model = try? SuggestedUsersViewModel(damus_state: damus_state!) {
+ OnboardingSuggestionsView(model: model)
+ .interactiveDismissDisabled(true)
+ }
+ else {
+ ErrorView(
+ damus_state: damus_state,
+ error: .init(
+ user_visible_description: NSLocalizedString("Unexpected error loading user suggestions", comment: "Human readable error label"),
+ tip: NSLocalizedString("Please contact support", comment: "Human readable error tip"),
+ technical_info: "Error inializing SuggestedUsersViewModel"
+ )
+ )
+ }
case .purple(let purple_url):
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
case .purple_onboarding:
diff --git a/damus/DIP06/Interests.swift b/damus/DIP06/Interests.swift
@@ -0,0 +1,77 @@
+//
+// Interests.swift
+// damus
+//
+// Created by Daniel D’Aquino on 2025-06-25.
+//
+
+import Foundation
+
+struct DIP06 {
+ /// Standard general interest topics.
+ /// See https://github.com/damus-io/dips/pull/3
+ enum Interest: String, CaseIterable {
+ /// Bitcoin-related topics (e.g. Bitcoin, Lightning, e-cash etc)
+ case bitcoin = "bitcoin"
+ /// Any non-Bitcoin technology-related topic (e.g. Linux, new releases, software development, supersonic flight, etc)
+ case technology = "technology"
+ /// Any science-related topic (e.g. astronomy, biology, physics, etc)
+ case science = "science"
+ /// Lifestyle topics (e.g. Worldschooling, Digital nomading, vagabonding, homesteading, digital minimalism, life hacks, etc)
+ case lifestyle = "lifestyle"
+ /// Travel-related topics (e.g. Information about locations to visit, travel logs, etc)
+ case travel = "travel"
+ /// Any art-related topic (e.g. poetry, painting, sculpting, photography, etc)
+ case art = "art"
+ /// Topics focused on improving human health (e.g. advances in medicine, exercising, nutrition, meditation, sleep, etc)
+ case health = "health"
+ /// Any music-related topic (e.g. Bands, fan pages, instruments, classical music theory, etc)
+ case music = "music"
+ /// Any topic related to food (e.g. Cooking, recipes, meal planning, nutrition)
+ case food = "food"
+ /// Any topic related to sports (e.g. Athlete fan pages, general sports information, sports news, sports equipment, etc)
+ case sports = "sports"
+ /// Any topic related to religion, spirituality, or faith (e.g. Christianity, Judaism, Buddhism, Islamism, Hinduism, Taoism, general meditation practice, etc)
+ case religionSpirituality = "religion-spirituality"
+ /// General humanities topics (e.g. philosophy, sociology, culture, etc)
+ case humanities = "humanities"
+ /// General topics about politics
+ case politics = "politics"
+ /// Other miscellaneous topics that do not fit in any of the previous items of the list
+ case other = "other"
+
+ var label: String {
+ switch self {
+ case .bitcoin:
+ return NSLocalizedString("₿ Bitcoin", comment: "Interest topic label")
+ case .technology:
+ return NSLocalizedString("💻 Tech", comment: "Interest topic label")
+ case .science:
+ return NSLocalizedString("🔭 Science", comment: "Interest topic label")
+ case .lifestyle:
+ return NSLocalizedString("🏝️ Lifestyle", comment: "Interest topic label")
+ case .travel:
+ return NSLocalizedString("✈️ Travel", comment: "Interest topic label")
+ case .art:
+ return NSLocalizedString("🎨 Art", comment: "Interest topic label")
+ case .health:
+ return NSLocalizedString("🏃 Health", comment: "Interest topic label")
+ case .music:
+ return NSLocalizedString("🎶 Music", comment: "Interest topic label")
+ case .food:
+ return NSLocalizedString("🍱 Food", comment: "Interest topic label")
+ case .sports:
+ return NSLocalizedString("⚾️ Sports", comment: "Interest topic label")
+ case .religionSpirituality:
+ return NSLocalizedString("🛐 Religion", comment: "Interest topic label")
+ case .humanities:
+ return NSLocalizedString("📚 Humanities", comment: "Interest topic label")
+ case .politics:
+ return NSLocalizedString("🏛️ Politics", comment: "Interest topic label")
+ case .other:
+ return NSLocalizedString("♾️ Other", comment: "Interest topic label")
+ }
+ }
+ }
+}
+
diff --git a/damus/Models/FollowPackEvent.swift b/damus/Models/FollowPackEvent.swift
@@ -8,13 +8,14 @@
import Foundation
-struct FollowPackEvent {
+struct FollowPackEvent: Hashable {
let event: NostrEvent
var title: String? = nil
var uuid: String? = nil
var image: URL? = nil
var description: String? = nil
var publicKeys: [Pubkey] = []
+ var interests: Set<DIP06.Interest> = []
static func parse(from ev: NostrEvent) -> FollowPackEvent {
@@ -29,6 +30,10 @@ struct FollowPackEvent {
case "description": followlist.description = tag[1].string()
case "p":
followlist.publicKeys.append(Pubkey(Data(hex: tag[1].string())))
+ case "t":
+ if let interest = DIP06.Interest(rawValue: tag[1].string()) {
+ followlist.interests.insert(interest)
+ }
default:
break
}
diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift
@@ -229,6 +229,8 @@ class HomeModel: ContactsDelegate {
break // This will be handled by `UserRelayListManager`
case .follow_list:
break
+ case .interest_list:
+ break // Don't care for now
}
}
diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift
@@ -137,6 +137,9 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "hide_nsfw_tagged_content", default_value: false)
var hide_nsfw_tagged_content: Bool
+ @Setting(key: "reduce_bitcoin_content", default_value: false)
+ var reduce_bitcoin_content: Bool
+
@Setting(key: "show_profile_action_sheet_on_pfp_click", default_value: true)
var show_profile_action_sheet_on_pfp_click: Bool
diff --git a/damus/NIP51/InterestList.swift b/damus/NIP51/InterestList.swift
@@ -0,0 +1,111 @@
+//
+// InterestList.swift
+// damus
+//
+// Created by Daniel D'Aquino on 2025-06-23.
+//
+// Some text excerpts taken from the Nostr Protocol itself (which are public domain)
+
+import Foundation
+
+/// Includes models and functions for working with NIP-51
+struct NIP51: Sendable {}
+
+extension NIP51 {
+ /// An error thrown when decoding an item into a NIP-51 list
+ enum NIP51DecodingError: Error {
+ /// The Nostr event being converted is not a NIP-51 interest list
+ case notInterestList
+ }
+}
+
+extension NIP51 {
+ /// Models a NIP-51 Interest List (kind:10015)
+ struct InterestList: NostrEventConvertible, Sendable {
+ typealias E = NIP51DecodingError
+
+ enum InterestItem: Sendable, Hashable {
+ case hashtag(String)
+ case interestSet(String, String, String) // a-tag: kind, pubkey, identifier
+
+ var tag: [String] {
+ switch self {
+ case .hashtag(let tag):
+ return ["t", tag]
+ case .interestSet(let kind, let pubkey, let identifier):
+ var tag = ["a", "\(kind):\(pubkey):\(identifier)"]
+ return tag
+ }
+ }
+
+ static func fromTag(tag: TagSequence) -> InterestItem? {
+ var i = tag.makeIterator()
+
+ guard let t0 = i.next(),
+ let t1 = i.next() else { return nil }
+
+ let tagName = t0.string()
+
+ if tagName == "t" {
+ return .hashtag(t1.string())
+ } else if tagName == "a" {
+ let components = t1.string().split(separator: ":")
+ guard components.count > 2 else { return nil }
+
+ let kind = String(components[0])
+ let pubkey = String(components[1])
+ let identifier = String(components[2])
+
+ return .interestSet(kind, pubkey, identifier)
+ }
+
+ return nil
+ }
+ }
+
+ let interests: [InterestItem]
+
+ // MARK: - Initialization
+
+ init(event: NdbNote) throws(E) {
+ try self.init(event: UnownedNdbNote(event))
+ }
+
+ init(event: borrowing UnownedNdbNote) throws(E) {
+ guard event.known_kind == .interest_list else {
+ throw E.notInterestList
+ }
+
+ var interests: [InterestItem] = []
+
+ for tag in event.tags {
+ if let interest = InterestItem.fromTag(tag: tag) {
+ interests.append(interest)
+ }
+ }
+
+ self.interests = interests
+ }
+
+ init?(event: NdbNote?) throws(E) {
+ guard let event else { return nil }
+ try self.init(event: event)
+ }
+
+ init(interests: [InterestItem]) {
+ self.interests = interests
+ }
+
+ // MARK: - Conversion to a Nostr Event
+
+ func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? {
+ return NdbNote(
+ content: "",
+ keypair: keypair.to_keypair(),
+ kind: NostrKind.interest_list.rawValue,
+ tags: self.interests.map { $0.tag },
+ createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970)
+ )
+ }
+ }
+}
diff --git a/damus/Nostr/NostrKind.swift b/damus/Nostr/NostrKind.swift
@@ -20,6 +20,7 @@ enum NostrKind: UInt32, Codable {
case chat = 42
case mute_list = 10000
case relay_list = 10002
+ case interest_list = 10015
case list_deprecated = 30000
case draft = 31234
case longform = 30023
diff --git a/damus/Types/Ids/Pubkey.swift b/damus/Types/Ids/Pubkey.swift
@@ -45,4 +45,3 @@ struct Pubkey: IdType, TagKey, TagConvertible, Identifiable {
}
}
-
diff --git a/damus/Util/Constants.swift b/damus/Util/Constants.swift
@@ -18,6 +18,9 @@ class Constants {
static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2"
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"
+ // MARK: Curation
+ static let ONBOARDING_FOLLOW_PACK_CURATOR_PUBKEY: Pubkey = Pubkey(hex: "895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba")!
+
// MARK: Push notification server
static let PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL: URL = URL(string: "https://notify.damus.io")!
static let PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL: URL = URL(string: "https://notify-staging.damus.io")!
diff --git a/damus/Views/LoadableNostrEventView.swift b/damus/Views/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:
+ 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:
return .unknown_or_unsupported_kind
}
case .naddr(let naddr):
diff --git a/damus/Views/Onboarding/InterestSelectionView.swift b/damus/Views/Onboarding/InterestSelectionView.swift
@@ -0,0 +1,121 @@
+//
+// InterestSelectionView.swift
+// damus
+//
+// Created by Daniel D’Aquino on 2025-05-16.
+//
+import SwiftUI
+
+extension OnboardingSuggestionsView {
+ typealias Interest = DIP06.Interest
+
+ struct InterestSelectionView: View {
+ var damus_state: DamusState
+ var next_page: (() -> Void)
+
+ /// Track selected interests using a Set
+ @Binding var selectedInterests: Set<Interest>
+ var isNextEnabled: Bool
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 20) {
+ // Title
+ Text(NSLocalizedString("Select Your Interests", comment: "Screen title for interest selection"))
+ .font(.largeTitle)
+ .fontWeight(.bold)
+ .multilineTextAlignment(.center)
+ .padding(.top)
+
+ // Instruction subtitle
+ Text(NSLocalizedString("Please pick your interests. This will help us recommend accounts to follow.", comment: "Instruction for interest selection"))
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+
+ // Interests grid view
+ InterestsGridView(availableInterests: Interest.allCases,
+ selectedInterests: $selectedInterests)
+ .padding()
+
+ Spacer()
+
+ // Next button wrapped inside a NavigationLink for easy transition.
+ Button(action: {
+ self.next_page()
+ }, label: {
+ Text(NSLocalizedString("Next", comment: "Next button title"))
+ .foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ })
+ .buttonStyle(GradientButtonStyle())
+ .disabled(!isNextEnabled)
+ .opacity(isNextEnabled ? 1.0 : 0.5)
+ .padding([.leading, .trailing, .bottom])
+ }
+ .padding()
+ }
+ }
+ }
+
+ /// A grid view to display interest options
+ struct InterestsGridView: View {
+ let availableInterests: [Interest]
+ @Binding var selectedInterests: Set<Interest>
+
+ // Adaptive grid layout with two columns
+ private let columns = [
+ GridItem(.adaptive(minimum: 120, maximum: 480)),
+ GridItem(.adaptive(minimum: 120, maximum: 480)),
+ ]
+
+ var body: some View {
+ LazyVGrid(columns: columns, spacing: 16) {
+ ForEach(availableInterests, id: \ .self) { interest in
+ let disabled = false
+ InterestButton(interest: interest,
+ isSelected: selectedInterests.contains(interest)) {
+ // Toggle selection
+ if selectedInterests.contains(interest) {
+ selectedInterests.remove(interest)
+ } else {
+ selectedInterests.insert(interest)
+ }
+ }
+ .disabled(disabled)
+ .opacity(disabled ? 0.5 : 1.0)
+ }
+ }
+ }
+ }
+
+ /// A button view representing a single interest option
+ struct InterestButton: View {
+ let interest: Interest
+ let isSelected: Bool
+ var action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(interest.label)
+ .font(.body)
+ .padding(.vertical, 8)
+ .padding(.horizontal, 10)
+ .frame(maxWidth: .infinity)
+ .background(isSelected ? Color.accentColor : Color.gray.opacity(0.2))
+ .foregroundColor(isSelected ? Color.white : Color.primary)
+ .cornerRadius(50)
+ }
+ }
+ }
+}
+
+struct InterestSelectionView_Previews: PreviewProvider {
+ static var previews: some View {
+ OnboardingSuggestionsView.InterestSelectionView(
+ damus_state: test_damus_state,
+ next_page: { print("next") },
+ selectedInterests: Binding.constant(Set([DIP06.Interest.art, DIP06.Interest.music])), isNextEnabled: true
+ )
+ }
+}
diff --git a/damus/Views/Onboarding/OnboardingContentSettings.swift b/damus/Views/Onboarding/OnboardingContentSettings.swift
@@ -0,0 +1,82 @@
+//
+// OnboardingContentSettings.swift
+// damus
+//
+// Created by Daniel D’Aquino on 2025-05-19.
+//
+import SwiftUI
+
+extension OnboardingSuggestionsView {
+ struct OnboardingContentSettings: View {
+ var model: SuggestedUsersViewModel
+ var next_page: (() -> Void)
+ @ObservedObject var settings: UserSettingsStore
+
+ @Binding var selectedInterests: Set<Interest>
+
+ private var isNextEnabled: Bool { true }
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 20) {
+ // Title
+ Text(NSLocalizedString("Other preferences", comment: "Screen title for content preferences screen during onboarding"))
+ .font(.largeTitle)
+ .fontWeight(.bold)
+ .multilineTextAlignment(.center)
+ .padding(.top)
+
+ // Instruction subtitle
+ Text(NSLocalizedString("Tweak these settings to better match your preferences", comment: "Instructions for content preferences screen during onboarding"))
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+
+ // Content preferences section with toggles
+ Section() {
+ VStack(alignment: .leading, spacing: 5) {
+ Toggle(NSLocalizedString("Hide notes with #nsfw tags", comment: "Setting to hide notes with not safe for work tags"), isOn: $settings.hide_nsfw_tagged_content)
+ .toggleStyle(.switch)
+
+ Text(NSLocalizedString("Notes with the #nsfw tag usually contains adult content or other \"Not safe for work\" content", comment: "Explanation of what NSFW means"))
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .padding(.bottom, 10)
+
+ if !selectedInterests.contains(.bitcoin) {
+ Toggle(
+ NSLocalizedString("Show Bitcoin-heavy profile suggestions", comment: "Setting label during onboarding"),
+ isOn: Binding(get: { !model.reduceBitcoinContent }, set: { model.reduceBitcoinContent = !$0 })
+ )
+ .toggleStyle(.switch)
+
+ Text(NSLocalizedString("Some profiles tend to have a lot of Bitcoin-related content alongside their topics of interest. Disable this setting if you prefer to filter out follow suggestions that frequently talk about Bitcoin.", comment: "Explanation label for the 'Show Bitcoin-heavy profile suggestions' onboarding toggle setting"))
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding()
+ .background(Color.secondary.opacity(0.1))
+ .cornerRadius(10)
+ }
+ .padding()
+
+ Spacer()
+
+ Button(action: {
+ self.next_page()
+ }, label: {
+ Text(NSLocalizedString("Next", comment: "Next button title"))
+ .foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ })
+ .buttonStyle(GradientButtonStyle())
+ .disabled(!isNextEnabled)
+ .opacity(isNextEnabled ? 1.0 : 0.5)
+ .padding([.leading, .trailing, .bottom])
+ }
+ .padding()
+ }
+ }
+ }
+}
diff --git a/damus/Views/Onboarding/OnboardingSuggestionsView.swift b/damus/Views/Onboarding/OnboardingSuggestionsView.swift
@@ -26,49 +26,103 @@ struct OnboardingSuggestionsView: View {
current_page += 1
}
}
+
+ private var canLeaveInterestSelectionPage: Bool {
+ let count = model.interests.count
+ return count > 0
+ }
+
+ /// Save the user's selected interests to NDB
+ private func saveInterestsToNdb() {
+ // Convert the selected interests to hashtags for the NIP51 interest list
+ let interestItems = model.interests.map { interest in
+ NIP51.InterestList.InterestItem.hashtag(interest.rawValue)
+ }
+
+ // Create the interest list
+ let interestList = NIP51.InterestList(interests: Array(interestItems))
+
+ // Convert to a NostrEvent and send to NDB
+ guard let keypair = model.damus_state.keypair.to_full(),
+ let event = interestList.toNostrEvent(keypair: keypair, timestamp: nil) else {
+ return // Not a big deal, fail silently
+ }
+
+ // Send the event to NostrDB to allow us to retrieve later
+ // Did not send this to the network yet because:
+ // 1. I believe we should add an opt-out/opt-in button.
+ // 2. If we do, and the user accepts to share it, it will be an awkward situation considering:
+ // - We don't show that anywhere else yet
+ // - We don't have other mechanisms to allow the user to edit this yet
+ //
+ // Therefore, it is better to just save it locally, and retrieve this once we build out https://github.com/damus-io/damus/issues/3042
+ model.damus_state.nostrNetwork.pool.send_raw_to_local_ndb(.typical(.event(event)))
+ }
var body: some View {
NavigationView {
TabView(selection: $current_page) {
- SuggestedUsersPageView(model: model, next_page: self.next_page)
- .navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow"))
+ InterestSelectionView(damus_state: model.damus_state, next_page: {
+ self.next_page()
+ }, selectedInterests: $model.interests, isNextEnabled: canLeaveInterestSelectionPage)
+ .navigationTitle(NSLocalizedString("Select your interests", comment: "Title for a screen asking the user for interests"))
.navigationBarTitleDisplayMode(.inline)
- .navigationBarItems(leading: Button(action: {
- self.next_page()
- }, label: {
- Text("Skip", comment: "Button to dismiss the suggested users screen")
- .font(.subheadline.weight(.semibold))
- })
- .accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_sheet_skip_button.rawValue)
- )
.tag(0)
- PostView(
- action: .posting(.user(model.damus_state.pubkey)),
- damus_state: model.damus_state,
- prompt_view: {
- AnyView(
- HStack {
- Image(systemName: "sparkles")
- Text("Add your first post", comment: "Prompt given to the user during onboarding, suggesting them to write their first post")
- }
- .foregroundColor(.secondary)
- .font(.callout)
- .padding(.top, 10)
+ if canLeaveInterestSelectionPage {
+
+ OnboardingContentSettings(model: model, next_page: self.next_page, settings: model.damus_state.settings, selectedInterests: $model.interests)
+ .navigationTitle(NSLocalizedString("Content settings", comment: "Title for an onboarding screen showing user some content settings"))
+ .navigationBarTitleDisplayMode(.inline)
+ .tag(1)
+
+ SuggestedUsersPageView(model: model, next_page: self.next_page)
+ .navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow"))
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationBarItems(leading: Button(action: {
+ self.next_page()
+ }, label: {
+ Text("Skip", comment: "Button to dismiss the suggested users screen")
+ .font(.subheadline.weight(.semibold))
+ })
+ .accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_sheet_skip_button.rawValue)
)
- },
- placeholder_messages: self.first_post_examples,
- initial_text_suffix: self.initial_text_suffix
- )
- .onReceive(handle_notify(.post)) { _ in
- // NOTE: Even though PostView already calls `dismiss`, that is not guaranteed to work under deeply nested views.
- // Thus, we should also call `dismiss` from here (a direct subview of a sheet), which is explicitly supported by Apple.
- // See https://github.com/damus-io/damus/issues/1726 for more context and information
- dismiss()
+ .tag(2)
+
+ PostView(
+ action: .posting(.user(model.damus_state.pubkey)),
+ damus_state: model.damus_state,
+ prompt_view: {
+ AnyView(
+ HStack {
+ Image(systemName: "sparkles")
+ Text("Add your first post", comment: "Prompt given to the user during onboarding, suggesting them to write their first post")
+ }
+ .foregroundColor(.secondary)
+ .font(.callout)
+ .padding(.top, 10)
+ )
+ },
+ placeholder_messages: self.first_post_examples,
+ initial_text_suffix: self.initial_text_suffix
+ )
+ .onReceive(handle_notify(.post)) { _ in
+ // NOTE: Even though PostView already calls `dismiss`, that is not guaranteed to work under deeply nested views.
+ // Thus, we should also call `dismiss` from here (a direct subview of a sheet), which is explicitly supported by Apple.
+ // See https://github.com/damus-io/damus/issues/1726 for more context and information
+ dismiss()
+ }
+ .tag(3)
}
- .tag(1)
}
.tabViewStyle(.page(indexDisplayMode: .never))
+ .onChange(of: current_page) { newPage in
+ // If the user just swiped from the interests page (0) to the next page (1),
+ // save their interests to NDB
+ if newPage == 1 && current_page == 1 {
+ saveInterestsToNdb()
+ }
+ }
}
}
}
@@ -79,20 +133,27 @@ fileprivate struct SuggestedUsersPageView: View {
var body: some View {
VStack {
- List {
- ForEach(model.groups) { group in
- Section {
- ForEach(group.users, id: \.self) { pk in
- if let user = model.suggestedUser(pubkey: pk) {
- SuggestedUserView(user: user, damus_state: model.damus_state)
+ if let suggestions = model.suggestions {
+ List {
+ ForEach(suggestions, id: \.self) { followPack in
+ Section {
+ ForEach(followPack.publicKeys, id: \.self) { pk in
+ if let usersInterests = model.interestUserMap[pk],
+ !usersInterests.intersection(model.interests).isEmpty && usersInterests.intersection(model.disinterests).isEmpty,
+ let user = model.suggestedUser(pubkey: pk) {
+ SuggestedUserView(user: user, damus_state: model.damus_state)
+ }
}
+ } header: {
+ SuggestedUsersSectionHeader(followPack: followPack, model: model)
}
- } header: {
- SuggestedUsersSectionHeader(group: group, model: model)
}
}
+ .listStyle(.plain)
+ }
+ else {
+ ProgressView()
}
- .listStyle(.plain)
Spacer()
@@ -110,17 +171,14 @@ fileprivate struct SuggestedUsersPageView: View {
}
struct SuggestedUsersSectionHeader: View {
- let group: SuggestedUserGroup
+ let followPack: FollowPackEvent
let model: SuggestedUsersViewModel
var body: some View {
HStack {
- let locale = Locale.current
- let format = localizedStringFormat(key: group.category, locale: locale)
- let categoryName = String(format: format, locale: locale)
- Text(categoryName)
+ Text(followPack.title ?? NSLocalizedString("Untitled Follow Pack", comment: "Default title for a follow pack if no title is specified"))
Spacer()
Button(NSLocalizedString("Follow All", comment: "Button to follow all users in this section")) {
- model.follow(pubkeys: group.users)
+ model.follow(pubkeys: followPack.publicKeys)
}
.font(.subheadline.weight(.semibold))
}
@@ -129,6 +187,6 @@ struct SuggestedUsersSectionHeader: View {
struct SuggestedUsersView_Previews: PreviewProvider {
static var previews: some View {
- OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: test_damus_state))
+ OnboardingSuggestionsView(model: try! SuggestedUsersViewModel(damus_state: test_damus_state))
}
}
diff --git a/damus/Views/Onboarding/SuggestedUsersViewModel.swift b/damus/Views/Onboarding/SuggestedUsersViewModel.swift
@@ -8,32 +8,76 @@
import Foundation
import Combine
-struct SuggestedUserGroup: Identifiable, Codable {
- let id = UUID()
- let category: String
- let users: [Pubkey]
-
- enum CodingKeys: String, CodingKey {
- case category, users
- }
-}
-
-
+/// This model does the following:
+///
+/// - It loads follow packs (From the network, with a local copy fallback), and related profiles
+/// - It tracks the interests and disinterests as selected by the user via an interface
+/// - It computes publishes suggestions for users based on selected interests
+@MainActor
class SuggestedUsersViewModel: ObservableObject {
-
+ /// The Damus State
public let damus_state: DamusState
-
- @Published var groups: [SuggestedUserGroup] = []
-
- private let sub_id = UUID().uuidString
-
- init(damus_state: DamusState) {
+
+ /// Keeps all the suggested follow packs available. For internal use only.
+ private var allSuggestions: [FollowPackEvent]? = nil {
+ didSet { self.recomputeSuggestions() }
+ }
+
+ /// The user-selected topics of interests
+ @Published var interests: Set<Interest> = [] {
+ didSet {
+ self.recomputeSuggestions()
+ if interests.contains(.bitcoin) {
+ // Ensures there are no setting contradictions if user goes back and forth on onboarding
+ reduceBitcoinContent = false
+ }
+ }
+ }
+ /// A user preference that allows users to reduce bitcoin content
+ @Published var reduceBitcoinContent: Bool {
+ didSet {
+ self.recomputeDisinterests()
+ damus_state.settings.reduce_bitcoin_content = reduceBitcoinContent
+ }
+ }
+ @Published private(set) var disinterests: Set<Interest> = [] {
+ didSet { self.recomputeSuggestions() }
+ }
+
+ /// Keeps the suggested follow packs to the user.
+ ///
+ /// ## Implementation notes
+ ///
+ /// This is technically meant to be a computed property (see `recomputeSuggestions`),
+ /// but we also want views that display this to be automatically updated,
+ /// so therefore we use `@Published` instead, and add property write observers on its logical dependencies
+ @Published private(set) var suggestions: [FollowPackEvent]? = nil
+
+ /// A map of suggested pubkeys and the particular interest categories they belong to
+ private(set) var interestUserMap: [Pubkey: Set<Interest>] = [:]
+
+
+ // MARK: - Helper types
+
+ typealias FollowPackID = String
+ typealias Interest = DIP06.Interest
+
+
+ // MARK: - Initialization
+
+ init(damus_state: DamusState) throws {
self.damus_state = damus_state
- loadSuggestedUserGroups()
- let pubkeys = getPubkeys(groups: groups)
- subscribeToSuggestedProfiles(pubkeys: pubkeys)
+ self.reduceBitcoinContent = damus_state.settings.reduce_bitcoin_content
+ self.recomputeAll()
+ Task.detached {
+ await self.loadSuggestedFollowPacks()
+ }
}
+
+
+ // MARK: - External interface methods
+ /// Gets suggested user information from a provided pubkey
func suggestedUser(pubkey: Pubkey) -> SuggestedUser? {
let profile_txn = damus_state.profiles.lookup(id: pubkey)
if let profile = profile_txn?.unsafeUnownedValue,
@@ -43,63 +87,154 @@ class SuggestedUsersViewModel: ObservableObject {
return nil
}
+ /// Allows the user to follow a list of other users
func follow(pubkeys: [Pubkey]) {
for pubkey in pubkeys {
notify(.follow(.pubkey(pubkey)))
}
}
-
- private func loadSuggestedUserGroups() {
- guard let url = Bundle.main.url(forResource: "suggested_users", withExtension: "json") else {
- return
- }
-
- guard let data = try? Data(contentsOf: url) else {
- return
+
+
+ // MARK: - Internal state management logic
+
+ /// State management function that recomputes all "computed" properties
+ ///
+ /// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
+ private func recomputeAll() {
+ self.recomputeDisinterests()
+ self.recomputeSuggestions()
+ }
+
+ /// State management function that recomputes `disinterests` based its logical dependencies
+ ///
+ /// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
+ private func recomputeDisinterests() {
+ self.disinterests = reduceBitcoinContent ? Set([.bitcoin]) : []
+ }
+
+ /// State management function that recomputes `suggestions` based its logical dependencies
+ ///
+ /// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
+ private func recomputeSuggestions() {
+ self.suggestions = Self.computeSuggestions(basedOn: allSuggestions, interests: interests, disinterests: disinterests)
+ }
+
+ /// Purely functional function that computes suggestions based on the ones available, and the user's interest selections
+ private static func computeSuggestions(basedOn allSuggestions: [FollowPackEvent]?, interests: Set<Interest>, disinterests: Set<Interest>) -> [FollowPackEvent]? {
+ guard let allSuggestions else { return nil }
+ return allSuggestions.filter({ suggestion in
+ return !suggestion.interests.intersection(interests).isEmpty && suggestion.interests.intersection(disinterests).isEmpty
+ })
+ }
+
+ // MARK: - Internal loading logic
+
+ /// Loads suggestions
+ ///
+ /// (This is the main loading function that kicks-off the others)
+ ///
+ /// ## Usage notes
+ ///
+ /// - Long running task, preferably use this as a detached task
+ private func loadSuggestedFollowPacks() async {
+ // First, try preload events from the local file (To have a fallback in the case of an unstable internet connection)
+ var packsById = await self.loadLocalSuggestedFollowPacks()
+
+ // Then fetch the newest follow packs from the network and overwrite old ones where necessary
+ let subscriptionTask = Task {
+ await self.loadSuggestedFollowPacksFromNetwork(packsById: &packsById)
}
- let decoder = JSONDecoder()
- do {
- let groups = try decoder.decode([SuggestedUserGroup].self, from: data)
- self.groups = groups
- } catch {
- print(error.localizedDescription.localizedLowercase)
- }
+ // Wait for 5 seconds before timing out
+ try? await Task.sleep(nanoseconds: 5_000_000_000)
+ // Cancel the subscription task on timeout, to make sure we don't load forever
+ subscriptionTask.cancel()
+
+ // Finish loading and computing suggestions, as well as profile info
+ let allPacks = Array(packsById.values)
+ self.allSuggestions = allPacks
+ await self.loadProfiles(for: allPacks)
}
-
- private func getPubkeys(groups: [SuggestedUserGroup]) -> [Pubkey] {
- var pubkeys: [Pubkey] = []
- for group in groups {
- pubkeys.append(contentsOf: group.users)
+
+ /// Load the local follow packs, to have a fallback in the case of network instability
+ ///
+ /// ## Implementation notes
+ ///
+ /// This might seem redundant, but onboarding is a crucial moment for users, so we need to make sure they are onboarded successfully.
+ private func loadLocalSuggestedFollowPacks() async -> [FollowPackID: FollowPackEvent] {
+ var packsById: [String: FollowPackEvent] = [:]
+
+ if let bundleURL = Bundle.main.url(forResource: "follow-packs", withExtension: "jsonl"),
+ let jsonlData = try? Data(contentsOf: bundleURL),
+ let jsonlString = String(data: jsonlData, encoding: .utf8) {
+
+ let lines = jsonlString.components(separatedBy: .newlines)
+ for line in lines where !line.isEmpty {
+ if let note = NdbNote.owned_from_json(json: line) {
+ let followPack = FollowPackEvent.parse(from: note)
+ if let id = followPack.uuid {
+ packsById[id] = followPack
+ }
+ }
+ }
}
- return pubkeys
+
+ return packsById
}
-
- private func subscribeToSuggestedProfiles(pubkeys: [Pubkey]) {
- let filter = NostrFilter(kinds: [.metadata], authors: pubkeys)
- damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event)
+
+ /// Loads the newest follow packs from the network, and overwrites the provided follow packs where appropriate
+ private func loadSuggestedFollowPacksFromNetwork(packsById: inout [FollowPackID: FollowPackEvent]) async {
+ let filter = NostrFilter(
+ kinds: [NostrKind.follow_list],
+ authors: [Constants.ONBOARDING_FOLLOW_PACK_CURATOR_PUBKEY]
+ )
+
+ for await item in self.damus_state.nostrNetwork.reader.subscribe(filters: [filter]) {
+ // Check for cancellation on each iteration
+ guard !Task.isCancelled else { break }
+
+ switch item {
+ case .event(let borrow):
+ try? borrow { event in
+ let followPack = FollowPackEvent.parse(from: event.toOwned())
+
+ guard let id = followPack.uuid else { return }
+
+ let latestPackForThisId: FollowPackEvent
+
+ if let existingPack = packsById[id], existingPack.event.created_at > followPack.event.created_at {
+ latestPackForThisId = existingPack
+ } else {
+ latestPackForThisId = followPack
+ }
+
+ packsById[id] = latestPackForThisId
+ }
+ case .eose:
+ break
+ }
+ }
}
- func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
- guard case .nostr_event(let nev) = ev else {
- return
+ /// Finds all profiles mentioned in the follow packs, and loads the profile data from the network
+ private func loadProfiles(for packs: [FollowPackEvent]) async {
+ var allPubkeys: [Pubkey] = []
+
+ for followPack in packs {
+ for pubkey in followPack.publicKeys {
+ self.interestUserMap[pubkey] = Set(Array(self.interestUserMap[pubkey] ?? []) + Array(followPack.interests))
+ allPubkeys.append(pubkey)
+ }
}
-
- switch nev {
- case .event:
- break
-
- case .notice(let msg):
- print("suggested user profiles notice: \(msg)")
-
- case .eose:
- self.objectWillChange.send()
-
- case .ok:
- break
-
- case .auth:
- break
+
+ let profileFilter = NostrFilter(kinds: [.metadata], authors: allPubkeys)
+ for await item in damus_state.nostrNetwork.reader.subscribe(filters: [profileFilter]) {
+ switch item {
+ case .event(_):
+ continue // We just need NostrDB to ingest these for them to be available elsewhere, no need to analyze the data
+ case .eose:
+ break
+ }
}
}
}
diff --git a/damus/Views/Onboarding/follow-packs.jsonl b/damus/Views/Onboarding/follow-packs.jsonl
@@ -0,0 +1,19 @@
+{"kind":39089,"id":"5d0bb7d7cfde8614d91180e534ef9e33c35006d81185d8065a36ab3d3fa079db","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1750724913,"tags":[["title","Bitcoin enthusiasts"],["description","Profiles that like to talk about Bitcoin-related topics."],["image","https://images.unsplash.com/photo-1623227413711-25ee4388dae3?q=80&w=2972&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["d","hzgji33wnyku"],["t","bitcoin"],["p","704c4773626bf0f7ebf99d861eb0eae473be2b004f91725f8a1750486c9c848f"],["p","5b3260bc06f8e96b64a8c02e37385125d85479d681bb0302652b7a817c307a5e"],["p","7b3f7803750746f455413a221f80965eecb69ef308f2ead1da89cc2c8912e968"],["p","58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196"],["p","7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84"],["p","692c6feb28a0bd93d67cc19840bb07bfe678f97bf15930b5d812676b4cd512ef"],["p","130dcd3a1963f7fa35b206c44be6bc6f4ea0f5ee531b26126cb989678d5cfff5"],["p","c1831fbe2653f76164421d57db6cee38b8cef8ce6771bc65c12f8543de4b39bf"],["p","4d023ce9dfd75a7f3075b8e8e084008be17a1f750c63b5de721e6ef883adc765"],["p","f783ba3b12b91e375aba6594015b90bd95f7e132b03cc8c4c52ce0a7c36aab52"],["p","e07bfc04ea87c72a9185b0891f055361e6492f259f81247683d6c9ccb2b651ed"],["p","500ccc532c301711d88aa6d30b11dc477e6a32770853f8ab1c2be389b824e3f8"],["p","6a359852238dc902aed19fbbf6a055f9abf21c1ca8915d1c4e27f50df2f290d9"],["p","1586fd57ac81b66177b0087bb0c0fa465f30b9895949c8936836ec5e6cd13132"],["p","31ff694f01417160459e59e2e283b5454b499d3885092c792ae0d6b2de7839f0"],["p","259b1ddc0d3137cad123d4af64f87ff9db3834a843843963e58e2a9ac956cf39"],["p","91c9a5e1a9744114c6fe2d61ae4de82629eaaa0fb52f48288093c7e7e036f832"],["p","a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f"],["p","b7b51cc25216d4c10bc85ae27055c9a945fe77cafd463cf23b20917e39ce6816"],["p","25a2192dcf34c3be326988b5c9f942aa96789899d15b59412602854a8723e9e8"],["p","1c197b12ca9ce0415b70e7405b9770f0ec6bccfb59b32b63aafd42cb242e1642"],["p","9c86d77537a6380ce371e3a4860bc7e1fb2adbb2821bf1a8f1cd4e8ce02240c9"],["p","b203872cefbd5572b5551dde5bdfc0387219d99fb7e8d1c8418145d432ca0390"],["p","97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"],["p","ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a"],["p","566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8"],["p","c7617e84337c611c7d5f941b35b1ec51f2ae6e9f41aac9616092d510e1c295e0"],["p","e9986a10caaa96738ceda88aabd3e184307be5143e687457581f9b096c6ef89c"],["p","805b34f708837dfb3e7f05815ac5760564628b58d5a0ce839ccbb6ef3620fac3"],["p","d284b9523c99fe1acc9e759129bd9f17984a5aff413caf87018510a020a656fd"],["p","7644119b18ec56b5b2779e0d035e7712a9d669dbfbe5b2c5f458cb564afb6c95"],["p","5a8e581f16a012e24d2a640152ad562058cb065e1df28e907c1bfa82c150c8ba"],["p","0d6c8388dcb049b8dd4fc8d3d8c3bb93de3da90ba828e4f09c8ad0f346488a33"],["p","738f7873ac2c6cb7701e3150616afc824379b132b467ba5a8429d5964af1b136"],["p","37039b91a6ff36ff48189dfae4a1f6ea017992a61fb9485b47de54fe491f75bf"],["p","d36e8083fa7b36daee646cb8b3f99feaa3d89e5a396508741f003e21ac0b6bec"],["p","f4d89779148ccd245c8d50914a284fd62d97cb0fb68b797a70f24a172b522db9"],["p","32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],["p","b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"],["p","40b9c85fffeafc1cadf8c30a4e5c88660ff6e4971a0dc723d5ab674b5e61b451"],["p","963a2dca29e2ed5663a627b5289ed36d445531a3f5ef127716227d8a1aaa5166"],["p","139137969c1c56bf8306cd71272f681ed54206be15eeceb9eb98956d2fd9f7ef"],["p","f1989a96d75aa386b4c871543626cbb362c03248b220dc9ae53d7cefbcaaf2c1"],["p","76e36f2fabfbd8c353ffcda8fe07877a0977ee4aa9d9bcc224f02038d73b3787"],["p","e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb"],["p","4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"],["p","5e7e69f863d20ca30103df88887194e091679491fefe5eb8212753013cc3d552"],["p","ce9cfafcdc8425dc90c7045f263f966d077dbde96940b7fab4c9403998c5b49b"],["p","75da94027ad408bc2faffeb1e67d71babe8d78d89c3620da212303b877a65b5c"],["p","eda96cb93aecdd61ade0c1f9d2bfdf95a7e76cf1ca89820c38e6e4cea55c0c05"]],"content":"","sig":"5128ec83bd15ea260f6597b2e1d77125e297c8fef26977fe952b7e9d04e06d8ceb17d5cf94d5e7fb58e475c6afb8ff2880ec63b405bf868f826b425dde12fbe1"}
+{"kind":39089,"id":"7426682b30d03cf8a40bcd86fe074e2f79c1b81c3b6c335ef56a3ba9d1035d16","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1750721921,"tags":[["title","Health"],["d","7i53tdhuhhex"],["image","https://images.unsplash.com/photo-1649134296132-56606326c566?q=80&w=1332&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","6b4ec98f02e647e01440b473bbd92a7fae21e01b6aa6c65e32db94a36092272e"],["description","Profiles that talk about health topics."],["t","health"]],"content":"","sig":"ff9f6f7d3c9a00fe89baef11559ca2ad9b5247e87129c0d6631c75bf3e10a12e9778f1ef5d2ae951aca504c7ac6f08a250868aae978ad5fae235e593af0c98d7"}
+{"kind":39089,"id":"88d94f81af4eb6b5a8c70dcdb54124cf7d177dddd6408095d30e301f41024f9b","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1750721164,"tags":[["title","Sports"],["d","i6z5fdlgm0mc"],["image","https://images.unsplash.com/photo-1612872087720-bb876e2e67d1?q=80&w=1307&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84"],["description","Profiles that talk or post about Sports."],["t","sports"]],"content":"","sig":"6b2d43dea6e50c04cd4bf1e5992e277c1719d1206125bbe045d3396543c3642daa8513d3a44eca5ed6f9a3bd536c9425ab034aaa29a0aa3e096aba93ad83071c"}
+{"kind":39089,"id":"97d3211d499773c7b67a79f48a0bda207bcc09239af069285b256f247815f774","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1749695841,"tags":[["title","Christianity"],["d","fa5b4qnthgtw"],["image","https://images.unsplash.com/photo-1646281579942-ef27ddbf729f?q=80&w=3270&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","ffca54e078fd9884a745d70eb0159c0b8a9e7d383a4906a7484b83ebf6101dd5"],["p","d1b118c61b0fcaac3d9e8beafa8bb9a649cce921756a8fd7c21ca97b4985b38d"],["p","8d34bd2432240c5637174a3db191878baa1c133aec739b64a264259f414be32b"],["p","6e80246f10cc91b261e46d376bd3d64409b79e0c9bb3c2a0ecba612c321f6c4f"],["p","31ff694f01417160459e59e2e283b5454b499d3885092c792ae0d6b2de7839f0"],["description","Profiles that actively post about Christian topics."],["t","religion-spirituality"]],"content":"","sig":"f02e1d37fbb0f18f4b5c5da7d6a5fdfca4506d81232acb1032ef9aab5397641ad645296ed358729c968f0260cafd628fffd7efee60d91694849834b6b5279d86"}
+{"kind":39089,"id":"7b18fc4ca212dd7d9afb025d08fe9562ff9ac32c6daffdeda3a15196cd1e158e","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1749691990,"tags":[["title","Music"],["d","w6d8d4247nr8"],["image","https://images.unsplash.com/photo-1511379938547-c1f69419868d?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","ca9d68eb25620fc755e1b8c76b5f155f4c7e96d99c532c109a8b36d208bdce55"],["p","23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e"],["p","8806372af51515bf4aef807291b96487ea1826c966a5596bca86697b5d8b23bc"],["p","b9d02cb8fddeb191701ec0648e37ed1f6afba263e0060fc06099a62851d25e04"],["p","eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e"],["p","937ce652c92cb8bb7a8a3715d62c6e9f71dc66fd9164193e6c3cb962fdc07c94"],["description","Profiles of musicians or profiles that talk about music."],["t","music"]],"content":"","sig":"04f03a368481a1c3716fe426ac920a6977f4717f68073c0114ba16b1a76e30e7c3b99a97f8a7dee666317b077d30aaf2a6206223d6a3d65eb6d8377cf38a124d"}
+{"kind":39089,"id":"9927b76f56750aeb3f1ef454720546a0e22c0f9cfcce346a6928fd7844d728f9","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1749256086,"tags":[["title","Human rights"],["d","vhsaqseo9sbx"],["image","https://images.unsplash.com/photo-1629753908080-e8551ac57b8d?q=80&w=1475&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240"],["p","6a359852238dc902aed19fbbf6a055f9abf21c1ca8915d1c4e27f50df2f290d9"],["p","f1989a96d75aa386b4c871543626cbb362c03248b220dc9ae53d7cefbcaaf2c1"],["p","500ccc532c301711d88aa6d30b11dc477e6a32770853f8ab1c2be389b824e3f8"],["p","58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196"],["p","33bd77e5394520747faae1394a4af5fa47f404389676375b6dc7be865ed81452"],["p","6e1010d8e5b953f9a52314d97bc94c597af26d51bae88b3fdf2c8fbd7e962d01"],["description","Human rights activists, or profiles that talk about human rights."],["t","politics"]],"content":"","sig":"7d2bf4423be8cab7d21138c172c9fc409e011f6c148e86ceed500f51edcf6d10530950385486d28f8b42eee0eb3dbb3b4b502d0e9d4adfe3f24fff99245074b3"}
+{"kind":39089,"id":"dc17bccfe044d3350768f3c58632aaacee025eaf1e0b17717cbb51461446f377","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748291531,"tags":[["title","Philosophy"],["d","2dnchq9fxd5r"],["image","https://images.unsplash.com/photo-1620662736427-b8a198f52a4d?q=80&w=2971&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","0ffab7d9247132f14bcd38378b0acedc30d35e2b2670d89b7ae8c2d2c306af99"],["p","580f511af0a79d2cca20fb5bf6bb89abe6988593f8568177d2a513e87b2bbef3"],["t","humanities"]],"content":"","sig":"8cc9baf5beac297e575fb6db72785792cf43ac1086460f82deb4e7bc81c232ed0887e061de8bdc05389d6b26ded882036c9a7c120842548a192805c543ef4814"}
+{"kind":39089,"id":"a77a31ee6fbab6ba3067ce72ba3a5f376b90f8a98f50635179010d98e158095c","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287979,"tags":[["title","Farmers (farmstr)"],["d","cioc58duuftq"],["image","https://images.unsplash.com/photo-1589923188900-85dae523342b?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","d70d50091504b992d1838822af245d5f6b3a16b82d917acb7924cef61ed4acee"],["p","4d023ce9dfd75a7f3075b8e8e084008be17a1f750c63b5de721e6ef883adc765"],["p","8d9d2b77930ee54ec3e46faf774ddd041dbb4e4aa35ad47c025884a286dd65fa"],["p","c0e0c4272134d92da8651650c10ca612b710a670d5e043488f27e073a1f63a16"],["p","5e7e69f863d20ca30103df88887194e091679491fefe5eb8212753013cc3d552"],["p","a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be"],["p","f27a4813f31d8faf80377ef15d4a397e3640b5ca1dffba9ce9dca91d3362c091"],["p","715c279295b65454c464b78503d6154ad7697ba82460c56d8a42eb5da11e6c4b"],["p","ab11aa702482080cfead6e35fb6fbad454a3e792dd4d75f183f52f5fea2a2fa9"],["p","6a02b7d5d5c1ceec3d0ad28dd71c4cfeebb6397b95fef5cd5032c9223a13d02a"],["p","e7211c227437dcb5582712b5b1bbfb2a23b27654649ddc7c1fb5a7dde87afeee"],["p","7b8fcd9153d2c47146155df24b7f167924c1fc16fb4e843d98f8b5f3add6bd85"],["p","0f4011159c24a6a53eeea72e1c2e97e0425be8af15e7de397003dd65d9e8d278"],["p","58d2be9f20537ad7074b0283287611507ef7bd313c5edb64def9e1fc5df26d11"],["p","d020fe6e22b3aca9f578172a135e25d7b692e67b52e97fdce55431127d7626af"],["p","7644119b18ec56b5b2779e0d035e7712a9d669dbfbe5b2c5f458cb564afb6c95"],["description","Profiles of farmers or profiles that talk about farming"],["t","food"]],"content":"","sig":"d6bbdbc1d84bf6f85e462f1abbe7dfc5d06218091ab0be00cd0ba440d0835eb26f81e8d0b2aea3e4bd3a5ab3638be0ce27c4072df460e99c5fe9dcc59113a153"}
+{"kind":39089,"id":"fb51210ce13077cecd890a053f611fa8a258e8898e8b7b3116b641dbf1330959","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287889,"tags":[["title","Lifestyle"],["d","rptxdnrphqsr"],["image","https://images.unsplash.com/photo-1493225255756-d9584f8606e9?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","2652f3af10de63bc10a2628871a3fce62e08655e4fcf90a58be16f246bb65da3"],["p","c7c8f645fd45b09055fb6c26d148737ad7ed12ddecde0d4c877b88f8d4196865"],["p","e451c4e82e272ef912a04f490373fce9054a044b11a2aa847823b3686c8eba77"],["p","22a193fb86c793d7f73348126a5fe55ab8a4c975805ebc2ff81bf290f7805f7e"],["p","805b34f708837dfb3e7f05815ac5760564628b58d5a0ce839ccbb6ef3620fac3"],["description","Profiles that talk about different lifestyles."],["t","lifestyle"]],"content":"","sig":"11d70b315a7084ad26f31e3821ff295962654c2d41526f7e8491e18dcd7ed5a578acade9c47b0456c76b12e74653147bef069dafd1b05278506d63d7a8cb2316"}
+{"kind":39089,"id":"d1c8d4b8684b7e1eda6830e6dde96b2ea548ce4f717ff5cf528a00a2206992e7","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287802,"tags":[["title","Art & Photography"],["d","9gnjzbkd59lp"],["image","https://images.unsplash.com/photo-1579541592065-da8a15e49bc7?q=80&w=3201&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e"],["p","f96c3d76497074c4c83a7b3823380e77dc73d5a9494fd2e053e4a1453e17824b"],["p","11b2d93b26d7e56fb57f0afce0d33bfa7fb35b913e4c0aeb7706464befb9ca97"],["p","f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0"],["p","af146f51634a5fb0abc592fbc2bed42cf740700990344a766a8999fe55eed1c6"],["p","8816e8938fe60a3a925433c77410f39500fe1320787acb2165f9345e70464592"],["p","8d0638d3e8d9b23e337fb4017bb7d5a97def40bdc258e8121ee7d51e623ca065"],["p","ece8ed2111f73b92251e74a3da22dee1d6c6d9b497040acc68a37ac6bbf5a105"],["p","64bfa9abffe5b18d0731eed57b38173adc2ba89bf87c168da90517f021e722b5"],["p","546bffcb9317edf919828de272dd8eba11f2aadc07969cba6c9bb894c07712ef"],["p","20998a8d433181cc5e778903db528d162f5579918a3437be40fdba0130b3469c"],["p","37c962bfb34d32e667d5075f7d55f3a7ca9bcddbafeff59a4003c276ad147352"],["p","87deef9034c52d87ae327af3a76358e58e7ee2e03d80c738ee1a2be399c23e79"],["p","55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185"],["description","A list of profiles related to art and photography"],["t","art"]],"content":"","sig":"a6409e0172380d30d36446f46ff2ccc97cb01cd3cdff612991b8e759a103a8243928579efdcea9f5e0c44ce2785c240218928fec26cdd6b2b0bafb63a8c0790a"}
+{"kind":39089,"id":"f650750b46c7f97d9f4f85ee1f1c704015f3ca35b129826ac370ad47faa016a4","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287746,"tags":[["title","Technology companies"],["d","yogtlbnbuw39"],["image","https://images.unsplash.com/photo-1531297484001-80022131f5a1?q=80&w=3220&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","d7df8b3e14166796a8ad8740b06f427aea9dd95b72e0276aa9179210e27f81f7"],["p","6b4a29bbd43d1d0eeead384f512dbb591ce9407d27dba48ad54b00d9d2e1972b"],["p","34104dedf3cc5936802c8308a3d0090f2857d4aba4e8b720accaf6b2ab049969"],["p","ebdee92945ef05283be0ac3de25787c81a6a58a10f568f9c6b61d9dd513adbad"],["p","37cdc3e7f5a7147752cc6cb348bd77e1b999e3e43a4b21b740812193ba81c298"],["p","de7aa63b1c7e809f66a67bdcd2bd4c952a09cd52ee01ed7db358d09bad97f840"],["p","c7617e84337c611c7d5f941b35b1ec51f2ae6e9f41aac9616092d510e1c295e0"],["p","9be21611a341426e9146257c54179e22d178bb7d4106e247ddf3e507b7985a6b"],["description","A list of technology companies on Nostr"],["t","technology"]],"content":"","sig":"d1e9adeb5f9850ecffc3a524e59dbfa6c49913ac77b3653852f46908ae6e6290d7e58fc4c74717c45ee9a2fd4b6421d8c5885b8e198a6aaf14db39e461902d68"}
+{"kind":39089,"id":"7e734701c3a09c63e9c68a4c6762c7dd122220d2d325802e40d1f8c983733b42","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287584,"tags":[["title","Technology news"],["d","mbj6yxabm94c"],["image","https://images.unsplash.com/photo-1726568313407-c7d9c8a8ce88?q=80&w=2971&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","9c5d0fe246b80bc313082aa3dbb4f82744751ca8d79901dc74f7eb69bba8ad4b"],["p","8451100685869f1fa9b122300167359f9a0459fcf517f2fb1b1dd22319a38815"],["p","de5ba539eb564946ae0e3f5abbf3599e8eeed8efd40875c6320405a0c17d976e"],["t","technology"]],"content":"","sig":"b92854e27172dfb0225308669a1d8c422f82258736e2403a89dc104e0d3a97ac4434d482402f37ecb16e57f65cd7ced7dccf978f1da0065d6f0bcd55782829bc"}
+{"kind":39089,"id":"9143d35516660826a8c1a4a2440fa124c395b144bd172d39ea647d0222aaaeb3","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287486,"tags":[["title","Bitcoin builders"],["d","2iofns19fsiv"],["image","https://images.unsplash.com/photo-1564241832756-58a1d4055663?q=80&w=2969&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8"],["p","91c9a5e1a9744114c6fe2d61ae4de82629eaaa0fb52f48288093c7e7e036f832"],["p","25a2192dcf34c3be326988b5c9f942aa96789899d15b59412602854a8723e9e8"],["p","11b9a89404dbf3034e7e1886ba9dc4c6d376f239a118271bd2ec567a889850ce"],["p","b7b51cc25216d4c10bc85ae27055c9a945fe77cafd463cf23b20917e39ce6816"],["p","e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411"],["p","5a8e581f16a012e24d2a640152ad562058cb065e1df28e907c1bfa82c150c8ba"],["description","People who builds products and services around Bitcoin.\n\n(Not an exhaustive list, work in progress)"],["t","bitcoin"]],"content":"","sig":"192f65e8392742a01be41b60aef05da4026a1fd22229bde468a6123890f680d2f7765fa8b21c41f75945b676053e91eabfe2529bb289a7dda80789a0e6486d20"}
+{"kind":39089,"id":"0ed119d7e0828ac23e7a0d735498be9054da91d21e7e268c25633ad747a949c1","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287175,"tags":[["title","Cars"],["d","dgj9fi1mk83t"],["image","https://images.unsplash.com/photo-1526726538690-5cbf956ae2fd?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","98adf137d9da1b1b3654f29ede930f27c95847c9719bf4b38e1cef33c21f7e38"],["description","A list of profiles that talk about cars."],["t","other"]],"content":"","sig":"47e6b974e57b6344e47269cc2ac1c800332a2c1f949c9cd1522b90580ece31d49409a02fa9cdac64a5cfe660c221a1c50b9eeaa5204a44c24d5d6c5bd5ade506"}
+{"kind":39089,"id":"37d183d3f069167e4df7294c842086d61abee8ca8f7d79a402eeb0d2dca34268","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287050,"tags":[["title","Science"],["d","ypfr6bg55nrw"],["image","https://images.unsplash.com/photo-1606125784258-570fc63c22c1?q=80&w=3161&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","c582af78dff442700ec59e21786532a7074c00be8b7b1eac989bbf62698069cc"],["p","e85ed75286cb77475776c1007df8c4ff1c9c68eff91c3627347b065c5bf4dc78"],["t","science"]],"content":"","sig":"3649115023c3c62432742d209f3a4791b0d38d784a147a967d81288add8ab127197a6207de82b5cb53d4618cd1c92b0d190b3c7a9f5276535853f0a83fc485d3"}
+{"kind":39089,"id":"bf1546f39fc0d3dac03ab77b0ce9d62d71707114cfe3f88c75fb13227c0b02ad","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748286976,"tags":[["title","Nostr builders"],["d","68qs4i0xgex8"],["image","https://images.unsplash.com/photo-1588508107117-227d4ab6b751?q=80&w=3228&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],["p","97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"],["p","d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a"],["p","3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],["p","a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be"],["p","17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4"],["p","8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"],["p","4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"],["p","1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef"],["p","8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883"],["p","1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411"],["p","2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1"],["p","520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626"],["p","e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb"],["p","850605096dbfb50b929e38a6c26c3d56c425325c85e05de29b759bc0e5d6cebc"],["p","0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"],["p","d36e8083fa7b36daee646cb8b3f99feaa3d89e5a396508741f003e21ac0b6bec"],["p","0d6c8388dcb049b8dd4fc8d3d8c3bb93de3da90ba828e4f09c8ad0f346488a33"],["p","460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["p","bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91"],["p","e7424ad457e512fdf4764a56bf6d428a06a13a1006af1fb8e0fe32f6d03265c7"],["p","68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272"],["p","50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63"],["p","76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa"],["description","People who build products and services on Nostr. (Not an exhaustive list)"],["t","technology"]],"content":"","sig":"566d88b6ecbfa00f51789bc660c92abb4c9d6d592a3f2022531c0615444ad773aecf6d1781177b19dd2f26fad94e4ab077affcad86b60fda8f5f7658545a2ae6"}
+{"kind":39089,"id":"700646832f4d70ce896d9ebd833c6c7552a91b2acaaf469c5ac35c59c9d8801b","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748286876,"tags":[["title","Travel"],["d","2xhos7ml5pcp"],["image","https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=2973&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","7d33ba57d8a6e8869a1f1d5215254597594ac0dbfeb01b690def8c461b82db35"],["description","Profiles that talk about travel."],["t","travel"]],"content":"","sig":"b62a5df0295cac59d2616a3786a72ba47052a540b7185b2b93f77d26dbb4fad171cf145ea445d15b150e7642260646c5850ea4482330cd5dc5038d0ef26954de"}
+{"kind":39089,"id":"03012789fd1d247142192bf6f8999ee848ba83edf6b7f4919b4e3690e75889b6","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1747845497,"tags":[["title","Human Architecture, Local Vernacular, and Craftsmanship"],["d","y156932o9xfh"],["image","https://c4.wallpaperflare.com/wallpaper/82/123/783/architecture-building-bulgaria-village-wallpaper-preview.jpg"],["p","5db2be23cde61dd0a69e667a021a943fa38760104fe4160ce13b1a097e9fe447"],["p","7a8e476bd97e1a9c348b2f9e1c8c9d1f371e2fda001dae82e44d336d4ca2f7ec"],["p","16f1a0100d4cfffbcc4230e8e0e4290cc5849c1adc64d6653fda07c031b1074b"],["p","462eb31a6b3de5727407e796d984be2c631cb4bfa854f8a1a2b092dcc6d7bbe1"],["p","e9986a10caaa96738ceda88aabd3e184307be5143e687457581f9b096c6ef89c"],["p","704c4773626bf0f7ebf99d861eb0eae473be2b004f91725f8a1750486c9c848f"],["p","41261aca9c043397d53c1d09d2d62926e8ab230ec8f7516c258c81c6844169c3"],["p","94f57887daad1a4b952bd755539f239922cd614a1b1ba0e623ea8361a4ca2a65"],["description","Aesthetic architecture, craftsmanship, and localism"],["t","art"]],"content":"","sig":"2a987dcbc86d66166599c55221a0bfb39a42fc83cd5db5f79e7aa6ddbf0b3777cfd39e1d97d36572084eb34ae30d58e10fab4e50de29a8798ef21a64e9b90e84"}
+{"kind":39089,"id":"7e847eafeaf8ec7dec435b7a8f2eda41b645653ea2414297fbf4b9788d77b582","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1747767150,"tags":[["title","Linux Enjoyers"],["d","unjue0fdg0ef"],["image","https://image.nostr.build/2e730d4041764c32c40f33dd1e5ae15158f4463ed1bf09f217715b2735f667f1.jpg"],["p","52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd"],["p","edb470271297ac5a61f277f3cd14de54c67eb5ccd20ef0d9df29be18685bb004"],["p","fd5989ddfadd9e2af6ceb8b63942a9e31b37367e89917931ede3b2ea76823f10"],["p","139137969c1c56bf8306cd71272f681ed54206be15eeceb9eb98956d2fd9f7ef"],["p","f4d89779148ccd245c8d50914a284fd62d97cb0fb68b797a70f24a172b522db9"],["p","70122128273bdc07af9be7725fa5c4bc0fc146866bec38d44360dc4bc6cc18b9"],["p","0463223adf38df9a22a7fb07999a638fdd42d8437573e0bf19c43e013b14d673"],["p","480ec1a7516406090dc042ddf67780ef30f26f3a864e83b417c053a5a611c838"],["p","93332f1e437be05aa1a4c2cbb694cafc9721be57cca98aef7ec6641895ae4735"],["p","259b1ddc0d3137cad123d4af64f87ff9db3834a843843963e58e2a9ac956cf39"],["p","75656740209960c74fe373e6943f8a21ab896889d8691276a60f86aadbc8f92a"],["p","bd4ae3e67e29964d494172261dc45395c89f6bd2e774642e366127171dfb81f5"],["p","76e36f2fabfbd8c353ffcda8fe07877a0977ee4aa9d9bcc224f02038d73b3787"],["p","75da94027ad408bc2faffeb1e67d71babe8d78d89c3620da212303b877a65b5c"],["p","a793448d75b909e9597252cc5b133b24f64012d02fe041009f70cba16bf72864"],["p","c7eda660a6bc8270530e82b4a7712acdea2e31dc0a56f8dc955ac009efd97c86"],["p","1c197b12ca9ce0415b70e7405b9770f0ec6bccfb59b32b63aafd42cb242e1642"],["p","74fb3ef27cd8985d7fefc6e94d178290275f5492557b4a166ab9cd1458adabc7"],["p","d9a329afabb263a8af216838eb45e1f04cc3000704464947c4b907e1bef580d7"],["p","b203872cefbd5572b5551dde5bdfc0387219d99fb7e8d1c8418145d432ca0390"],["p","566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8"],["p","fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1"],["p","97a403640c83ac12bce556ded8db2f3ebe891801832fa1114abda73a6ae8598c"],["p","40b9c85fffeafc1cadf8c30a4e5c88660ff6e4971a0dc723d5ab674b5e61b451"],["p","a5ee447568a2372226d647013a55251eac7dd37ab40804a5121a63ce2ca75401"],["p","abd277d90caba20aee0f1f05a68d24cd117badc1205d0d1b1a0451357d32b92f"],["p","b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc"],["p","ce9cfafcdc8425dc90c7045f263f966d077dbde96940b7fab4c9403998c5b49b"],["t","technology"]],"content":"","sig":"271c27e39c2cb24977a1f04e7df0c234b0fd35624abbbb0df35cf5ae6ae4e4b40f00acbcb2fd964edc40167fd0b45598d3879c01c0645938c1686f103285cb0f"}
diff --git a/damus/Views/Onboarding/suggested_users.json b/damus/Views/Onboarding/suggested_users.json
@@ -1,79 +0,0 @@
-[
- {
- "category": "suggested_users_nostr",
- "users": [
- "ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a",
- "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
- "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
- "b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e"
- ]
- },
- {
- "category": "suggested_users_permaculture_livestock_gardening",
- "users": [
- "4b1804c90d59dff195ee0e8f692b98a7c762bf1793b3e126c546d730dcb04477",
- "2c24e1af571fb4ccfeca3981649c1b09c695cd83b129709eb3b41c7ad2854899",
- "296842eaaed9be5ae0668da09fe48aac0521c4af859ad547d93145e5ac34c17e"
- ]
- },
- {
- "category": "suggested_users_music",
- "users": [
- "23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e",
- "ca9d68eb25620fc755e1b8c76b5f155f4c7e96d99c532c109a8b36d208bdce55"
- ]
- },
- {
- "category": "suggested_users_books",
- "users": [
- "2652f3af10de63bc10a2628871a3fce62e08655e4fcf90a58be16f246bb65da3",
- "b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"
- ]
- },
- {
- "category": "suggested_users_art_photography",
- "users": [
- "f96c3d76497074c4c83a7b3823380e77dc73d5a9494fd2e053e4a1453e17824b",
- "11b2d93b26d7e56fb57f0afce0d33bfa7fb35b913e4c0aeb7706464befb9ca97",
- "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0",
- "af146f51634a5fb0abc592fbc2bed42cf740700990344a766a8999fe55eed1c6",
- "8816e8938fe60a3a925433c77410f39500fe1320787acb2165f9345e70464592",
- "8d0638d3e8d9b23e337fb4017bb7d5a97def40bdc258e8121ee7d51e623ca065",
- "ece8ed2111f73b92251e74a3da22dee1d6c6d9b497040acc68a37ac6bbf5a105",
- "64bfa9abffe5b18d0731eed57b38173adc2ba89bf87c168da90517f021e722b5",
- "546bffcb9317edf919828de272dd8eba11f2aadc07969cba6c9bb894c07712ef",
- "20998a8d433181cc5e778903db528d162f5579918a3437be40fdba0130b3469c",
- "37c962bfb34d32e667d5075f7d55f3a7ca9bcddbafeff59a4003c276ad147352",
- "387fa328198e67a400caf00947cc91e0c166d24d71d8b4b78a6c3ef91ff4d058",
- "87deef9034c52d87ae327af3a76358e58e7ee2e03d80c738ee1a2be399c23e79"
- ]
- },
- {
- "category": "suggested_users_ai_art",
- "users": [
- "431fa2f340f0adf8963d6d7c6e2c20d913278c691fe609fd3857db13d8f39feb",
- "9936a53def39d712f886ac7e2ed509ce223b534834dd29d95caba9f6bc01ef35",
- "1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b",
- "693c2832de939b4af8ccd842b17f05df2edd551e59989d3c4ef9a44957b2f1fb",
- "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185"
- ]
- },
- {
- "category": "suggested_users_parenting",
- "users": [
- "c7c8f645fd45b09055fb6c26d148737ad7ed12ddecde0d4c877b88f8d4196865",
- "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
- "e451c4e82e272ef912a04f490373fce9054a044b11a2aa847823b3686c8eba77",
- "261c7e18545aee4eb55e8052297fd93bd886c2f96b10d599cfdad4f3477b87ab",
- "22a193fb86c793d7f73348126a5fe55ab8a4c975805ebc2ff81bf290f7805f7e"
- ]
- },
- {
- "category": "suggested_users_food",
- "users": [
- "cbb2f023b6aa09626d51d2f4ea99fa9138ea80ec7d5ffdce9feef8dcd6352031"
- ]
- }
-]
-
-
diff --git a/devtools/follow_pack_map.yaml b/devtools/follow_pack_map.yaml
@@ -0,0 +1,15 @@
+# Farmers (farmstr)
+- "39089:17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4:cioc58duuftq": ["food", "lifestyle"]
+# Human Architecture, Local Vernacular, and Craftsmanship
+- "39089:17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4:y156932o9xfh": ["art"]
+# Linux Enjoyers
+- "39089:17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4:unjue0fdg0ef": ["technology"]
+# Technology companies
+- "39089:895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba:yogtlbnbuw39": ["technology"]
+# Art & Photography
+- "39089:895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba:9gnjzbkd59lp": ["art"]
+# Bitcoin
+- "39089:895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba:hzgji33wnyku": ["bitcoin"]
+# Lifestyle
+- "39089:895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba:rptxdnrphqsr": ["lifestyle"]
+
diff --git a/devtools/tag_follow_packs.py b/devtools/tag_follow_packs.py
@@ -0,0 +1,368 @@
+#!/usr/bin/env python3
+"""
+Nostr Event Updater
+
+This script fetches Nostr events based on a YAML mapping file, updates them with
+'tags' based on the mapping data, and signs them with a specified private key.
+Optionally can publish the updated events to a relay.
+
+Example YAML mapping file format:
+```
+# mapping.yaml
+"39089:17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4:cioc58duuftq": ["farmers", "agriculture"]
+"1:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789:someid": "technology"
+```
+
+Each key is in the format "kind:pubkey:d-value" and the value is either a single tag string
+or a list of tag strings to add.
+"""
+import sys
+import json
+import argparse
+import yaml
+import subprocess
+import time
+import os
+from typing import Dict, List, Optional, Tuple, Any, Union
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(
+ description="Fetch Nostr events, update them with tags 't' based on a mapping, and sign them with a private key.",
+ epilog="""
+Examples:
+ # Fetch events, update tags, and print to stdout
+ ./update_jsonl.py mapping.yaml nsec1...
+
+ # Fetch events, update tags, and publish to a relay
+ ./update_jsonl.py mapping.yaml nsec1... --publish --relay wss://relay.example.com
+
+ # Fetch events, update tags, save to file, and update timestamps
+ ./update_jsonl.py mapping.yaml nsec1... --output updated_events.jsonl --update-timestamp
+"""
+ )
+ parser.add_argument(
+ "map_yaml_file",
+ help="Path to the YAML file containing the mapping in format 'kind:pubkey:d-value': [tags]"
+ )
+ parser.add_argument(
+ "private_key",
+ help="Private key (hex or nsec format) for signing the updated events."
+ )
+ parser.add_argument(
+ "--relay",
+ default="wss://relay.damus.io",
+ help="Relay URL to fetch events from and optionally publish to. (default: wss://relay.damus.io)"
+ )
+ parser.add_argument(
+ "--output",
+ default=None,
+ help="Output file path to save updated events. If not provided, print to stdout."
+ )
+ parser.add_argument(
+ "--publish",
+ action="store_true",
+ help="Publish updated events to the specified relay."
+ )
+ parser.add_argument(
+ "--update-timestamp",
+ action="store_true",
+ help="Update event timestamps to current time instead of preserving original timestamps."
+ )
+ return parser.parse_args()
+
+
+def split_coordinate(coordinate: str) -> Tuple[int, str, str]:
+ """Split a coordinate string into kind, pubkey, and d-tag value."""
+ parts = coordinate.split(":")
+ if len(parts) != 3:
+ raise ValueError(f"Invalid coordinate format: {coordinate}")
+ kind = int(parts[0])
+ pubkey = parts[1]
+ d_value = parts[2]
+ return kind, pubkey, d_value
+
+
+def fetch_event(kind: int, pubkey: str, d_value: str, relay: str) -> Optional[Dict]:
+ """Fetch an event from the Nostr network using nak CLI.
+
+ Args:
+ kind: The event kind to fetch
+ pubkey: The author's public key
+ d_value: The d-tag value to match
+ relay: The relay URL to fetch from
+
+ Returns:
+ The event as a dictionary, or None if not found or error
+ """
+ try:
+ # Check if nak CLI is available
+ try:
+ subprocess.run(["nak", "--version"], capture_output=True, check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ sys.stderr.write("Error: 'nak' CLI tool is not available or not in PATH.\n")
+ sys.stderr.write("Please install it from https://github.com/fiatjaf/nak\n")
+ sys.exit(1)
+
+ # Prepare the request command
+ cmd = [
+ "nak", "req",
+ "--kind", str(kind),
+ "--author", pubkey,
+ "-d", d_value,
+ relay
+ ]
+
+ sys.stderr.write(f"Fetching event: kind={kind}, author={pubkey}, d={d_value} from {relay}...\n")
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
+
+ if not result.stdout.strip():
+ sys.stderr.write(f"No event found for kind={kind}, pubkey={pubkey}, d={d_value}\n")
+ return None
+
+ event_data = json.loads(result.stdout.strip())
+ sys.stderr.write(f"Successfully fetched event with ID: {event_data.get('id', 'unknown')}\n")
+ return event_data
+
+ except subprocess.CalledProcessError as e:
+ sys.stderr.write(f"Error fetching event: {e}\n")
+ sys.stderr.write(f"stderr: {e.stderr}\n")
+ return None
+ except json.JSONDecodeError as e:
+ sys.stderr.write(f"Invalid JSON response: {e}\n")
+ sys.stderr.write(f"Response: {result.stdout}\n")
+ return None
+ except Exception as e:
+ sys.stderr.write(f"Unexpected error fetching event: {e}\n")
+ return None
+
+
+def get_d_tag(tags: List[List[str]]) -> Optional[str]:
+ """Find the d-tag value in the event tags."""
+ for tag in tags:
+ if tag and len(tag) > 1 and tag[0] == "d":
+ return tag[1]
+ return None
+
+
+def update_event_tags(event: Dict, tag_values: List[str]) -> Dict:
+ """Update the event tags with new t-tags."""
+ if "tags" not in event:
+ event["tags"] = []
+
+ # Remove existing t-tags to avoid duplicates
+ event["tags"] = [tag for tag in event["tags"] if not (tag and tag[0] == "t")]
+
+ # Add new t-tags
+ for val in tag_values:
+ event["tags"].append(["t", val])
+
+ return event
+
+
+def sign_and_publish_event(event: Dict, private_key: str, relay: str = None) -> Dict:
+ """Sign the event with the provided private key using nak and optionally publish it.
+
+ Args:
+ event: The event to sign
+ private_key: The private key (hex or nsec format) for signing
+ relay: Optional relay URL to publish to
+
+ Returns:
+ The signed event as a dictionary
+
+ Raises:
+ SystemExit: If signing or publishing fails
+ """
+ # Preserve the original event's structure, but remove fields that will be regenerated
+ # (id, sig, pubkey) as they'll be replaced by the signing process
+ signing_event = {
+ "kind": event["kind"],
+ "created_at": event["created_at"], # Preserve original timestamp
+ "content": event["content"],
+ "tags": event["tags"],
+ }
+
+ try:
+ # Set up nak event command with private key
+ cmd = ["nak", "event", "--sec", private_key]
+
+ # Add relay if publishing is requested
+ if relay:
+ cmd.append(relay)
+
+ event_json = json.dumps(signing_event)
+
+ sys.stderr.write(f"Signing event of kind {event['kind']}...\n")
+ result = subprocess.run(
+ cmd,
+ input=event_json,
+ capture_output=True,
+ text=True,
+ check=True
+ )
+
+ signed_event = json.loads(result.stdout.strip())
+
+ if relay:
+ sys.stderr.write(f"Published event to {relay}: {signed_event['id']}\n")
+ else:
+ sys.stderr.write(f"Event signed with ID: {signed_event['id']}\n")
+
+ return signed_event
+ except subprocess.CalledProcessError as e:
+ sys.stderr.write(f"Error signing/publishing event: {e}\n")
+ sys.stderr.write(f"stderr: {e.stderr}\n")
+ sys.exit(1)
+ except json.JSONDecodeError as e:
+ sys.stderr.write(f"Invalid JSON in signed event: {e}\n")
+ sys.stderr.write(f"Response: {result.stdout}\n")
+ sys.exit(1)
+ except Exception as e:
+ sys.stderr.write(f"Unexpected error during signing/publishing: {e}\n")
+ sys.exit(1)
+
+
+def validate_private_key(private_key: str) -> bool:
+ """Validate that the provided private key is in a valid format.
+
+ Args:
+ private_key: The private key string to validate
+
+ Returns:
+ True if the key format appears valid, False otherwise
+ """
+ # Check for nsec format
+ if private_key.startswith("nsec1"):
+ return len(private_key) >= 60 # Approx length for nsec keys
+
+ # Check for hex format
+ if all(c in "0123456789abcdefABCDEF" for c in private_key):
+ return len(private_key) == 64
+
+ return False
+
+
+def main():
+ args = parse_args()
+
+ # Validate the private key format
+ if not validate_private_key(args.private_key):
+ sys.stderr.write("Error: Invalid private key format. Must be hex (64 chars) or nsec1 format.\n")
+ sys.exit(1)
+
+ # Check if the mapping file exists
+ if not os.path.isfile(args.map_yaml_file):
+ sys.stderr.write(f"Error: Mapping file '{args.map_yaml_file}' does not exist or is not accessible.\n")
+ sys.exit(1)
+
+ # Load the mapping from the provided YAML file
+ try:
+ with open(args.map_yaml_file, "r") as mf:
+ mapping = yaml.safe_load(mf)
+ if mapping is None:
+ sys.stderr.write(f"Error: Mapping file '{args.map_yaml_file}' is empty or invalid.\n")
+ sys.exit(1)
+ except yaml.YAMLError as e:
+ sys.stderr.write(f"Error parsing YAML file: {e}\n")
+ sys.exit(1)
+ except Exception as e:
+ sys.stderr.write(f"Error loading mapping file: {e}\n")
+ sys.exit(1)
+
+ # If the mapping is a list, convert it to a dictionary
+ if isinstance(mapping, list):
+ new_mapping = {}
+ for item in mapping:
+ if isinstance(item, dict):
+ new_mapping.update(item)
+ else:
+ sys.stderr.write(f"Unexpected item in mapping list: {item}\n")
+ mapping = new_mapping
+
+ # Make sure we have at least one mapping
+ if not mapping:
+ sys.stderr.write("Error: No valid mappings found in the YAML file.\n")
+ sys.exit(1)
+
+ # Prepare output file if specified
+ output_file = None
+ if args.output:
+ try:
+ output_file = open(args.output, "w")
+ sys.stderr.write(f"Writing output to '{args.output}'\n")
+ except Exception as e:
+ sys.stderr.write(f"Error opening output file: {e}\n")
+ sys.exit(1)
+
+ updated_events = []
+ total_events = len(mapping)
+
+ sys.stderr.write(f"Processing {total_events} events from mapping...\n")
+
+ # Process each coordinate in the mapping
+ for i, (coordinate, tag_values) in enumerate(mapping.items(), 1):
+ try:
+ sys.stderr.write(f"[{i}/{total_events}] Processing coordinate: {coordinate}\n")
+ kind, pubkey, d_value = split_coordinate(coordinate)
+
+ # Fetch the event
+ event = fetch_event(kind, pubkey, d_value, args.relay)
+ if not event:
+ sys.stderr.write(f"Skipping coordinate {coordinate}: Event not found\n")
+ continue
+
+ # Verify the event has the expected d-tag
+ event_d_tag = get_d_tag(event.get("tags", []))
+ if event_d_tag != d_value:
+ sys.stderr.write(f"Skipping coordinate {coordinate}: D-tag mismatch (expected={d_value}, found={event_d_tag})\n")
+ continue
+
+ # Update the event tags
+ if isinstance(tag_values, list):
+ updated_event = update_event_tags(event, tag_values)
+ sys.stderr.write(f"Added {len(tag_values)} t-tags: {', '.join(tag_values)}\n")
+ elif tag_values is not None:
+ updated_event = update_event_tags(event, [tag_values])
+ sys.stderr.write(f"Added t-tag: {tag_values}\n")
+ else:
+ sys.stderr.write(f"Skipping coordinate {coordinate}: No tag values\n")
+ continue
+
+ # Update timestamp if requested
+ if args.update_timestamp:
+ updated_event["created_at"] = int(time.time())
+ sys.stderr.write(f"Updated timestamp to current time: {updated_event['created_at']}\n")
+
+ # Sign the updated event and optionally publish it
+ signed_event = sign_and_publish_event(
+ updated_event,
+ args.private_key,
+ args.relay if args.publish else None
+ )
+
+ # Save or print the updated event
+ updated_events.append(signed_event)
+ if output_file:
+ output_file.write(json.dumps(signed_event) + "\n")
+ else:
+ print(json.dumps(signed_event))
+
+ except ValueError as e:
+ sys.stderr.write(f"Error processing coordinate {coordinate}: {e}\n")
+ continue
+ except Exception as e:
+ sys.stderr.write(f"Unexpected error processing coordinate {coordinate}: {e}\n")
+ continue
+
+ # Close output file if opened
+ if output_file:
+ output_file.close()
+
+ successful = len(updated_events)
+ failed = total_events - successful
+ sys.stderr.write(f"Summary: Successfully processed {successful} events, {failed} failed\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/shell.nix b/shell.nix
@@ -1,5 +1,5 @@
{ pkgs ? import <nixpkgs> {} }:
with pkgs;
mkShell {
- buildInputs = with python3Packages; [ Mako requests wabt todo-txt-cli ];
+ buildInputs = with python3Packages; [ Mako requests wabt todo-txt-cli pyyaml ];
}