damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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:
Mdamus.xcodeproj/project.pbxproj | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mdamus/ContentView.swift | 15++++++++++++++-
Adamus/DIP06/Interests.swift | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Models/FollowPackEvent.swift | 7++++++-
Mdamus/Models/HomeModel.swift | 2++
Mdamus/Models/UserSettingsStore.swift | 3+++
Adamus/NIP51/InterestList.swift | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Nostr/NostrKind.swift | 1+
Mdamus/Types/Ids/Pubkey.swift | 1-
Mdamus/Util/Constants.swift | 3+++
Mdamus/Views/LoadableNostrEventView.swift | 2+-
Adamus/Views/Onboarding/InterestSelectionView.swift | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Views/Onboarding/OnboardingContentSettings.swift | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/Onboarding/OnboardingSuggestionsView.swift | 154++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mdamus/Views/Onboarding/SuggestedUsersViewModel.swift | 265+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Adamus/Views/Onboarding/follow-packs.jsonl | 19+++++++++++++++++++
Ddamus/Views/Onboarding/suggested_users.json | 79-------------------------------------------------------------------------------
Adevtools/follow_pack_map.yaml | 15+++++++++++++++
Adevtools/tag_follow_packs.py | 368+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mshell.nix | 2+-
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 ]; }