Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
eddd908fa3
|
|||
| 3ddb2625e9 | |||
| f53ffae767 | |||
| b9168f9914 | |||
| 63ff2b6f9e | |||
| 7d9468388b | |||
|
66b555e0ff
|
|||
|
8df332472c
|
|||
|
6072668438
|
|||
|
6f26ddf7ac
|
|||
|
df156df6d9
|
|||
|
11c367b541
|
|||
|
4e1b23d1cb
|
|||
|
2de3083dad
|
|||
|
93149642db
|
|||
|
0b0d422b7a
|
|||
|
036ea50a3a
|
|||
| 073feccbbf | |||
| eeea9d3266 | |||
| b8bf5df7bc | |||
| e9e68422d4 | |||
| 6f9a00d728 | |||
| 51e07df1b5 | |||
| 2a42723b81 | |||
| 839ef6a80d | |||
| c073dd8fea | |||
| 8d9f728cf0 | |||
| 2c62741e25 | |||
| 1f612f7fde | |||
| 0e9e102d0f | |||
| b94e8765a1 | |||
| 53964f5c1a | |||
| bd574d93c3 | |||
| 47514ace79 | |||
| 298b43733f | |||
| 02116c0af5 | |||
| 92121e3b2d | |||
| c92094823e | |||
| f4b1a504a5 | |||
| 99ae7de5eb | |||
| b3d9ee3fc0 | |||
| e65219ee3e | |||
| 414c67a919 |
@@ -6,3 +6,4 @@ damus.xcodeproj/xcshareddata/xcbaselines
|
||||
TODO.bak
|
||||
tags
|
||||
build-git-hash.txt
|
||||
.build
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
[](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml)
|
||||
<div align="center">
|
||||
|
||||
# damus
|
||||
<img src="./damus/Assets.xcassets/damus-home.imageset/damus-home@2x.png" alt="Damus Logo" title="Damus logo" width=""/>
|
||||
|
||||
# Damus
|
||||
|
||||
The social network you control
|
||||
|
||||
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
|
||||
|
||||
<img src="./ss.png" width="50%" height="50%" />
|
||||
[](/LICENSE)
|
||||
|
||||
## Download and Install
|
||||
|
||||
[](https://apps.apple.com/us/app/damus/id1628663131)
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
iOS 16.0+ • macOS 13.0+
|
||||
|
||||
<img src="./demo1.png" width="70%" height="50%" />
|
||||
|
||||
</div>
|
||||
|
||||
[nostr]: https://github.com/fiatjaf/nostr
|
||||
|
||||
|
||||
@@ -427,11 +427,26 @@
|
||||
5C0567592C8FBDE30073F23A /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; };
|
||||
5C05675A2C8FBDE70073F23A /* NDBSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0567572C8FBC560073F23A /* NDBSearchView.swift */; };
|
||||
5C0707D12A1ECB38004E7B51 /* DamusLogoGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0707D02A1ECB38004E7B51 /* DamusLogoGradient.swift */; };
|
||||
5C09FD122DF283D700823661 /* FollowPackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C09FD112DF283D200823661 /* FollowPackModel.swift */; };
|
||||
5C09FD132DF283D700823661 /* FollowPackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C09FD112DF283D200823661 /* FollowPackModel.swift */; };
|
||||
5C09FD142DF283D700823661 /* FollowPackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C09FD112DF283D200823661 /* FollowPackModel.swift */; };
|
||||
5C14C29B2BBBA29C00079FD2 /* RelaySoftwareDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C14C29A2BBBA29C00079FD2 /* RelaySoftwareDetail.swift */; };
|
||||
5C14C29D2BBBA40B00079FD2 /* RelayAdminDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C14C29C2BBBA40B00079FD2 /* RelayAdminDetail.swift */; };
|
||||
5C14C29F2BBBA5C600079FD2 /* RelayNipList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C14C29E2BBBA5C600079FD2 /* RelayNipList.swift */; };
|
||||
5C42E78C29DB76D90086AAC1 /* EmptyUserSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */; };
|
||||
5C4D9EA72C042FA5005EA0F7 /* HighlightDraftContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4D9EA62C042FA5005EA0F7 /* HighlightDraftContentView.swift */; };
|
||||
5C4FA7EC2DC29AE900CE658C /* FollowPackEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7EB2DC29AE900CE658C /* FollowPackEvent.swift */; };
|
||||
5C4FA7ED2DC29AE900CE658C /* FollowPackEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7EB2DC29AE900CE658C /* FollowPackEvent.swift */; };
|
||||
5C4FA7EE2DC29AE900CE658C /* FollowPackEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7EB2DC29AE900CE658C /* FollowPackEvent.swift */; };
|
||||
5C4FA7FB2DC29C3800CE658C /* FollowPackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7F92DC29C3800CE658C /* FollowPackView.swift */; };
|
||||
5C4FA7FC2DC29C3800CE658C /* FollowPackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7F92DC29C3800CE658C /* FollowPackView.swift */; };
|
||||
5C4FA7FD2DC29C3800CE658C /* FollowPackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7F92DC29C3800CE658C /* FollowPackView.swift */; };
|
||||
5C4FA7FF2DC5119300CE658C /* FollowPackPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7FE2DC5119300CE658C /* FollowPackPreview.swift */; };
|
||||
5C4FA8002DC5119300CE658C /* FollowPackPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7FE2DC5119300CE658C /* FollowPackPreview.swift */; };
|
||||
5C4FA8012DC5119300CE658C /* FollowPackPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7FE2DC5119300CE658C /* FollowPackPreview.swift */; };
|
||||
5C4FA8032DCAF80E00CE658C /* FollowPackTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA8022DCAF80400CE658C /* FollowPackTimeline.swift */; };
|
||||
5C4FA8042DCAF80E00CE658C /* FollowPackTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA8022DCAF80400CE658C /* FollowPackTimeline.swift */; };
|
||||
5C4FA8052DCAF80E00CE658C /* FollowPackTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA8022DCAF80400CE658C /* FollowPackTimeline.swift */; };
|
||||
5C513FBA297F72980072348F /* CustomPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C513FB9297F72980072348F /* CustomPicker.swift */; };
|
||||
5C513FCC2984ACA60072348F /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C513FCB2984ACA60072348F /* QRCodeView.swift */; };
|
||||
5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */; };
|
||||
@@ -765,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 */; };
|
||||
@@ -1100,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 */; };
|
||||
@@ -1143,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 */; };
|
||||
@@ -1309,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 */; };
|
||||
@@ -1493,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 */; };
|
||||
@@ -1528,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 */; };
|
||||
@@ -1537,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 */; };
|
||||
@@ -1575,6 +1603,9 @@
|
||||
D7A0D8752D1FE67900DCBE59 /* EditPictureControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */; };
|
||||
D7A343EE2AD0D77C00CED48B /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D7A343ED2AD0D77C00CED48B /* InlineSnapshotTesting */; };
|
||||
D7A343F02AD0D77C00CED48B /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D7A343EF2AD0D77C00CED48B /* SnapshotTesting */; };
|
||||
D7AACFFF2E0387B800FB7699 /* LnurlAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AACFFE2E0387B800FB7699 /* LnurlAmountView.swift */; };
|
||||
D7AAD0002E0387B800FB7699 /* LnurlAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AACFFE2E0387B800FB7699 /* LnurlAmountView.swift */; };
|
||||
D7AAD0012E0387B800FB7699 /* LnurlAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AACFFE2E0387B800FB7699 /* LnurlAmountView.swift */; };
|
||||
D7ADD3DE2B53854300F104C4 /* DamusPurpleURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */; };
|
||||
D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */; };
|
||||
D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */; };
|
||||
@@ -1705,6 +1736,9 @@
|
||||
D7DB930C2D69486700DA1EE5 /* NIP65.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93092D69485A00DA1EE5 /* NIP65.swift */; };
|
||||
D7DBD41F2B02F15E002A6197 /* NostrKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */; };
|
||||
D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */; };
|
||||
D7DF58322DFCF18D00E9AD28 /* SendPaymentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DF58312DFCF18800E9AD28 /* SendPaymentView.swift */; };
|
||||
D7DF58332DFCF18D00E9AD28 /* SendPaymentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DF58312DFCF18800E9AD28 /* SendPaymentView.swift */; };
|
||||
D7DF58342DFCF18D00E9AD28 /* SendPaymentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DF58312DFCF18800E9AD28 /* SendPaymentView.swift */; };
|
||||
D7EB00B02CD59C8D00660C07 /* PresentFullScreenItemNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */; };
|
||||
D7EB00B12CD59C8D00660C07 /* PresentFullScreenItemNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */; };
|
||||
D7EDED152B11776B0018B19C /* LibreTranslateServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */; };
|
||||
@@ -1754,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 */; };
|
||||
@@ -2449,11 +2482,16 @@
|
||||
5C0567542C8B60C20073F23A /* OffsetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetExtension.swift; sourceTree = "<group>"; };
|
||||
5C0567572C8FBC560073F23A /* NDBSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NDBSearchView.swift; sourceTree = "<group>"; };
|
||||
5C0707D02A1ECB38004E7B51 /* DamusLogoGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusLogoGradient.swift; sourceTree = "<group>"; };
|
||||
5C09FD112DF283D200823661 /* FollowPackModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowPackModel.swift; sourceTree = "<group>"; };
|
||||
5C14C29A2BBBA29C00079FD2 /* RelaySoftwareDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySoftwareDetail.swift; sourceTree = "<group>"; };
|
||||
5C14C29C2BBBA40B00079FD2 /* RelayAdminDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayAdminDetail.swift; sourceTree = "<group>"; };
|
||||
5C14C29E2BBBA5C600079FD2 /* RelayNipList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayNipList.swift; sourceTree = "<group>"; };
|
||||
5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyUserSearchView.swift; sourceTree = "<group>"; };
|
||||
5C4D9EA62C042FA5005EA0F7 /* HighlightDraftContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDraftContentView.swift; sourceTree = "<group>"; };
|
||||
5C4FA7EB2DC29AE900CE658C /* FollowPackEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowPackEvent.swift; sourceTree = "<group>"; };
|
||||
5C4FA7F92DC29C3800CE658C /* FollowPackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowPackView.swift; sourceTree = "<group>"; };
|
||||
5C4FA7FE2DC5119300CE658C /* FollowPackPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowPackPreview.swift; sourceTree = "<group>"; };
|
||||
5C4FA8022DCAF80400CE658C /* FollowPackTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowPackTimeline.swift; sourceTree = "<group>"; };
|
||||
5C513FB9297F72980072348F /* CustomPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPicker.swift; sourceTree = "<group>"; };
|
||||
5C513FCB2984ACA60072348F /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = "<group>"; };
|
||||
5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientButtonStyle.swift; sourceTree = "<group>"; };
|
||||
@@ -2527,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>"; };
|
||||
@@ -2553,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>"; };
|
||||
@@ -2569,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>"; };
|
||||
@@ -2589,6 +2632,7 @@
|
||||
D79C4C182AFEB061003A41B4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
D79C4C1C2AFEB061003A41B4 /* DamusNotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DamusNotificationService.entitlements; sourceTree = "<group>"; };
|
||||
D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPictureControlTests.swift; sourceTree = "<group>"; };
|
||||
D7AACFFE2E0387B800FB7699 /* LnurlAmountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LnurlAmountView.swift; sourceTree = "<group>"; };
|
||||
D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = "<group>"; };
|
||||
D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = "<group>"; };
|
||||
D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = "<group>"; };
|
||||
@@ -2613,6 +2657,7 @@
|
||||
D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Undistractor.swift; sourceTree = "<group>"; };
|
||||
D7DB93092D69485A00DA1EE5 /* NIP65.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP65.swift; sourceTree = "<group>"; };
|
||||
D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventTests.swift; sourceTree = "<group>"; };
|
||||
D7DF58312DFCF18800E9AD28 /* SendPaymentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPaymentView.swift; sourceTree = "<group>"; };
|
||||
D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentFullScreenItemNotify.swift; sourceTree = "<group>"; };
|
||||
D7EDED1B2B1178FE0018B19C /* NoteContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContent.swift; sourceTree = "<group>"; };
|
||||
D7EDED1D2B11797D0018B19C /* LongformEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformEvent.swift; sourceTree = "<group>"; };
|
||||
@@ -2636,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>"; };
|
||||
@@ -2830,6 +2874,8 @@
|
||||
4C0A3F8D280F63FF000448DE /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C09FD112DF283D200823661 /* FollowPackModel.swift */,
|
||||
5C4FA7EB2DC29AE900CE658C /* FollowPackEvent.swift */,
|
||||
D73BDB122D71212600D69970 /* NostrNetworkManager */,
|
||||
D74F43082B23F09300425B75 /* Purple */,
|
||||
BA3759882ABCCDE30018D73B /* Camera */,
|
||||
@@ -3348,6 +3394,8 @@
|
||||
4C7D095A2A098C5C00943473 /* Wallet */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D7AACFFE2E0387B800FB7699 /* LnurlAmountView.swift */,
|
||||
D7DF58312DFCF18800E9AD28 /* SendPaymentView.swift */,
|
||||
5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */,
|
||||
5CB017302D4422D600A9ED05 /* NWCSettings.swift */,
|
||||
5CB0172C2D42C76600A9ED05 /* BalanceView.swift */,
|
||||
@@ -3637,6 +3685,7 @@
|
||||
4CC7AAEE297F11B300430951 /* Events */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C4FA7FA2DC29C3800CE658C /* FollowPack */,
|
||||
5CC852A02BDED9970039FFC5 /* Highlight */,
|
||||
4CA927682A290F8F0098A105 /* Components */,
|
||||
4CC7AAEF297F11C700430951 /* SelectedEventView.swift */,
|
||||
@@ -3745,6 +3794,8 @@
|
||||
4CE6DEE527F7A08100C66700 /* damus */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D76BE18A2E0CF3BF004AD0C6 /* DIP06 */,
|
||||
D71527FD2E0A3D5800C893D6 /* NIP51 */,
|
||||
D7DB93082D69478400DA1EE5 /* NIP65 */,
|
||||
D7DB1FDC2D5A77E500CF06DA /* NIP44 */,
|
||||
D755B28B2D3E7D6500BBEEFA /* NIP37 */,
|
||||
@@ -3952,6 +4003,16 @@
|
||||
path = Images;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5C4FA7FA2DC29C3800CE658C /* FollowPack */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C4FA8022DCAF80400CE658C /* FollowPackTimeline.swift */,
|
||||
5C4FA7FE2DC5119300CE658C /* FollowPackPreview.swift */,
|
||||
5C4FA7F92DC29C3800CE658C /* FollowPackView.swift */,
|
||||
);
|
||||
path = FollowPack;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5CC852A02BDED9970039FFC5 /* Highlight */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -4032,6 +4093,14 @@
|
||||
path = Detail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D71527FD2E0A3D5800C893D6 /* NIP51 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D71527FE2E0A3D5F00C893D6 /* InterestList.swift */,
|
||||
);
|
||||
path = NIP51;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D71AC4CA2BA8E3320076268E /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -4090,6 +4159,14 @@
|
||||
path = NIP37;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D76BE18A2E0CF3BF004AD0C6 /* DIP06 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D76BE18B2E0CF3D5004AD0C6 /* Interests.swift */,
|
||||
);
|
||||
path = DIP06;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D78DB85D2C20FE9E00F0AB12 /* Chat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -4177,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>";
|
||||
@@ -4465,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 */,
|
||||
@@ -4474,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;
|
||||
@@ -4659,6 +4738,7 @@
|
||||
4C363A9028247A1D006E126D /* NostrLink.swift in Sources */,
|
||||
4C3D52B6298DB4E6001C5831 /* ZapEvent.swift in Sources */,
|
||||
647D9A8D2968520300A295DE /* SideMenuView.swift in Sources */,
|
||||
D7AAD0012E0387B800FB7699 /* LnurlAmountView.swift in Sources */,
|
||||
F7F0BA272978E54D009531F3 /* ParticipantsView.swift in Sources */,
|
||||
4CF0ABE32981BC7D00D66079 /* UserView.swift in Sources */,
|
||||
4CE0E2AF29A2E82100DB4CA2 /* EventHolder.swift in Sources */,
|
||||
@@ -4678,6 +4758,7 @@
|
||||
BA3759972ABCCF360018D73B /* CameraPreview.swift in Sources */,
|
||||
4C75EFA627FF87A20006080F /* Nostr.swift in Sources */,
|
||||
4CA927672A290F8B0098A105 /* RelativeTime.swift in Sources */,
|
||||
5C4FA8032DCAF80E00CE658C /* FollowPackTimeline.swift in Sources */,
|
||||
4CB883A62975F83C00DC99E7 /* LNUrlPayRequest.swift in Sources */,
|
||||
D7CB5D4B2B11721600AD4105 /* ZapType.swift in Sources */,
|
||||
4CE4F9DE2852768D00C00DD9 /* ConfigView.swift in Sources */,
|
||||
@@ -4692,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 */,
|
||||
@@ -4823,6 +4905,7 @@
|
||||
4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */,
|
||||
4C12535E2A76CA870004F4B8 /* SwitchedTimelineNotify.swift in Sources */,
|
||||
D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */,
|
||||
5C4FA7ED2DC29AE900CE658C /* FollowPackEvent.swift in Sources */,
|
||||
9CA876E229A00CEA0003B9A3 /* AttachMediaUtility.swift in Sources */,
|
||||
3ACF94462DAA006500971A4E /* NIP05DomainEventsModel.swift in Sources */,
|
||||
D734B1452CCC19B1000B5C97 /* DamusFullScreenCover.swift in Sources */,
|
||||
@@ -4880,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 */,
|
||||
@@ -4894,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 */,
|
||||
@@ -4905,6 +4990,7 @@
|
||||
D7373BAA2B68A65A00F7783D /* PurpleAccountUpdateNotify.swift in Sources */,
|
||||
5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */,
|
||||
3CCD1E6A2A874C4E0099A953 /* Nip98HTTPAuth.swift in Sources */,
|
||||
5C4FA7FF2DC5119300CE658C /* FollowPackPreview.swift in Sources */,
|
||||
4C8EC52529D1FA6C0085D9A8 /* DamusColors.swift in Sources */,
|
||||
3A4647CF2A413ADC00386AD8 /* CondensedProfilePicturesView.swift in Sources */,
|
||||
5C14C29B2BBBA29C00079FD2 /* RelaySoftwareDetail.swift in Sources */,
|
||||
@@ -4935,6 +5021,7 @@
|
||||
3165648B295B70D500C64604 /* LinkView.swift in Sources */,
|
||||
4C8D00CF29E38B950036AF10 /* nostr_bech32.c in Sources */,
|
||||
D7CB5D5C2B1176B200AD4105 /* MediaUploader.swift in Sources */,
|
||||
5C4FA7FD2DC29C3800CE658C /* FollowPackView.swift in Sources */,
|
||||
4C1253562A76C8C60004F4B8 /* BroadcastNotify.swift in Sources */,
|
||||
4C3BEFD42819DE8F00B3DE84 /* NostrKind.swift in Sources */,
|
||||
B533694E2B66D791008A805E /* MutelistManager.swift in Sources */,
|
||||
@@ -4962,6 +5049,7 @@
|
||||
4C3AC7A728369BA200E1F516 /* SearchHomeView.swift in Sources */,
|
||||
E0EE9DD42B8E5FEA00F3002D /* ImageProcessing.swift in Sources */,
|
||||
4CB883B0297705DD00DC99E7 /* NoteZapButton.swift in Sources */,
|
||||
D7DF58342DFCF18D00E9AD28 /* SendPaymentView.swift in Sources */,
|
||||
4C363A922825FCF2006E126D /* ProfileUpdate.swift in Sources */,
|
||||
4C3BEFDA281DCA1400B3DE84 /* LikeCounter.swift in Sources */,
|
||||
4C32B9502A9AD44700DC3548 /* FlatBufferBuilder.swift in Sources */,
|
||||
@@ -4974,9 +5062,11 @@
|
||||
4C32B9512A9AD44700DC3548 /* FlatbuffersErrors.swift in Sources */,
|
||||
5CC852A62BE00F180039FFC5 /* HighlightEventRef.swift in Sources */,
|
||||
4CE8794E2996B16A00F758CC /* RelayToggle.swift in Sources */,
|
||||
5C09FD132DF283D700823661 /* FollowPackModel.swift in Sources */,
|
||||
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 */,
|
||||
@@ -5135,6 +5225,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5C4FA7FB2DC29C3800CE658C /* FollowPackView.swift in Sources */,
|
||||
D7F360262CEBBD8B009D34DA /* PresentFullScreenItemNotify.swift in Sources */,
|
||||
82D6FA9A2CD9820500C925F4 /* ShareViewController.swift in Sources */,
|
||||
82D6FAA92CD99F7900C925F4 /* FbConstants.swift in Sources */,
|
||||
@@ -5176,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 */,
|
||||
@@ -5202,6 +5294,7 @@
|
||||
82D6FAE42CD99F7900C925F4 /* FollowNotify.swift in Sources */,
|
||||
82D6FAE52CD99F7900C925F4 /* LikedNotify.swift in Sources */,
|
||||
82D6FAE62CD99F7900C925F4 /* LocalNotificationNotify.swift in Sources */,
|
||||
5C4FA8012DC5119300CE658C /* FollowPackPreview.swift in Sources */,
|
||||
82D6FAE72CD99F7900C925F4 /* LoginNotify.swift in Sources */,
|
||||
82D6FAE82CD99F7900C925F4 /* LogoutNotify.swift in Sources */,
|
||||
D706C5B12D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */,
|
||||
@@ -5233,6 +5326,7 @@
|
||||
82D6FAFD2CD99F7900C925F4 /* IdType.swift in Sources */,
|
||||
82D6FAFE2CD99F7900C925F4 /* Pubkey.swift in Sources */,
|
||||
82D6FAFF2CD99F7900C925F4 /* NoteId.swift in Sources */,
|
||||
D7DF58332DFCF18D00E9AD28 /* SendPaymentView.swift in Sources */,
|
||||
82D6FB002CD99F7900C925F4 /* Referenced.swift in Sources */,
|
||||
5CB0172D2D42C76A00A9ED05 /* BalanceView.swift in Sources */,
|
||||
82D6FB012CD99F7900C925F4 /* Block.swift in Sources */,
|
||||
@@ -5254,6 +5348,7 @@
|
||||
82D6FB0F2CD99F7900C925F4 /* DamusLogoGradient.swift in Sources */,
|
||||
82D6FB102CD99F7900C925F4 /* DamusBackground.swift in Sources */,
|
||||
82D6FB112CD99F7900C925F4 /* DamusLightGradient.swift in Sources */,
|
||||
5C4FA8042DCAF80E00CE658C /* FollowPackTimeline.swift in Sources */,
|
||||
82D6FB132CD99F7900C925F4 /* Shimmer.swift in Sources */,
|
||||
82D6FB142CD99F7900C925F4 /* EndBlock.swift in Sources */,
|
||||
82D6FB152CD99F7900C925F4 /* ImageCarousel.swift in Sources */,
|
||||
@@ -5281,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 */,
|
||||
@@ -5385,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 */,
|
||||
@@ -5396,10 +5493,12 @@
|
||||
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 */,
|
||||
82D6FB9D2CD99F7900C925F4 /* ZapButtonModel.swift in Sources */,
|
||||
5C09FD142DF283D700823661 /* FollowPackModel.swift in Sources */,
|
||||
82D6FB9E2CD99F7900C925F4 /* ContentFilters.swift in Sources */,
|
||||
3A515C512DF4E100002D3B34 /* TrustedNetworkRepliesTip.swift in Sources */,
|
||||
82D6FB9F2CD99F7900C925F4 /* DamusCacheManager.swift in Sources */,
|
||||
@@ -5439,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 */,
|
||||
@@ -5451,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 */,
|
||||
@@ -5539,6 +5638,7 @@
|
||||
82D6FC202CD99F7900C925F4 /* RelayType.swift in Sources */,
|
||||
82D6FC212CD99F7900C925F4 /* SignalView.swift in Sources */,
|
||||
82D6FC222CD99F7900C925F4 /* RelayPicView.swift in Sources */,
|
||||
5C4FA7EC2DC29AE900CE658C /* FollowPackEvent.swift in Sources */,
|
||||
82D6FC232CD99F7900C925F4 /* UserSearch.swift in Sources */,
|
||||
82D6FC242CD99F7900C925F4 /* AddMuteItemView.swift in Sources */,
|
||||
82D6FC252CD99F7900C925F4 /* MuteDurationMenu.swift in Sources */,
|
||||
@@ -5559,6 +5659,7 @@
|
||||
82D6FC342CD99F7900C925F4 /* BuilderEventView.swift in Sources */,
|
||||
82D6FC352CD99F7900C925F4 /* EventProfile.swift in Sources */,
|
||||
82D6FC362CD99F7900C925F4 /* EventMenu.swift in Sources */,
|
||||
D7AAD0002E0387B800FB7699 /* LnurlAmountView.swift in Sources */,
|
||||
82D6FC372CD99F7900C925F4 /* EventMutingContainerView.swift in Sources */,
|
||||
82D6FC382CD99F7900C925F4 /* ZapEvent.swift in Sources */,
|
||||
82D6FC392CD99F7900C925F4 /* TextEvent.swift in Sources */,
|
||||
@@ -5710,8 +5811,10 @@
|
||||
D73E5E602C6A97F4007EB227 /* ImageMetadata.swift in Sources */,
|
||||
D73E5E612C6A97F4007EB227 /* ImageProcessing.swift in Sources */,
|
||||
D73E5E622C6A97F4007EB227 /* BlurHashEncode.swift in Sources */,
|
||||
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 */,
|
||||
@@ -5729,8 +5832,10 @@
|
||||
D73E5E6F2C6A97F4007EB227 /* TimeAgo.swift in Sources */,
|
||||
D73E5E702C6A97F4007EB227 /* Parser.swift in Sources */,
|
||||
D73E5E722C6A97F4007EB227 /* LinkView.swift in Sources */,
|
||||
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 */,
|
||||
@@ -5746,6 +5851,7 @@
|
||||
D73E5E802C6A97F4007EB227 /* CredentialHandler.swift in Sources */,
|
||||
D73E5E812C6A97F4007EB227 /* KeyboardVisible.swift in Sources */,
|
||||
D73E5E832C6A97F4007EB227 /* AVPlayer+Additions.swift in Sources */,
|
||||
5C4FA7FC2DC29C3800CE658C /* FollowPackView.swift in Sources */,
|
||||
D73E5E842C6A97F4007EB227 /* Zaps+.swift in Sources */,
|
||||
D73E5E852C6A97F4007EB227 /* WalletConnect+.swift in Sources */,
|
||||
D73E5E862C6A97F4007EB227 /* DamusPurpleNotificationManagement.swift in Sources */,
|
||||
@@ -5829,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 */,
|
||||
@@ -5884,6 +5989,7 @@
|
||||
D73E5EFF2C6A97F4007EB227 /* ZapsView.swift in Sources */,
|
||||
D73E5F002C6A97F4007EB227 /* CustomizeZapView.swift in Sources */,
|
||||
D73E5F012C6A97F4007EB227 /* ZapTypePicker.swift in Sources */,
|
||||
5C4FA8052DCAF80E00CE658C /* FollowPackTimeline.swift in Sources */,
|
||||
D73E5F022C6A97F4007EB227 /* ZapUserView.swift in Sources */,
|
||||
D73E5F032C6A97F4007EB227 /* ProfileZapLinkView.swift in Sources */,
|
||||
D7DB930C2D69486700DA1EE5 /* NIP65.swift in Sources */,
|
||||
@@ -5903,6 +6009,7 @@
|
||||
D73E5F0F2C6A97F4007EB227 /* CondensedProfilePicturesView.swift in Sources */,
|
||||
D73E5F102C6A97F4007EB227 /* ProfileEditButton.swift in Sources */,
|
||||
D73E5F112C6A97F4007EB227 /* RelayPaidDetail.swift in Sources */,
|
||||
D7AACFFF2E0387B800FB7699 /* LnurlAmountView.swift in Sources */,
|
||||
D73E5F122C6A97F4007EB227 /* RelayAuthenticationDetail.swift in Sources */,
|
||||
D73E5F132C6A97F4007EB227 /* RelaySoftwareDetail.swift in Sources */,
|
||||
D73E5F142C6A97F4007EB227 /* RelayAdminDetail.swift in Sources */,
|
||||
@@ -5980,6 +6087,8 @@
|
||||
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 */,
|
||||
D73E5F562C6A97F5007EB227 /* NoteContentView.swift in Sources */,
|
||||
@@ -6022,6 +6131,7 @@
|
||||
D703D7552C670A3700A400EA /* DamusUserDefaults.swift in Sources */,
|
||||
D703D7A32C670E1D00A400EA /* nostr_bech32.c in Sources */,
|
||||
D703D7992C670DF900A400EA /* sha256.c in Sources */,
|
||||
5C4FA8002DC5119300CE658C /* FollowPackPreview.swift in Sources */,
|
||||
D703D7972C670DED00A400EA /* wasm.c in Sources */,
|
||||
5C8498042D5D150000F74FEB /* ZapExplainer.swift in Sources */,
|
||||
D703D7842C670C4700A400EA /* SequenceUtils.swift in Sources */,
|
||||
@@ -6045,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 */,
|
||||
@@ -6061,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 */,
|
||||
|
||||
@@ -162,6 +162,7 @@ class CarouselModel: ObservableObject {
|
||||
// Upon updating information, update the carousel fill size if the size for the current url has changed
|
||||
if oldValue[current_url] != media_size_information[current_url] {
|
||||
self.refresh_current_item_fill()
|
||||
self.refresh_first_item_height()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,6 +187,13 @@ class CarouselModel: ObservableObject {
|
||||
/// and is automatically updated upon changes to these properties.
|
||||
@Published private(set) var current_item_fill: ImageFill?
|
||||
|
||||
/// Holds the ideal fill dimensions for the first item in the carousel.
|
||||
/// This is used to maintain a consistent height for the carousel when swiping between images.
|
||||
/// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly.
|
||||
/// **Implementation note:** This property ensures the carousel maintains a consistent height based on the first image,
|
||||
/// preventing the UI from "jumping" when swiping between images of different aspect ratios.
|
||||
@Published private(set) var first_image_fill: ImageFill?
|
||||
|
||||
|
||||
// MARK: Initialization and de-initialization
|
||||
|
||||
@@ -207,6 +215,7 @@ class CarouselModel: ObservableObject {
|
||||
self.observe_video_sizes()
|
||||
Task {
|
||||
self.refresh_current_item_fill()
|
||||
self.refresh_first_item_height()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,10 +250,17 @@ class CarouselModel: ObservableObject {
|
||||
/// **Usage note:** This is private, do not call this directly from outside the class.
|
||||
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
|
||||
private func refresh_current_item_fill() {
|
||||
if let current_url,
|
||||
let item_size = self.media_size_information[current_url],
|
||||
self.current_item_fill = self.compute_item_fill(url: current_url)
|
||||
}
|
||||
|
||||
/// Computes the image fill properties for a given URL without side effects.
|
||||
/// This is a pure function that calculates the appropriate fill dimensions based on image size and container constraints.
|
||||
/// **Usage note:** This is a helper method used by both `refresh_current_item_fill` and `refresh_first_item_height`.
|
||||
private func compute_item_fill(url: URL?) -> ImageFill? {
|
||||
if let url,
|
||||
let item_size = self.media_size_information[url],
|
||||
let geo_size {
|
||||
self.current_item_fill = ImageFill.calculate_image_fill(
|
||||
return ImageFill.calculate_image_fill(
|
||||
geo_size: geo_size,
|
||||
img_size: item_size,
|
||||
maxHeight: self.max_height,
|
||||
@@ -252,9 +268,26 @@ class CarouselModel: ObservableObject {
|
||||
)
|
||||
}
|
||||
else {
|
||||
self.current_item_fill = nil // Not enough information to compute the proper fill. Default to nil
|
||||
return nil // Not enough information to compute the proper fill. Default to nil
|
||||
}
|
||||
}
|
||||
|
||||
/// This function refreshes the first item height based on the current state of the model
|
||||
/// **Usage note:** This is private, do not call this directly from outside the class.
|
||||
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the height.
|
||||
/// When the first image dimensions change, this ensures the carousel maintains consistent dimensions.
|
||||
private func refresh_first_item_height() {
|
||||
self.first_image_fill = self.compute_first_item_fill()
|
||||
}
|
||||
|
||||
/// Computes the first item fill with no side-effects.
|
||||
/// **Usage note:** Not to be used outside the class. Use the `first_image_fill` property instead.
|
||||
/// **Implementation note:** This retrieves the first URL from the carousel and computes its fill properties
|
||||
/// to establish a consistent height for the entire carousel.
|
||||
private func compute_first_item_fill() -> ImageFill? {
|
||||
guard let first_url = urls[safe: 0] else { return nil }
|
||||
return self.compute_item_fill(url: first_url.url)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Carousel
|
||||
@@ -286,13 +319,15 @@ struct ImageCarousel<Content: View>: View {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var filling: Bool {
|
||||
model.current_item_fill?.filling == true
|
||||
}
|
||||
/// Determines if the image should fill its container.
|
||||
/// Always returns true to ensure images consistently fill the width of the container.
|
||||
/// This simplifies the layout behavior and prevents inconsistent sizing between carousel items.
|
||||
var filling: Bool { true }
|
||||
|
||||
var height: CGFloat {
|
||||
// Use the calculated fill height if available, otherwise use the default fill height
|
||||
model.current_item_fill?.height ?? model.default_fill_height
|
||||
// Use the first image height (to prevent height from jumping when swiping), then default to the default fill height
|
||||
// This prioritization ensures consistent carousel height regardless of which image is currently displayed
|
||||
model.first_image_fill?.height ?? model.default_fill_height
|
||||
}
|
||||
|
||||
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
|
||||
@@ -376,6 +411,7 @@ struct ImageCarousel<Content: View>: View {
|
||||
}
|
||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
|
||||
.frame(height: height)
|
||||
.clipped() // Prevents content from overflowing the frame, ensuring clean edges in the carousel
|
||||
.onChange(of: model.selectedIndex) { value in
|
||||
model.selectedIndex = value
|
||||
}
|
||||
|
||||
+14
-1
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ enum FilterState : Int {
|
||||
case posts = 0
|
||||
case posts_and_replies = 1
|
||||
case conversations = 2
|
||||
case follow_list = 3
|
||||
|
||||
func filter(ev: NostrEvent) -> Bool {
|
||||
switch self {
|
||||
@@ -22,13 +23,15 @@ enum FilterState : Int {
|
||||
return true
|
||||
case .conversations:
|
||||
return true
|
||||
case .follow_list:
|
||||
return ev.known_kind == .follow_list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple filter to determine whether to show posts with #nsfw tags
|
||||
func nsfw_tag_filter(ev: NostrEvent) -> Bool {
|
||||
return ev.referenced_hashtags.first(where: { t in t.hashtag == "nsfw" }) == nil
|
||||
return ev.referenced_hashtags.first(where: { t in t.hashtag.caseInsensitiveCompare("nsfw") == .orderedSame }) == nil
|
||||
}
|
||||
|
||||
func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEvent) -> Bool) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// FollowPackEvent.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 4/30/25.
|
||||
//
|
||||
|
||||
|
||||
import Foundation
|
||||
|
||||
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 {
|
||||
var followlist = FollowPackEvent(event: ev)
|
||||
|
||||
for tag in ev.tags {
|
||||
guard tag.count >= 2 else { continue }
|
||||
switch tag[0].string() {
|
||||
case "title": followlist.title = tag[1].string()
|
||||
case "d": followlist.uuid = tag[1].string()
|
||||
case "image": followlist.image = URL(string: tag[1].string())
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return followlist
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// FollowPackModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 6/5/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
class FollowPackModel: ObservableObject {
|
||||
var events: EventHolder
|
||||
@Published var loading: Bool = false
|
||||
|
||||
let damus_state: DamusState
|
||||
let subid = UUID().description
|
||||
let limit: UInt32 = 500
|
||||
|
||||
init(damus_state: DamusState) {
|
||||
self.damus_state = damus_state
|
||||
self.events = EventHolder(on_queue: { ev in
|
||||
preload_events(state: damus_state, events: [ev])
|
||||
})
|
||||
}
|
||||
|
||||
func subscribe(follow_pack_users: [Pubkey]) {
|
||||
loading = true
|
||||
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
|
||||
var filter = NostrFilter(kinds: [.text, .chat])
|
||||
filter.until = UInt32(Date.now.timeIntervalSince1970)
|
||||
filter.authors = follow_pack_users
|
||||
filter.limit = 500
|
||||
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: subid, filters: [filter], handler: handle_event, to: to_relays)
|
||||
}
|
||||
|
||||
func unsubscribe(to: RelayURL? = nil) {
|
||||
loading = false
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: to.map { [$0] })
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
||||
guard case .nostr_event(let event) = conn_ev else {
|
||||
return
|
||||
}
|
||||
|
||||
switch event {
|
||||
case .event(let sub_id, let ev):
|
||||
guard sub_id == self.subid else {
|
||||
return
|
||||
}
|
||||
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
||||
{
|
||||
if self.events.insert(ev) {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
case .notice(let msg):
|
||||
print("follow pack notice: \(msg)")
|
||||
case .ok:
|
||||
break
|
||||
case .eose(let sub_id):
|
||||
loading = false
|
||||
|
||||
if sub_id == self.subid {
|
||||
unsubscribe(to: relay_id)
|
||||
|
||||
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
|
||||
}
|
||||
|
||||
break
|
||||
case .auth:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,10 @@ class HomeModel: ContactsDelegate {
|
||||
break
|
||||
case .relay_list:
|
||||
break // This will be handled by `UserRelayListManager`
|
||||
case .follow_list:
|
||||
break
|
||||
case .interest_list:
|
||||
break // Don't care for now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,24 +294,12 @@ class HomeModel: ContactsDelegate {
|
||||
Log.debug("HomeModel: got NWC response, %s not found in the postbox, nothing to remove [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
|
||||
}
|
||||
|
||||
damus_state.wallet.handle_nwc_response(response: resp) // This can handle success or error cases
|
||||
|
||||
guard resp.response.error == nil else {
|
||||
Log.error("HomeModel: NWC wallet raised an error: %s", for: .nwc, String(describing: resp.response))
|
||||
WalletConnect.handle_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp)
|
||||
if let humanReadableError = resp.response.error?.humanReadableError {
|
||||
present_sheet(.error(humanReadableError))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if resp.response.result_type == .list_transactions {
|
||||
Log.info("Received NWC transaction list from %s", for: .nwc, relay.absoluteString)
|
||||
damus_state.wallet.handle_nwc_response(response: resp)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.response.result_type == .get_balance {
|
||||
Log.info("Received NWC balance information from %s", for: .nwc, relay.absoluteString)
|
||||
damus_state.wallet.handle_nwc_response(response: resp)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -64,10 +64,35 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
|
||||
switch self {
|
||||
case .pubkey(let pubkey): return ["p", pubkey.hex()]
|
||||
case .note(let noteId): return ["e", noteId.hex()]
|
||||
case .nevent(let nevent): return ["e", nevent.noteid.hex()]
|
||||
case .nprofile(let nprofile): return ["p", nprofile.author.hex()]
|
||||
case .nevent(let nevent):
|
||||
var tagBuilder = ["e", nevent.noteid.hex()]
|
||||
|
||||
let relay = nevent.relays.first
|
||||
if let author = nevent.author?.hex() {
|
||||
tagBuilder.append(relay ?? "")
|
||||
tagBuilder.append(author)
|
||||
} else if let relay {
|
||||
tagBuilder.append(relay)
|
||||
}
|
||||
|
||||
return tagBuilder
|
||||
case .nprofile(let nprofile):
|
||||
var tagBuilder = ["p", nprofile.author.hex()]
|
||||
|
||||
if let relay = nprofile.relays.first {
|
||||
tagBuilder.append(relay)
|
||||
}
|
||||
|
||||
return tagBuilder
|
||||
case .nrelay(let url): return ["r", url]
|
||||
case .naddr(let naddr): return ["a", naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier.string()]
|
||||
case .naddr(let naddr):
|
||||
var tagBuilder = ["a", "\(naddr.kind.description):\(naddr.author.hex()):\(naddr.identifier.string())"]
|
||||
|
||||
if let relay = naddr.relays.first {
|
||||
tagBuilder.append(relay)
|
||||
}
|
||||
|
||||
return tagBuilder
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +188,10 @@ struct LightningInvoice<T> {
|
||||
let payment_hash: Data
|
||||
let created_at: UInt64
|
||||
|
||||
var abbreviated: String {
|
||||
return self.string.prefix(8) + "…" + self.string.suffix(8)
|
||||
}
|
||||
|
||||
var description_string: String {
|
||||
switch description {
|
||||
case .description(let string):
|
||||
@@ -171,6 +200,17 @@ struct LightningInvoice<T> {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
static func from(string: String) -> Invoice? {
|
||||
// This feels a bit hacky at first, but it is actually clean
|
||||
// because it reuses the same well-tested parsing logic as the rest of the app,
|
||||
// avoiding code duplication and utilizing the guarantees acquired from age and testing.
|
||||
// We could also use the C function `parse_invoice`, but it requires extra C bridging logic.
|
||||
// NDBTODO: This may need updating on the nostrdb upgrade.
|
||||
let parsedBlocks = parse_note_content(content: .content(string,nil)).blocks
|
||||
guard parsedBlocks.count == 1 else { return nil }
|
||||
return parsedBlocks[0].asInvoice
|
||||
}
|
||||
}
|
||||
|
||||
func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>!) -> T? {
|
||||
@@ -192,6 +232,13 @@ enum Amount: Equatable {
|
||||
return format_msats(amt)
|
||||
}
|
||||
}
|
||||
|
||||
func amount_sats() -> Int64? {
|
||||
switch self {
|
||||
case .any: nil
|
||||
case .specific(let amount): amount / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func format_msats_abbrev(_ msats: Int64) -> String {
|
||||
|
||||
@@ -51,6 +51,16 @@ class NostrNetworkManager {
|
||||
func connect() {
|
||||
self.userRelayList.connect()
|
||||
}
|
||||
|
||||
func relaysForEvent(event: NostrEvent) -> [RelayURL] {
|
||||
// TODO(tyiu) Ideally this list would be sorted by the event author's outbox relay preferences
|
||||
// and reliability of relays to maximize chances of others finding this event.
|
||||
if let relays = pool.seen[event.id] {
|
||||
return Array(relays)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private let MAX_SHARE_RELAYS = 4
|
||||
|
||||
var events: EventHolder
|
||||
let pubkey: Pubkey
|
||||
@@ -222,7 +220,7 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
}
|
||||
|
||||
func getCappedRelayStrings() -> [String] {
|
||||
return self.relay_urls?.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
|
||||
return self.relay_urls?.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//
|
||||
// SearchHomeModel.swift
|
||||
// damus
|
||||
//
|
||||
@@ -16,6 +15,7 @@ class SearchHomeModel: ObservableObject {
|
||||
var seen_pubkey: Set<Pubkey> = Set()
|
||||
let damus_state: DamusState
|
||||
let base_subid = UUID().description
|
||||
let follow_pack_subid = UUID().description
|
||||
let profiles_subid = UUID().description
|
||||
let limit: UInt32 = 500
|
||||
//let multiple_events_per_pubkey: Bool = false
|
||||
@@ -42,12 +42,18 @@ class SearchHomeModel: ObservableObject {
|
||||
func subscribe() {
|
||||
loading = true
|
||||
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
|
||||
|
||||
var follow_list_filter = NostrFilter(kinds: [.follow_list])
|
||||
follow_list_filter.until = UInt32(Date.now.timeIntervalSince1970)
|
||||
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: follow_pack_subid, filters: [follow_list_filter], handler: handle_event, to: to_relays)
|
||||
}
|
||||
|
||||
func unsubscribe(to: RelayURL? = nil) {
|
||||
loading = false
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: follow_pack_subid, to: to.map { [$0] })
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
||||
@@ -57,7 +63,7 @@ class SearchHomeModel: ObservableObject {
|
||||
|
||||
switch event {
|
||||
case .event(let sub_id, let ev):
|
||||
guard sub_id == self.base_subid || sub_id == self.profiles_subid else {
|
||||
guard sub_id == self.base_subid || sub_id == self.profiles_subid || sub_id == self.follow_pack_subid else {
|
||||
return
|
||||
}
|
||||
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
||||
|
||||
@@ -36,7 +36,7 @@ class SearchModel: ObservableObject {
|
||||
func subscribe() {
|
||||
// since 1 month
|
||||
search.limit = self.limit
|
||||
search.kinds = [.text, .like, .longform, .highlight]
|
||||
search.kinds = [.text, .like, .longform, .highlight, .follow_list]
|
||||
|
||||
//likes_filter.ids = ref_events.referenced_ids!
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ class WalletModel: ObservableObject {
|
||||
|
||||
@Published private(set) var connect_state: WalletConnectState
|
||||
|
||||
/// A dictionary listing continuations waiting for a response for each request note id.
|
||||
///
|
||||
/// Please see the `waitForResponse` method for context.
|
||||
private var continuations: [NoteId: CheckedContinuation<WalletConnect.Response.Result, any Error>] = [:]
|
||||
|
||||
init(state: WalletConnectState, settings: UserSettingsStore) {
|
||||
self.connect_state = state
|
||||
self.previous_state = .none
|
||||
@@ -75,12 +80,16 @@ class WalletModel: ObservableObject {
|
||||
///
|
||||
/// - Parameter response: The NWC response received from the network
|
||||
func handle_nwc_response(response: WalletConnect.FullWalletResponse) {
|
||||
switch response.response.result {
|
||||
if let error = response.response.error {
|
||||
self.resume(request: response.req_id, throwing: error)
|
||||
return
|
||||
}
|
||||
guard let result = response.response.result else { return }
|
||||
self.resume(request: response.req_id, with: result)
|
||||
switch result {
|
||||
case .get_balance(let balanceResp):
|
||||
self.balance = balanceResp.balance / 1000
|
||||
case .none:
|
||||
return
|
||||
case .some(.pay_invoice(_)):
|
||||
case .pay_invoice(_):
|
||||
return
|
||||
case .list_transactions(let transactionsResp):
|
||||
self.transactions = transactionsResp.transactions
|
||||
@@ -91,4 +100,41 @@ class WalletModel: ObservableObject {
|
||||
self.transactions = nil
|
||||
self.balance = nil
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Async wallet response waiting mechanism
|
||||
|
||||
func waitForResponse(for requestId: NoteId, timeout: Duration = .seconds(10)) async throws -> WalletConnect.Response.Result {
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
self.continuations[requestId] = continuation
|
||||
|
||||
let timeoutTask = Task {
|
||||
try? await Task.sleep(for: timeout)
|
||||
self.resume(request: requestId, throwing: WaitError.timeout) // Must resume the continuation exactly once even if there is no response
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func resume(request requestId: NoteId, with result: WalletConnect.Response.Result) {
|
||||
continuations[requestId]?.resume(returning: result)
|
||||
continuations[requestId] = nil // Never resume a continuation twice
|
||||
}
|
||||
|
||||
private func resume(request requestId: NoteId, throwing error: any Error) {
|
||||
if let continuation = continuations[requestId] {
|
||||
continuation.resume(throwing: error)
|
||||
continuations[requestId] = nil // Never resume a continuation twice
|
||||
return // Error will be handled by the listener, no need for the generic error sheet
|
||||
}
|
||||
|
||||
// No listeners to catch the error, show generic error sheet
|
||||
if let error = error as? WalletConnect.WalletResponseErr,
|
||||
let humanReadableError = error.humanReadableError {
|
||||
present_sheet(.error(humanReadableError))
|
||||
}
|
||||
}
|
||||
|
||||
enum WaitError: Error {
|
||||
case timeout
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -448,17 +448,26 @@ func random_bytes(count: Int) -> Data {
|
||||
return Data(bytes: bytes, count: count)
|
||||
}
|
||||
|
||||
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent) -> NostrEvent? {
|
||||
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent, relayURL: RelayURL?) -> NostrEvent? {
|
||||
var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
|
||||
|
||||
tags.append(["e", boosted.id.hex(), "", "root"])
|
||||
tags.append(["p", boosted.pubkey.hex()])
|
||||
var eTagBuilder = ["e", boosted.id.hex()]
|
||||
var pTagBuilder = ["p", boosted.pubkey.hex()]
|
||||
|
||||
let relayURLString = relayURL?.absoluteString
|
||||
if let relayURLString {
|
||||
pTagBuilder.append(relayURLString)
|
||||
}
|
||||
eTagBuilder.append(contentsOf: [relayURLString ?? "", "root", boosted.pubkey.hex()])
|
||||
|
||||
tags.append(eTagBuilder)
|
||||
tags.append(pTagBuilder)
|
||||
|
||||
let content = event_to_json(ev: boosted)
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 6, tags: tags)
|
||||
}
|
||||
|
||||
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙") -> NostrEvent? {
|
||||
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙", relayURL: RelayURL?) -> NostrEvent? {
|
||||
var tags = liked.tags.reduce(into: [[String]]()) { ts, tag in
|
||||
guard tag.count >= 2,
|
||||
(tag[0].matches_char("e") || tag[0].matches_char("p")) else {
|
||||
@@ -467,8 +476,17 @@ func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String =
|
||||
ts.append(tag.strings())
|
||||
}
|
||||
|
||||
tags.append(["e", liked.id.hex()])
|
||||
tags.append(["p", liked.pubkey.hex()])
|
||||
var eTagBuilder = ["e", liked.id.hex()]
|
||||
var pTagBuilder = ["p", liked.pubkey.hex()]
|
||||
|
||||
let relayURLString = relayURL?.absoluteString
|
||||
if let relayURLString {
|
||||
pTagBuilder.append(relayURLString)
|
||||
}
|
||||
eTagBuilder.append(contentsOf: [relayURLString ?? "", liked.pubkey.hex()])
|
||||
|
||||
tags.append(eTagBuilder)
|
||||
tags.append(pTagBuilder)
|
||||
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 7, tags: tags)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -30,4 +31,5 @@ enum NostrKind: UInt32, Codable {
|
||||
case nwc_response = 23195
|
||||
case http_auth = 27235
|
||||
case status = 30315
|
||||
case follow_list = 39089
|
||||
}
|
||||
|
||||
@@ -19,17 +19,12 @@ struct QueuedRequest {
|
||||
let skip_ephemeral: Bool
|
||||
}
|
||||
|
||||
struct SeenEvent: Hashable {
|
||||
let relay_id: RelayURL
|
||||
let evid: NoteId
|
||||
}
|
||||
|
||||
/// Establishes and manages connections and subscriptions to a list of relays.
|
||||
class RelayPool {
|
||||
private(set) var relays: [Relay] = []
|
||||
var handlers: [RelayHandler] = []
|
||||
var request_queue: [QueuedRequest] = []
|
||||
var seen: Set<SeenEvent> = Set()
|
||||
var seen: [NoteId: Set<RelayURL>] = [:]
|
||||
var counts: [RelayURL: UInt64] = [:]
|
||||
var ndb: Ndb
|
||||
/// The keypair used to authenticate with relays
|
||||
@@ -357,15 +352,11 @@ class RelayPool {
|
||||
func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
|
||||
if case .nostr_event(let ev) = event {
|
||||
if case .event(_, let nev) = ev {
|
||||
let k = SeenEvent(relay_id: relay_id, evid: nev.id)
|
||||
if !seen.contains(k) {
|
||||
seen.insert(k)
|
||||
if counts[relay_id] == nil {
|
||||
counts[relay_id] = 1
|
||||
} else {
|
||||
counts[relay_id] = (counts[relay_id] ?? 0) + 1
|
||||
}
|
||||
if seen[nev.id]?.contains(relay_id) == true {
|
||||
return
|
||||
}
|
||||
seen[nev.id, default: Set()].insert(relay_id)
|
||||
counts[relay_id, default: 0] += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,3 +202,13 @@ extension Block {
|
||||
}
|
||||
}
|
||||
}
|
||||
extension Block {
|
||||
var asInvoice: Invoice? {
|
||||
switch self {
|
||||
case .invoice(let invoice):
|
||||
return invoice
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,3 @@ struct Pubkey: IdType, TagKey, TagConvertible, Identifiable {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ struct NEvent : Equatable, Hashable {
|
||||
self.author = author
|
||||
self.kind = kind
|
||||
}
|
||||
|
||||
init(event: NostrEvent, relays: [String]) {
|
||||
self.init(noteid: event.id, relays: relays, author: event.pubkey, kind: event.kind)
|
||||
}
|
||||
}
|
||||
|
||||
struct NProfile : Equatable, Hashable {
|
||||
|
||||
@@ -42,6 +42,11 @@ class CoinosDeterministicAccountClient {
|
||||
return String(fullText.prefix(16))
|
||||
}
|
||||
|
||||
var expectedLud16: String? {
|
||||
guard let username else { return nil }
|
||||
return username + "@coinos.io"
|
||||
}
|
||||
|
||||
/// A deterministic password for a Coinos account
|
||||
private var password: String? {
|
||||
// Add some prefix so that we can ensure this will NOT match the user nor the NWC private key
|
||||
@@ -163,6 +168,50 @@ class CoinosDeterministicAccountClient {
|
||||
throw ClientError.errorProcessingResponse
|
||||
}
|
||||
|
||||
/// Updates an existing NWC connection with a new maximum budget
|
||||
///
|
||||
/// Note: Account and NWC connection must exist before calling this endpoint
|
||||
func updateNWCConnection(maxAmount: UInt64) async throws -> WalletConnectURL {
|
||||
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||
guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
|
||||
|
||||
try await self.loginIfNeeded()
|
||||
|
||||
// Get existing config first
|
||||
guard let existingConfig = try await self.getNWCAppConnectionConfig() else {
|
||||
throw ClientError.errorProcessingResponse
|
||||
}
|
||||
|
||||
// Create updated config with new max amount
|
||||
let updatedConfig = NewWalletConnectionConfig(
|
||||
name: existingConfig.name ?? self.nwcConnectionName,
|
||||
secret: existingConfig.secret ?? nwcKeypair.privkey.hex(),
|
||||
pubkey: existingConfig.pubkey ?? nwcKeypair.pubkey.hex(),
|
||||
max_amount: maxAmount,
|
||||
budget_renewal: .weekly
|
||||
)
|
||||
|
||||
let configData = try encode_json_data(updatedConfig)
|
||||
|
||||
let (data, response) = try await self.makeAuthenticatedRequest(
|
||||
method: .post,
|
||||
url: urlEndpoint,
|
||||
payload: configData,
|
||||
payload_type: .json
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse }
|
||||
return nwc
|
||||
case 401: throw ClientError.unauthorized
|
||||
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
throw ClientError.errorProcessingResponse
|
||||
}
|
||||
|
||||
/// Returns the default wallet connection config
|
||||
private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
|
||||
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||
|
||||
@@ -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")!
|
||||
@@ -42,4 +45,5 @@ class Constants {
|
||||
|
||||
// MARK: General constants
|
||||
static let GIF_IMAGE_TYPE: String = "com.compuserve.gif"
|
||||
static let MAX_SHARE_RELAYS = 4
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ enum Route: Hashable {
|
||||
case FollowersYouKnow(friendedFollowers: [Pubkey], followers: FollowersModel)
|
||||
case NIP05DomainEvents(events: NIP05DomainEventsModel, nip05_domain_favicon: FaviconURL?)
|
||||
case NIP05DomainPubkeys(domain: String, nip05_domain_favicon: FaviconURL?, pubkeys: [Pubkey])
|
||||
case FollowPack(followPack: NostrEvent, model: FollowPackModel, blur_imgs: Bool)
|
||||
|
||||
@ViewBuilder
|
||||
func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> some View {
|
||||
@@ -134,6 +135,8 @@ enum Route: Hashable {
|
||||
NIP05DomainTimelineView(damus_state: damusState, model: events, nip05_domain_favicon: nip05_domain_favicon)
|
||||
case .NIP05DomainPubkeys(let domain, let nip05_domain_favicon, let pubkeys):
|
||||
NIP05DomainPubkeysView(damus_state: damusState, domain: domain, nip05_domain_favicon: nip05_domain_favicon, pubkeys: pubkeys)
|
||||
case .FollowPack(let followPack, let followPackModel, let blur_imgs):
|
||||
FollowPackView(state: damusState, ev: followPack, model: followPackModel, blur_imgs: blur_imgs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +247,9 @@ enum Route: Hashable {
|
||||
case .NIP05DomainPubkeys(let domain, _, _):
|
||||
hasher.combine("nip05DomainPubkeys")
|
||||
hasher.combine(domain)
|
||||
case .FollowPack(let followPack, let followPackModel, let blur_imgs):
|
||||
hasher.combine("followPack")
|
||||
hasher.combine(followPack.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ extension WalletConnect {
|
||||
let req_id: NoteId
|
||||
let response: Response
|
||||
|
||||
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) async throws(InitializationError) {
|
||||
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) throws(InitializationError) {
|
||||
guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey }
|
||||
|
||||
guard let referencedNoteId = event.referenced_ids.first else { throw .missingRequestIdReference }
|
||||
@@ -85,7 +85,7 @@ extension WalletConnect {
|
||||
}
|
||||
}
|
||||
|
||||
struct WalletResponseErr: Codable {
|
||||
struct WalletResponseErr: Codable, Error {
|
||||
let code: Code?
|
||||
let message: String?
|
||||
|
||||
|
||||
@@ -105,6 +105,28 @@ extension WalletConnect {
|
||||
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
|
||||
return ev
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func refresh_wallet_information(damus_state: DamusState) async {
|
||||
damus_state.wallet.resetWalletStateInformation()
|
||||
await Self.update_wallet_information(damus_state: damus_state)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func update_wallet_information(damus_state: DamusState) async {
|
||||
guard let url = damus_state.settings.nostr_wallet_connect,
|
||||
let nwc = WalletConnectURL(str: url) else {
|
||||
return
|
||||
}
|
||||
|
||||
let flusher: OnFlush? = nil
|
||||
|
||||
let delay = 0.0 // We don't need a delay when fetching a transaction list or balance
|
||||
|
||||
WalletConnect.request_transaction_list(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher)
|
||||
WalletConnect.request_balance_information(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher)
|
||||
return
|
||||
}
|
||||
|
||||
static func handle_zap_success(state: DamusState, resp: WalletConnect.FullWalletResponse) {
|
||||
// find the pending zap and mark it as pending-confirmed
|
||||
|
||||
@@ -217,7 +217,16 @@ struct EventActionBar: View {
|
||||
AnyView(self.action_bar_content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var event_relay_url_strings: [String] {
|
||||
let relays = damus_state.nostrNetwork.relaysForEvent(event: event)
|
||||
if !relays.isEmpty {
|
||||
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
|
||||
}
|
||||
|
||||
return userProfile.getCappedRelayStrings()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
self.content
|
||||
.onAppear {
|
||||
@@ -233,7 +242,9 @@ struct EventActionBar: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show_share_sheet, onDismiss: { self.show_share_sheet = false }) {
|
||||
ShareSheet(activityItems: [URL(string: "https://damus.io/" + event.id.bech32)!])
|
||||
if let url = URL(string: "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))) {
|
||||
ShareSheet(activityItems: [url])
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show_repost_action, onDismiss: { self.show_repost_action = false }) {
|
||||
|
||||
@@ -262,7 +273,7 @@ struct EventActionBar: View {
|
||||
|
||||
func send_like(emoji: String) {
|
||||
guard let keypair = damus_state.keypair.to_full(),
|
||||
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
|
||||
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ struct RepostAction: View {
|
||||
dismiss()
|
||||
|
||||
guard let keypair = self.damus_state.keypair.to_full(),
|
||||
let boost = make_boost_event(keypair: keypair, boosted: self.event) else {
|
||||
let boost = make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,16 @@ struct ShareAction: View {
|
||||
self.userProfile = userProfile
|
||||
self._show_share = show_share
|
||||
}
|
||||
|
||||
|
||||
var event_relay_url_strings: [String] {
|
||||
let relays = userProfile.damus.nostrNetwork.relaysForEvent(event: event)
|
||||
if !relays.isEmpty {
|
||||
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
|
||||
}
|
||||
|
||||
return userProfile.getCappedRelayStrings()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
VStack {
|
||||
@@ -40,7 +49,7 @@ struct ShareAction: View {
|
||||
|
||||
ShareActionButton(img: "link", text: NSLocalizedString("Copy Link", comment: "Button to copy link to note")) {
|
||||
dismiss()
|
||||
UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(noteid: event.id, relays: userProfile.getCappedRelayStrings())))
|
||||
UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))
|
||||
}
|
||||
|
||||
let bookmarkImg = isBookmarked ? "bookmark.fill" : "bookmark"
|
||||
|
||||
@@ -28,6 +28,15 @@ enum AppAccessibilityIdentifiers: String {
|
||||
// MARK: Onboarding
|
||||
// Prefix: `onboarding`
|
||||
|
||||
/// Any interest option button on the "select your interests" page during onboarding
|
||||
case onboarding_interest_option_button
|
||||
|
||||
/// The "next" button on the onboarding interest page
|
||||
case onboarding_interest_page_next_page
|
||||
|
||||
/// The "next" button on the onboarding content settings page
|
||||
case onboarding_content_settings_page_next_page
|
||||
|
||||
/// The skip button on the onboarding sheet
|
||||
case onboarding_sheet_skip_button
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ struct ChatEventView: View {
|
||||
|
||||
func send_like(emoji: String) {
|
||||
guard let keypair = damus_state.keypair.to_full(),
|
||||
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
|
||||
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,16 @@ struct MenuItems: View {
|
||||
self.target_pubkey = target_pubkey
|
||||
self.profileModel = profileModel
|
||||
}
|
||||
|
||||
|
||||
var event_relay_url_strings: [String] {
|
||||
let relays = damus_state.nostrNetwork.relaysForEvent(event: event)
|
||||
if !relays.isEmpty {
|
||||
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
|
||||
}
|
||||
|
||||
return profileModel.getCappedRelayStrings()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Button {
|
||||
@@ -79,7 +88,7 @@ struct MenuItems: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = event.id.bech32
|
||||
UIPasteboard.general.string = Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))
|
||||
} label: {
|
||||
Label(NSLocalizedString("Copy note ID", comment: "Context menu option for copying the ID of the note."), image: "note-book")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//
|
||||
// FollowPackPreview.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 4/30/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct FollowPackUsers: View {
|
||||
let state: DamusState
|
||||
var publicKeys: [Pubkey]
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center) {
|
||||
|
||||
if !publicKeys.isEmpty {
|
||||
CondensedProfilePicturesView(state: state, pubkeys: publicKeys, maxPictures: 5)
|
||||
}
|
||||
|
||||
let followPackUserCount = publicKeys.count
|
||||
let nounString = pluralizedString(key: "follow_pack_user_count", count: followPackUserCount)
|
||||
let nounText = Text(verbatim: nounString).font(.subheadline).foregroundColor(.gray)
|
||||
Text("\(Text(verbatim: followPackUserCount.formatted()).font(.subheadline.weight(.medium))) \(nounText)", comment: "Sentence composed of 2 variables to describe how many people are in the follow pack. In source English, the first variable is the number of users, and the second variable is 'user' or 'users'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FollowPackBannerImage: View {
|
||||
let state: DamusState
|
||||
let options: EventViewOptions
|
||||
var image: URL? = nil
|
||||
var preview: Bool
|
||||
@State var blur_imgs: Bool
|
||||
|
||||
func Placeholder(url: URL, preview: Bool) -> some View {
|
||||
Group {
|
||||
if let meta = state.events.lookup_img_metadata(url: url),
|
||||
case .processed(let blurhash) = meta.state {
|
||||
Image(uiImage: blurhash)
|
||||
.resizable()
|
||||
.frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200)
|
||||
} else {
|
||||
DamusColors.adaptableWhite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func titleImage(url: URL, preview: Bool) -> some View {
|
||||
KFAnimatedImage(url)
|
||||
.callbackQueue(.dispatch(.global(qos:.background)))
|
||||
.backgroundDecode(true)
|
||||
.imageContext(.note, disable_animation: state.settings.disable_animation)
|
||||
.image_fade(duration: 0.25)
|
||||
.cancelOnDisappear(true)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.background {
|
||||
Placeholder(url: url, preview: preview)
|
||||
}
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200)
|
||||
.kfClickable()
|
||||
.cornerRadius(1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let url = image {
|
||||
if (self.options.contains(.no_media)) {
|
||||
EmptyView()
|
||||
} else if !blur_imgs {
|
||||
titleImage(url: url, preview: preview)
|
||||
} else {
|
||||
ZStack {
|
||||
titleImage(url: url, preview: preview)
|
||||
BlurOverlayView(blur_images: $blur_imgs, artifacts: nil, size: nil, damus_state: nil, parentView: .longFormView)
|
||||
.frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(NSLocalizedString("No cover image", comment: "Text letting user know there is no cover image."))
|
||||
.foregroundColor(.gray)
|
||||
.frame(width: 350, height: 180)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct FollowPackPreviewBody: View {
|
||||
let state: DamusState
|
||||
let event: FollowPackEvent
|
||||
let options: EventViewOptions
|
||||
let header: Bool
|
||||
@State var blur_imgs: Bool
|
||||
|
||||
@ObservedObject var artifacts: NoteArtifactsModel
|
||||
|
||||
init(state: DamusState, ev: FollowPackEvent, options: EventViewOptions, header: Bool, blur_imgs: Bool) {
|
||||
self.state = state
|
||||
self.event = ev
|
||||
self.options = options
|
||||
self.header = header
|
||||
self.blur_imgs = blur_imgs
|
||||
|
||||
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
|
||||
}
|
||||
|
||||
init(state: DamusState, ev: NostrEvent, options: EventViewOptions, header: Bool, blur_imgs: Bool) {
|
||||
self.state = state
|
||||
self.event = FollowPackEvent.parse(from: ev)
|
||||
self.options = options
|
||||
self.header = header
|
||||
self.blur_imgs = blur_imgs
|
||||
|
||||
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if options.contains(.wide) {
|
||||
Main.padding(.horizontal)
|
||||
} else {
|
||||
Main
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var Main: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
|
||||
if state.settings.media_previews {
|
||||
FollowPackBannerImage(state: state, options: options, image: event.image, preview: true, blur_imgs: blur_imgs)
|
||||
}
|
||||
|
||||
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of follow list event if it is untitled."))
|
||||
.font(header ? .title : .headline)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 5)
|
||||
|
||||
if let description = event.description {
|
||||
Text(description)
|
||||
.font(header ? .body : .caption)
|
||||
.foregroundColor(.gray)
|
||||
.padding(.horizontal, 10)
|
||||
} else {
|
||||
Text("")
|
||||
.font(header ? .body : .caption)
|
||||
.foregroundColor(.gray)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
|
||||
HStack(alignment: .center) {
|
||||
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
|
||||
.onTapGesture {
|
||||
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
|
||||
}
|
||||
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
|
||||
switch displayName {
|
||||
case .one(let one):
|
||||
Text(one)
|
||||
.font(.subheadline).foregroundColor(.gray)
|
||||
|
||||
case .both(username: let username, displayName: let displayName):
|
||||
HStack(spacing: 6) {
|
||||
Text(verbatim: displayName)
|
||||
.font(.subheadline).foregroundColor(.gray)
|
||||
|
||||
Text(verbatim: "@\(username)")
|
||||
.font(.subheadline).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
|
||||
FollowPackUsers(state: state, publicKeys: event.publicKeys)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.frame(width: 350, height: state.settings.media_previews ? 330 : 150, alignment: .leading)
|
||||
.background(DamusColors.neutral3)
|
||||
.cornerRadius(10)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(DamusColors.neutral1, lineWidth: 1)
|
||||
)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
|
||||
struct FollowPackPreview: View {
|
||||
let state: DamusState
|
||||
let event: FollowPackEvent
|
||||
let options: EventViewOptions
|
||||
@State var blur_imgs: Bool
|
||||
|
||||
init(state: DamusState, ev: NostrEvent, options: EventViewOptions, blur_imgs: Bool) {
|
||||
self.state = state
|
||||
self.event = FollowPackEvent.parse(from: ev)
|
||||
self.options = options.union(.no_mentions)
|
||||
self.blur_imgs = blur_imgs
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
FollowPackPreviewBody(state: state, ev: event, options: options, header: false, blur_imgs: blur_imgs)
|
||||
}
|
||||
}
|
||||
|
||||
let test_follow_list_event = FollowPackEvent.parse(from: NostrEvent(
|
||||
content: "",
|
||||
keypair: test_keypair,
|
||||
kind: NostrKind.longform.rawValue,
|
||||
tags: [
|
||||
["title", "DAMUSES"],
|
||||
["description", "Damus Team"],
|
||||
["published_at", "1685638715"],
|
||||
["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],
|
||||
["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"],
|
||||
["p", "17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4"],
|
||||
["p", "520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626"],
|
||||
["p", "2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1"],
|
||||
["p", "bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91"],
|
||||
["p", "e7424ad457e512fdf4764a56bf6d428a06a13a1006af1fb8e0fe32f6d03265c7"],
|
||||
["p", "b88c7f007bbf3bc2fcaeff9e513f186bab33782c0baa6a6cc12add78b9110ba3"],
|
||||
["p", "4a0510f26880d40e432f4865cb5714d9d3c200ca6ebb16b418ae6c555f574967"],
|
||||
["image", "https://damus.io/img/logo.png"],
|
||||
])!
|
||||
)
|
||||
|
||||
|
||||
struct FollowPackPreview_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
FollowPackPreview(state: test_damus_state, ev: test_follow_list_event.event, options: [], blur_imgs: false)
|
||||
}
|
||||
.frame(height: 400)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//
|
||||
// FollowPackTimeline.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 5/6/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct FollowPackTimelineView<Content: View>: View {
|
||||
@ObservedObject var events: EventHolder
|
||||
@Binding var loading: Bool
|
||||
|
||||
let damus: DamusState
|
||||
let show_friend_icon: Bool
|
||||
let filter: (NostrEvent) -> Bool
|
||||
let content: Content?
|
||||
let apply_mute_rules: Bool
|
||||
|
||||
init(events: EventHolder, loading: Binding<Bool>, headerHeight: Binding<CGFloat>, headerOffset: Binding<CGFloat>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
|
||||
self.events = events
|
||||
self._loading = loading
|
||||
self.damus = damus
|
||||
self.show_friend_icon = show_friend_icon
|
||||
self.filter = filter
|
||||
self.apply_mute_rules = apply_mute_rules
|
||||
self.content = content?()
|
||||
}
|
||||
|
||||
init(events: EventHolder, loading: Binding<Bool>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
|
||||
self.events = events
|
||||
self._loading = loading
|
||||
self.damus = damus
|
||||
self.show_friend_icon = show_friend_icon
|
||||
self.filter = filter
|
||||
self.apply_mute_rules = apply_mute_rules
|
||||
self.content = content?()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MainContent
|
||||
}
|
||||
|
||||
var MainContent: some View {
|
||||
ScrollViewReader { scroller in
|
||||
ScrollView(.horizontal) {
|
||||
if let content {
|
||||
content
|
||||
}
|
||||
|
||||
Color.clear
|
||||
.id("startblock")
|
||||
.frame(height: 0)
|
||||
|
||||
FollowPackInnerView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
|
||||
.redacted(reason: loading ? .placeholder : [])
|
||||
.shimmer(loading)
|
||||
.disabled(loading)
|
||||
.background {
|
||||
GeometryReader { proxy -> Color in
|
||||
handle_scroll_queue(proxy, queue: self.events)
|
||||
return Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: "scroll")
|
||||
.onReceive(handle_notify(.scroll_to_top)) { () in
|
||||
events.flush()
|
||||
self.events.should_queue = false
|
||||
scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
events.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FollowPackInnerView: View {
|
||||
@ObservedObject var events: EventHolder
|
||||
let state: DamusState
|
||||
let filter: (NostrEvent) -> Bool
|
||||
|
||||
init(events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) {
|
||||
self.events = events
|
||||
self.state = damus
|
||||
self.filter = apply_mute_rules ? { filter($0) && !damus.mutelist_manager.is_event_muted($0) } : filter
|
||||
}
|
||||
|
||||
var event_options: EventViewOptions {
|
||||
if self.state.settings.truncate_timeline_text {
|
||||
return [.wide, .truncate_content]
|
||||
}
|
||||
|
||||
return [.wide]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LazyHStack(spacing: 0) {
|
||||
let events = self.events.events
|
||||
if events.isEmpty {
|
||||
EmptyTimelineView()
|
||||
} else {
|
||||
let evs = events.filter(filter)
|
||||
let indexed = Array(zip(evs, 0...))
|
||||
ForEach(indexed, id: \.0.id) { tup in
|
||||
let ev = tup.0
|
||||
let ind = tup.1
|
||||
let blur_imgs = should_blur_images(settings: state.settings, contacts: state.contacts, ev: ev, our_pubkey: state.pubkey)
|
||||
if ev.kind == NostrKind.follow_list.rawValue {
|
||||
FollowPackPreview(state: state, ev: ev, options: event_options, blur_imgs: blur_imgs)
|
||||
.onTapGesture {
|
||||
state.nav.push(route: Route.FollowPack(followPack: ev, model: FollowPackModel(damus_state: state), blur_imgs: blur_imgs))
|
||||
}
|
||||
.padding(.top, 7)
|
||||
.onAppear {
|
||||
let to_preload =
|
||||
Array([indexed[safe: ind+1]?.0,
|
||||
indexed[safe: ind+2]?.0,
|
||||
indexed[safe: ind+3]?.0,
|
||||
indexed[safe: ind+4]?.0,
|
||||
indexed[safe: ind+5]?.0
|
||||
].compactMap({ $0 }))
|
||||
|
||||
preload_events(state: state, events: to_preload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// FollowPackView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 4/30/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct FollowPackView: View {
|
||||
let state: DamusState
|
||||
let event: FollowPackEvent
|
||||
@StateObject var model: FollowPackModel
|
||||
@State var blur_imgs: Bool
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@ObservedObject var artifacts: NoteArtifactsModel
|
||||
|
||||
init(state: DamusState, ev: FollowPackEvent, model: FollowPackModel, blur_imgs: Bool) {
|
||||
self.state = state
|
||||
self.event = ev
|
||||
self._model = StateObject(wrappedValue: model)
|
||||
self.blur_imgs = blur_imgs
|
||||
|
||||
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
|
||||
}
|
||||
|
||||
init(state: DamusState, ev: NostrEvent, model: FollowPackModel, blur_imgs: Bool) {
|
||||
self.state = state
|
||||
self.event = FollowPackEvent.parse(from: ev)
|
||||
self._model = StateObject(wrappedValue: model)
|
||||
self.blur_imgs = blur_imgs
|
||||
|
||||
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
|
||||
}
|
||||
|
||||
func content_filter(_ pubkeys: [Pubkey]) -> ((NostrEvent) -> Bool) {
|
||||
var filters = ContentFilters.defaults(damus_state: self.state)
|
||||
filters.append({ pubkeys.contains($0.pubkey) })
|
||||
return ContentFilters(filters: filters).filter
|
||||
}
|
||||
|
||||
enum FollowPackTabSelection: Int {
|
||||
case people = 0
|
||||
case posts = 1
|
||||
}
|
||||
|
||||
@State var tab_selection: FollowPackTabSelection = .people
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ScrollView {
|
||||
FollowPackHeader
|
||||
|
||||
FollowPackTabs
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if model.events.events.isEmpty {
|
||||
model.subscribe(follow_pack_users: event.publicKeys)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
model.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
var tabs: [(String, FollowPackTabSelection)] {
|
||||
let tabs = [
|
||||
(NSLocalizedString("People", comment: "Label for filter for seeing the people in this follow pack."), FollowPackTabSelection.people),
|
||||
(NSLocalizedString("Posts", comment: "Label for filter for seeing the posts from the people in this follow pack."), FollowPackTabSelection.posts)
|
||||
]
|
||||
return tabs
|
||||
}
|
||||
|
||||
var FollowPackTabs: some View {
|
||||
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
CustomPicker(tabs: tabs, selection: $tab_selection)
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
|
||||
if tab_selection == FollowPackTabSelection.people {
|
||||
LazyVStack(alignment: .leading) {
|
||||
ForEach(event.publicKeys.reversed(), id: \.self) { pk in
|
||||
FollowUserView(target: .pubkey(pk), damus_state: state)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(.bottom, 50)
|
||||
.tag(FollowPackTabSelection.people)
|
||||
.id(FollowPackTabSelection.people)
|
||||
}
|
||||
|
||||
if tab_selection == FollowPackTabSelection.posts {
|
||||
InnerTimelineView(events: model.events, damus: state, filter: content_filter(event.publicKeys))
|
||||
}
|
||||
}
|
||||
.onAppear() {
|
||||
model.subscribe(follow_pack_users: event.publicKeys)
|
||||
}
|
||||
.onDisappear {
|
||||
model.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
var FollowPackHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
|
||||
if state.settings.media_previews {
|
||||
FollowPackBannerImage(state: state, options: EventViewOptions(), image: event.image, preview: false, blur_imgs: blur_imgs)
|
||||
}
|
||||
|
||||
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of follow list event if it is untitled."))
|
||||
.font(.title)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 5)
|
||||
|
||||
if let description = event.description {
|
||||
Text(description)
|
||||
.font(.body)
|
||||
.foregroundColor(.gray)
|
||||
.padding(.horizontal, 10)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
HStack(alignment: .center) {
|
||||
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
|
||||
.onTapGesture {
|
||||
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
|
||||
}
|
||||
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
|
||||
switch displayName {
|
||||
case .one(let one):
|
||||
Text("Created by \(one)", comment: "Lets the user know who created this follow pack.")
|
||||
.font(.subheadline).foregroundColor(.gray)
|
||||
|
||||
case .both(username: let username, displayName: let displayName):
|
||||
HStack(spacing: 6) {
|
||||
Text("Created by \(displayName)", comment: "Lets the user know who created this follow pack.")
|
||||
.font(.subheadline).foregroundColor(.gray)
|
||||
|
||||
Text(verbatim: "@\(username)")
|
||||
.font(.subheadline).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
HStack(alignment: .center) {
|
||||
FollowPackUsers(state: state, publicKeys: event.publicKeys)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct FollowPackView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
FollowPackView(state: test_damus_state, ev: test_follow_list_event, model: FollowPackModel(damus_state: test_damus_state), blur_imgs: false)
|
||||
}
|
||||
.frame(height: 400)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
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):
|
||||
|
||||
@@ -47,20 +47,6 @@ struct MutelistView: View {
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(NSLocalizedString("Users", comment: "Section header title for a list of muted users.")) {
|
||||
ForEach(users, id: \.self) { user in
|
||||
if case let MuteItem.user(pubkey, _) = user {
|
||||
UserViewRow(damus_state: damus_state, pubkey: pubkey)
|
||||
.id(pubkey)
|
||||
.swipeActions {
|
||||
RemoveAction(item: .user(pubkey, nil))
|
||||
}
|
||||
.onTapGesture {
|
||||
damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(NSLocalizedString("Hashtags", comment: "Section header title for a list of hashtags that are muted.")) {
|
||||
ForEach(hashtags, id: \.self) { item in
|
||||
if case let MuteItem.hashtag(hashtag, _) = item {
|
||||
@@ -86,10 +72,7 @@ struct MutelistView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(
|
||||
header: Text(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")),
|
||||
footer: Text("").padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
|
||||
) {
|
||||
Section(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")) {
|
||||
ForEach(threads, id: \.self) { item in
|
||||
if case let MuteItem.thread(note_id, _) = item {
|
||||
if let event = damus_state.events.lookup(note_id) {
|
||||
@@ -104,6 +87,23 @@ struct MutelistView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(
|
||||
header: Text(NSLocalizedString("Users", comment: "Section header title for a list of muted users.")),
|
||||
footer: Text("").padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
|
||||
) {
|
||||
ForEach(users, id: \.self) { user in
|
||||
if case let MuteItem.user(pubkey, _) = user {
|
||||
UserViewRow(damus_state: damus_state, pubkey: pubkey)
|
||||
.id(pubkey)
|
||||
.swipeActions {
|
||||
RemoveAction(item: .user(pubkey, nil))
|
||||
}
|
||||
.onTapGesture {
|
||||
damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(NSLocalizedString("Muted", comment: "Navigation title of view to see list of muted users & phrases."))
|
||||
.onAppear {
|
||||
|
||||
@@ -73,15 +73,40 @@ struct NoteContentView: View {
|
||||
}
|
||||
|
||||
var preview: LinkViewRepresentable? {
|
||||
guard !blur_images,
|
||||
case .loaded(let preview) = preview_model.state,
|
||||
guard case .loaded(let preview) = preview_model.state,
|
||||
case .value(let cached) = preview else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// If either
|
||||
// (1) the blur images setting is enabled
|
||||
// (2) the media previews setting is disabled
|
||||
// (3) this note content view does not display media
|
||||
// then do not show media in the link preview.
|
||||
if blur_images || !damus_state.settings.media_previews || self.options.contains(.no_media) {
|
||||
return linkPreviewWithNoMedia(cached)
|
||||
}
|
||||
|
||||
// If media is already being shown, do not show media in the link preview
|
||||
// to avoid taking up additional screen space.
|
||||
if case let .separated(separated) = note_artifacts, !separated.media.isEmpty && !self.options.contains(.no_media) {
|
||||
return linkPreviewWithNoMedia(cached)
|
||||
}
|
||||
|
||||
return LinkViewRepresentable(meta: .linkmeta(cached))
|
||||
}
|
||||
|
||||
|
||||
// Creates a LinkViewRepresentable without media previews.
|
||||
func linkPreviewWithNoMedia(_ cached: CachedMetadata) -> LinkViewRepresentable? {
|
||||
let linkMetadata = LPLinkMetadata()
|
||||
|
||||
linkMetadata.originalURL = cached.meta.originalURL
|
||||
linkMetadata.title = cached.meta.title
|
||||
linkMetadata.url = cached.meta.url
|
||||
|
||||
return LinkViewRepresentable(meta: .linkmeta(CachedMetadata(meta: linkMetadata)))
|
||||
}
|
||||
|
||||
func truncatedText(content: CompatibleText) -> some View {
|
||||
Group {
|
||||
if truncate_very_short {
|
||||
@@ -108,7 +133,7 @@ struct NoteContentView: View {
|
||||
|
||||
func previewView(links: [URL]) -> some View {
|
||||
Group {
|
||||
if let preview = self.preview, !blur_images {
|
||||
if let preview = self.preview {
|
||||
if let preview_height {
|
||||
preview
|
||||
.frame(height: preview_height)
|
||||
@@ -181,7 +206,7 @@ struct NoteContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if damus_state.settings.media_previews, has_previews {
|
||||
if has_previews {
|
||||
if with_padding {
|
||||
previewView(links: artifacts.links).padding(.horizontal)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//
|
||||
// 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])
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_interest_page_next_page.rawValue)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_interest_option_button.rawValue)
|
||||
.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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// 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])
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_content_settings_page_next_page.rawValue)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
self.damus_state = damus_state
|
||||
loadSuggestedUserGroups()
|
||||
let pubkeys = getPubkeys(groups: groups)
|
||||
subscribeToSuggestedProfiles(pubkeys: pubkeys)
|
||||
|
||||
/// 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
|
||||
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
|
||||
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
return
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// 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 packsById
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
do {
|
||||
let groups = try decoder.decode([SuggestedUserGroup].self, from: data)
|
||||
self.groups = groups
|
||||
} catch {
|
||||
print(error.localizedDescription.localizedLowercase)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getPubkeys(groups: [SuggestedUserGroup]) -> [Pubkey] {
|
||||
var pubkeys: [Pubkey] = []
|
||||
for group in groups {
|
||||
pubkeys.append(contentsOf: group.users)
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
return pubkeys
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
guard case .nostr_event(let nev) = ev else {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
+16
-10
@@ -798,18 +798,18 @@ private func isAlphanumeric(_ char: Character) -> Bool {
|
||||
return char.isLetter || char.isNumber
|
||||
}
|
||||
|
||||
func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair) -> [[String]] {
|
||||
func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair, relayURL: RelayURL?) -> [[String]] {
|
||||
guard let nip10 = replying_to.thread_reply() else {
|
||||
// we're replying to a post that isn't in a thread,
|
||||
// just add a single reply-to-root tag
|
||||
return [["e", replying_to.id.hex(), "", "root"]]
|
||||
return [["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "root"]]
|
||||
}
|
||||
|
||||
// otherwise use the root tag from the parent's nip10 reply and include the note
|
||||
// that we are replying to's note id.
|
||||
let tags = [
|
||||
["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"],
|
||||
["e", replying_to.id.hex(), "", "reply"]
|
||||
["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "reply"]
|
||||
]
|
||||
|
||||
return tags
|
||||
@@ -863,7 +863,9 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction,
|
||||
func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) -> NostrPost {
|
||||
let post = NSMutableAttributedString(attributedString: post)
|
||||
post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in
|
||||
if let link = attributes[.link] as? String {
|
||||
let linkValue = attributes[.link]
|
||||
let link = (linkValue as? String) ?? (linkValue as? URL)?.absoluteString
|
||||
if let link {
|
||||
let nextCharIndex = range.upperBound
|
||||
if nextCharIndex < post.length,
|
||||
let nextChar = post.attributedSubstring(from: NSRange(location: nextCharIndex, length: 1)).string.first,
|
||||
@@ -900,15 +902,19 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction,
|
||||
switch action {
|
||||
case .replying_to(let replying_to):
|
||||
// start off with the reply tags
|
||||
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
|
||||
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair, relayURL: state.nostrNetwork.relaysForEvent(event: replying_to).first)
|
||||
|
||||
case .quoting(let ev):
|
||||
content.append("\n\nnostr:" + bech32_note_id(ev.id))
|
||||
let relay_urls = state.nostrNetwork.relaysForEvent(event: ev)
|
||||
let nevent = Bech32Object.encode(.nevent(NEvent(event: ev, relays: relay_urls.prefix(4).map { $0.absoluteString })))
|
||||
content.append("\n\nnostr:\(nevent)")
|
||||
|
||||
tags.append(["q", ev.id.hex()]);
|
||||
|
||||
if let quoted_ev = state.events.lookup(ev.id) {
|
||||
tags.append(["p", quoted_ev.pubkey.hex()])
|
||||
if let first_relay = relay_urls.first?.absoluteString {
|
||||
tags.append(["q", ev.id.hex(), first_relay, ev.pubkey.hex()]);
|
||||
tags.append(["p", ev.pubkey.hex(), first_relay])
|
||||
} else {
|
||||
tags.append(["q", ev.id.hex(), "", ev.pubkey.hex()]);
|
||||
tags.append(["p", ev.pubkey.hex()])
|
||||
}
|
||||
case .posting, .highlighting, .sharing:
|
||||
break
|
||||
|
||||
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
import Combine
|
||||
|
||||
let BANNER_HEIGHT: CGFloat = 150.0;
|
||||
fileprivate let Scroll_height: CGFloat = 700.0
|
||||
|
||||
struct EditMetadataView: View {
|
||||
let damus_state: DamusState
|
||||
@@ -79,11 +80,14 @@ struct EditMetadataView: View {
|
||||
func topSection(topLevelGeo: GeometryProxy) -> some View {
|
||||
ZStack(alignment: .top) {
|
||||
GeometryReader { geo in
|
||||
let offset = geo.frame(in: .global).minY
|
||||
EditBannerImageView(damus_state: damus_state, viewModel: bannerUploadObserver, callback: uploadedBanner(image_url:), safeAreaInsets: topLevelGeo.safeAreaInsets, banner_image: URL(string: banner))
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: geo.size.width, height: BANNER_HEIGHT)
|
||||
.frame(width: geo.size.width, height: offset > 0 ? BANNER_HEIGHT + offset : BANNER_HEIGHT)
|
||||
.clipped()
|
||||
}.frame(height: BANNER_HEIGHT)
|
||||
.offset(y: offset > 0 ? -offset : 0) // Pin the top
|
||||
}
|
||||
.frame(height: BANNER_HEIGHT)
|
||||
VStack(alignment: .leading) {
|
||||
let pfp_size: CGFloat = 90.0
|
||||
|
||||
@@ -129,74 +133,78 @@ struct EditMetadataView: View {
|
||||
|
||||
func content(topLevelGeo: GeometryProxy) -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
self.topSection(topLevelGeo: topLevelGeo)
|
||||
Form {
|
||||
Section(NSLocalizedString("Your Name", comment: "Label for Your Name section of user profile form.")) {
|
||||
let display_name_placeholder = "Satoshi Nakamoto"
|
||||
TextField(display_name_placeholder, text: $display_name)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
ScrollView(showsIndicators: false) {
|
||||
self.topSection(topLevelGeo: topLevelGeo)
|
||||
|
||||
Section(NSLocalizedString("Username", comment: "Label for Username section of user profile form.")) {
|
||||
let username_placeholder = "satoshi"
|
||||
TextField(username_placeholder, text: $name)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
|
||||
}
|
||||
|
||||
Section(NSLocalizedString("Website", comment: "Label for Website section of user profile form.")) {
|
||||
TextField(NSLocalizedString("https://jb55.com", comment: "Placeholder example text for website URL for user profile."), text: $website)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
|
||||
Section(NSLocalizedString("About Me", comment: "Label for About Me section of user profile form.")) {
|
||||
let placeholder = NSLocalizedString("Absolute Boss", comment: "Placeholder text for About Me description.")
|
||||
ZStack(alignment: .topLeading) {
|
||||
TextEditor(text: $about)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.frame(minHeight: 45, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text(about.isEmpty ? placeholder : about)
|
||||
.padding(4)
|
||||
.opacity(about.isEmpty ? 1 : 0)
|
||||
.foregroundColor(Color(uiColor: .placeholderText))
|
||||
Form {
|
||||
Section(NSLocalizedString("Your Name", comment: "Label for Your Name section of user profile form.")) {
|
||||
let display_name_placeholder = "Satoshi Nakamoto"
|
||||
TextField(display_name_placeholder, text: $display_name)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
}
|
||||
|
||||
Section(NSLocalizedString("Bitcoin Lightning Tips", comment: "Label for Bitcoin Lightning Tips section of user profile form.")) {
|
||||
TextField(NSLocalizedString("Lightning Address or LNURL", comment: "Placeholder text for entry of Lightning Address or LNURL."), text: $ln)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.onReceive(Just(ln)) { newValue in
|
||||
self.ln = newValue.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
Section(content: {
|
||||
TextField(NSLocalizedString("jb55@jb55.com", comment: "Placeholder example text for identifier used for Nostr addresses."), text: $nip05)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.onReceive(Just(nip05)) { newValue in
|
||||
self.nip05 = newValue.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}, header: {
|
||||
Text("Nostr Address", comment: "Label for the Nostr Address section of user profile form.")
|
||||
}, footer: {
|
||||
switch validate_nostr_address(nip05: nip05_parts, nip05_str: nip05) {
|
||||
case .empty:
|
||||
// without this, the keyboard dismisses unnecessarily when the footer changes state
|
||||
Text("")
|
||||
case .valid:
|
||||
Text("")
|
||||
case .invalid:
|
||||
Text("'\(nip05)' is an invalid Nostr address. It should look like an email address.", comment: "Description of why the Nostr address is invalid.")
|
||||
|
||||
Section(NSLocalizedString("Username", comment: "Label for Username section of user profile form.")) {
|
||||
let username_placeholder = "satoshi"
|
||||
TextField(username_placeholder, text: $name)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
Section(NSLocalizedString("Website", comment: "Label for Website section of user profile form.")) {
|
||||
TextField(NSLocalizedString("https://jb55.com", comment: "Placeholder example text for website URL for user profile."), text: $website)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
|
||||
Section(NSLocalizedString("About Me", comment: "Label for About Me section of user profile form.")) {
|
||||
let placeholder = NSLocalizedString("Absolute Boss", comment: "Placeholder text for About Me description.")
|
||||
ZStack(alignment: .topLeading) {
|
||||
TextEditor(text: $about)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.frame(minHeight: 45, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text(about.isEmpty ? placeholder : about)
|
||||
.padding(4)
|
||||
.opacity(about.isEmpty ? 1 : 0)
|
||||
.foregroundColor(Color(uiColor: .placeholderText))
|
||||
}
|
||||
}
|
||||
|
||||
Section(NSLocalizedString("Bitcoin Lightning Tips", comment: "Label for Bitcoin Lightning Tips section of user profile form.")) {
|
||||
TextField(NSLocalizedString("Lightning Address or LNURL", comment: "Placeholder text for entry of Lightning Address or LNURL."), text: $ln)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.onReceive(Just(ln)) { newValue in
|
||||
self.ln = newValue.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
Section(content: {
|
||||
TextField(NSLocalizedString("jb55@jb55.com", comment: "Placeholder example text for identifier used for Nostr addresses."), text: $nip05)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.onReceive(Just(nip05)) { newValue in
|
||||
self.nip05 = newValue.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}, header: {
|
||||
Text("Nostr Address", comment: "Label for the Nostr Address section of user profile form.")
|
||||
}, footer: {
|
||||
switch validate_nostr_address(nip05: nip05_parts, nip05_str: nip05) {
|
||||
case .empty:
|
||||
// without this, the keyboard dismisses unnecessarily when the footer changes state
|
||||
Text("")
|
||||
case .valid:
|
||||
Text("")
|
||||
case .invalid:
|
||||
Text("'\(nip05)' is an invalid Nostr address. It should look like an email address.", comment: "Description of why the Nostr address is invalid.")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
.frame(height: Scroll_height)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
|
||||
@@ -123,7 +123,7 @@ struct ProfileView: View {
|
||||
var filters = ContentFilters.defaults(damus_state: damus_state)
|
||||
filters.append(fstate.filter)
|
||||
switch fstate {
|
||||
case .posts, .posts_and_replies:
|
||||
case .posts, .posts_and_replies, .follow_list:
|
||||
filters.append({ profile.pubkey == $0.pubkey })
|
||||
case .conversations:
|
||||
filters.append({ profile.conversation_events.contains($0.id) } )
|
||||
|
||||
@@ -15,8 +15,9 @@ struct SearchHomeView: View {
|
||||
@State var search: String = ""
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
var content_filter: (NostrEvent) -> Bool {
|
||||
let filters = ContentFilters.defaults(damus_state: self.damus_state)
|
||||
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
|
||||
var filters = ContentFilters.defaults(damus_state: damus_state)
|
||||
filters.append(fstate.filter)
|
||||
return ContentFilters(filters: filters).filter
|
||||
}
|
||||
|
||||
@@ -52,21 +53,20 @@ struct SearchHomeView: View {
|
||||
loading: $model.loading,
|
||||
damus: damus_state,
|
||||
show_friend_icon: true,
|
||||
filter: { ev in
|
||||
if !content_filter(ev) {
|
||||
return false
|
||||
}
|
||||
|
||||
let event_muted = damus_state.mutelist_manager.is_event_muted(ev)
|
||||
if event_muted {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
filter:content_filter(FilterState.posts),
|
||||
content: {
|
||||
AnyView(VStack {
|
||||
SuggestedHashtagsView(damus_state: damus_state, max_items: 5, events: model.events)
|
||||
AnyView(VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(PinkGradient)
|
||||
Text("Follow Packs", comment: "A label indicating that the items below it are follow packs")
|
||||
.foregroundStyle(PinkGradient)
|
||||
}
|
||||
.padding(.top)
|
||||
.padding(.horizontal)
|
||||
|
||||
FollowPackTimelineView<AnyView>(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true,filter:content_filter(FilterState.follow_list)
|
||||
).padding(.bottom)
|
||||
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
|
||||
@@ -17,19 +17,29 @@ struct BalanceView: View {
|
||||
Text("Current balance", comment: "Label for displaying current wallet balance")
|
||||
.foregroundStyle(DamusColors.neutral6)
|
||||
if let balance {
|
||||
self.numericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(integerLiteral: Int(balance)), number: .decimal))
|
||||
NumericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(integerLiteral: Int(balance)), number: .decimal), hide_balance: $hide_balance)
|
||||
}
|
||||
else {
|
||||
// Make sure we do not show any numeric value to the user when still loading (or when failed to load)
|
||||
// This is important because if we show a numeric value like "zero" when things are not loaded properly, we risk scaring the user into thinking that they have lost funds.
|
||||
self.numericalBalanceView(text: "??")
|
||||
Text(verbatim: "??")
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.70)
|
||||
.font(.veryVeryLargeTitle)
|
||||
.fontWeight(.heavy)
|
||||
.foregroundStyle(PinkGradient)
|
||||
.redacted(reason: .placeholder)
|
||||
.shimmer(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NumericalBalanceView: View {
|
||||
let text: String
|
||||
@Binding var hide_balance: Bool
|
||||
|
||||
func numericalBalanceView(text: String) -> some View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if hide_balance {
|
||||
Text(verbatim: "*****")
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//
|
||||
// LnurlAmountView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-06-18
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
class LnurlAmountModel: ObservableObject {
|
||||
@Published var custom_amount: String = "0"
|
||||
@Published var custom_amount_sats: Int? = 0
|
||||
@Published var processing: Bool = false
|
||||
@Published var error: String? = nil
|
||||
@Published var invoice: String? = nil
|
||||
@Published var zap_amounts: [ZapAmountItem] = []
|
||||
|
||||
func set_defaults(settings: UserSettingsStore) {
|
||||
let default_amount = settings.default_zap_amount
|
||||
custom_amount = String(default_amount)
|
||||
custom_amount_sats = default_amount
|
||||
zap_amounts = get_zap_amount_items(default_amount)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables the user to enter a Bitcoin amount to be sent. Based on `CustomizeZapView`.
|
||||
struct LnurlAmountView: View {
|
||||
let damus_state: DamusState
|
||||
let lnurlString: String
|
||||
let onInvoiceFetched: (Invoice) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@StateObject var model: LnurlAmountModel = LnurlAmountModel()
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@FocusState var isAmountFocused: Bool
|
||||
|
||||
init(damus_state: DamusState, lnurlString: String, onInvoiceFetched: @escaping (Invoice) -> Void, onCancel: @escaping () -> Void) {
|
||||
self.damus_state = damus_state
|
||||
self.lnurlString = lnurlString
|
||||
self.onInvoiceFetched = onInvoiceFetched
|
||||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
func AmountButton(zapAmountItem: ZapAmountItem) -> some View {
|
||||
let isSelected = model.custom_amount_sats == zapAmountItem.amount
|
||||
|
||||
return Button(action: {
|
||||
model.custom_amount_sats = zapAmountItem.amount
|
||||
model.custom_amount = String(zapAmountItem.amount)
|
||||
}) {
|
||||
let fmt = format_msats_abbrev(Int64(zapAmountItem.amount) * 1000)
|
||||
Text(verbatim: "\(zapAmountItem.icon)\n\(fmt)")
|
||||
.contentShape(Rectangle())
|
||||
.font(.headline)
|
||||
.frame(width: 70, height: 70)
|
||||
.foregroundColor(DamusColors.adaptableBlack)
|
||||
.background(isSelected ? DamusColors.adaptableWhite : DamusColors.adaptableGrey)
|
||||
.cornerRadius(15)
|
||||
.overlay(RoundedRectangle(cornerRadius: 15)
|
||||
.stroke(DamusColors.purple.opacity(isSelected ? 1.0 : 0.0), lineWidth: 2))
|
||||
}
|
||||
}
|
||||
|
||||
func amount_parts(_ n: Int) -> [ZapAmountItem] {
|
||||
var i: Int = -1
|
||||
let start = n * 4
|
||||
let end = start + 4
|
||||
|
||||
return model.zap_amounts.filter { _ in
|
||||
i += 1
|
||||
return i >= start && i < end
|
||||
}
|
||||
}
|
||||
|
||||
func AmountsPart(n: Int) -> some View {
|
||||
HStack(alignment: .center, spacing: 15) {
|
||||
ForEach(amount_parts(n)) { entry in
|
||||
AmountButton(zapAmountItem: entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var AmountGrid: some View {
|
||||
VStack {
|
||||
AmountsPart(n: 0)
|
||||
|
||||
AmountsPart(n: 1)
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
|
||||
var CustomAmountTextField: some View {
|
||||
VStack(alignment: .center, spacing: 0) {
|
||||
TextField("", text: $model.custom_amount)
|
||||
.focused($isAmountFocused)
|
||||
.task {
|
||||
self.isAmountFocused = true
|
||||
}
|
||||
.font(.system(size: 72, weight: .heavy))
|
||||
.minimumScaleFactor(0.01)
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.center)
|
||||
.onChange(of: model.custom_amount) { newValue in
|
||||
if let parsed = handle_string_amount(new_value: newValue) {
|
||||
model.custom_amount = parsed.formatted()
|
||||
model.custom_amount_sats = parsed
|
||||
} else {
|
||||
model.custom_amount = "0"
|
||||
model.custom_amount_sats = nil
|
||||
}
|
||||
}
|
||||
let noun = pluralizedString(key: "sats", count: model.custom_amount_sats ?? 0)
|
||||
Text(noun)
|
||||
.font(.system(size: 18, weight: .heavy))
|
||||
}
|
||||
}
|
||||
|
||||
func fetchInvoice() {
|
||||
guard let amount = model.custom_amount_sats, amount > 0 else {
|
||||
model.error = NSLocalizedString("Please enter a valid amount", comment: "Error message when no valid amount is entered for LNURL payment")
|
||||
return
|
||||
}
|
||||
|
||||
model.processing = true
|
||||
model.error = nil
|
||||
|
||||
Task { @MainActor in
|
||||
// For LNURL payments without zaps, we use nil for zapreq and comment
|
||||
// We just need the invoice for payment
|
||||
let msats = Int64(amount) * 1000
|
||||
|
||||
// First get the payment request from the LNURL
|
||||
guard let payreq = await fetch_static_payreq(lnurlString) else {
|
||||
model.processing = false
|
||||
model.error = NSLocalizedString("Error fetching LNURL payment information", comment: "Error message when LNURL fetch fails")
|
||||
return
|
||||
}
|
||||
|
||||
// Then fetch the invoice with the amount
|
||||
guard let invoiceStr = await fetch_zap_invoice(payreq, zapreq: nil, msats: msats, zap_type: .non_zap, comment: nil) else {
|
||||
model.processing = false
|
||||
model.error = NSLocalizedString("Error fetching lightning invoice", comment: "Error message when there was an error fetching a lightning invoice")
|
||||
return
|
||||
}
|
||||
|
||||
// Decode the invoice to validate it
|
||||
guard let invoice = decode_bolt11(invoiceStr) else {
|
||||
model.processing = false
|
||||
model.error = NSLocalizedString("Invalid lightning invoice received", comment: "Error message when the lightning invoice received from LNURL is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
// All good, pass the invoice back to the parent view
|
||||
model.processing = false
|
||||
onInvoiceFetched(invoice)
|
||||
}
|
||||
}
|
||||
|
||||
var PayButton: some View {
|
||||
VStack {
|
||||
if model.processing {
|
||||
Text("Processing...", comment: "Text to indicate that the app is in the process of fetching an invoice.")
|
||||
.padding()
|
||||
ProgressView()
|
||||
} else {
|
||||
Button(action: {
|
||||
fetchInvoice()
|
||||
}) {
|
||||
HStack {
|
||||
Text("Continue", comment: "Button to proceed with LNURL payment process.")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
}
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
.disabled(model.custom_amount_sats == 0 || model.custom_amount == "0")
|
||||
.opacity(model.custom_amount_sats == 0 || model.custom_amount == "0" ? 0.5 : 1.0)
|
||||
.padding(10)
|
||||
}
|
||||
|
||||
if let error = model.error {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var CancelButton: some View {
|
||||
Button(action: onCancel) {
|
||||
HStack {
|
||||
Text("Cancel", comment: "Button to cancel the LNURL payment process.")
|
||||
.font(.headline)
|
||||
.padding()
|
||||
}
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.buttonStyle(NeutralButtonStyle())
|
||||
.padding()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
ScrollView {
|
||||
VStack {
|
||||
Text("Enter Amount", comment: "Header text for LNURL payment amount entry screen")
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
.padding()
|
||||
|
||||
Text("How much would you like to send?", comment: "Instruction text for LNURL payment amount")
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.bottom)
|
||||
|
||||
CustomAmountTextField
|
||||
|
||||
AmountGrid
|
||||
|
||||
PayButton
|
||||
|
||||
CancelButton
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
model.set_defaults(settings: damus_state.settings)
|
||||
}
|
||||
.onTapGesture {
|
||||
hideKeyboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LnurlAmountView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
LnurlAmountView(
|
||||
damus_state: test_damus_state,
|
||||
lnurlString: "lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns",
|
||||
onInvoiceFetched: { _ in },
|
||||
onCancel: {}
|
||||
)
|
||||
.frame(width: 400, height: 600)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
struct NWCSettings: View {
|
||||
|
||||
@@ -16,6 +17,18 @@ struct NWCSettings: View {
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
// Budget sync state tracking
|
||||
@State private var isCoinosWallet: Bool = false
|
||||
@State private var maxWeeklyBudget: UInt64? = nil
|
||||
@State private var budgetSyncState: BudgetSyncState = .undefined
|
||||
|
||||
// Min/max budget values for slider
|
||||
private let minBudget: UInt64 = 100
|
||||
private let maxBudget: UInt64 = 10_000_000
|
||||
|
||||
// Slider min/max values for logarithmic scale (0-1 range)
|
||||
private let sliderMin: Double = 0.0
|
||||
private let sliderMax: Double = 1.0
|
||||
|
||||
func donation_binding() -> Binding<Double> {
|
||||
return Binding(get: {
|
||||
@@ -141,6 +154,75 @@ struct NWCSettings: View {
|
||||
|
||||
Toggle(NSLocalizedString("Hide balance", comment: "Setting to hide wallet balance."), isOn: $settings.hide_wallet_balance)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
if isCoinosWallet, let maxWeeklyBudget {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Max weekly budget", comment: "Label for setting the maximum weekly budget for Coinos wallet")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 2)
|
||||
Text("The maximum amount of funds that are allowed to be sent out from this wallet each week.", comment: "Description explaining the purpose of the 'Max weekly budget' setting for Coinos one-click setup wallets")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Slider(
|
||||
// Use a logarithmic scale for this slider to give more control to different kinds of users:
|
||||
//
|
||||
// - Users with higher budget tolerance can select very high amounts (e.g. Easy to go up to 5M or 10M sats)
|
||||
// - Conservative users can still have fine-grained control over lower amounts (e.g. Easy to switch between 500 and 1.5K sats)
|
||||
value: Binding(
|
||||
get: {
|
||||
// Convert from budget value to slider position (0-1)
|
||||
budgetToSliderPosition(budget: maxWeeklyBudget)
|
||||
},
|
||||
set: {
|
||||
// Convert from slider position to budget value
|
||||
let newValue = sliderPositionToBudget(position: $0)
|
||||
if self.maxWeeklyBudget != newValue {
|
||||
self.maxWeeklyBudget = newValue
|
||||
}
|
||||
}
|
||||
),
|
||||
in: sliderMin...sliderMax,
|
||||
onEditingChanged: { editing in
|
||||
if !editing {
|
||||
updateMaxWeeklyBudget()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Text(verbatim: format_msats(Int64(maxWeeklyBudget) * 1000))
|
||||
.foregroundColor(.gray)
|
||||
.frame(width: 150, alignment: .trailing)
|
||||
}
|
||||
|
||||
// Budget sync status
|
||||
HStack {
|
||||
switch budgetSyncState {
|
||||
case .undefined:
|
||||
EmptyView()
|
||||
case .success:
|
||||
HStack {
|
||||
Image("check-circle.fill")
|
||||
.foregroundStyle(.damusGreen)
|
||||
Text("Successfully updated", comment: "Label indicating success in updating budget")
|
||||
}
|
||||
case .syncing:
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Updating", comment: "Label indicating budget update is in progress")
|
||||
}
|
||||
case .failure(let error):
|
||||
Text(error)
|
||||
.foregroundStyle(.damusDangerPrimary)
|
||||
}
|
||||
}
|
||||
.padding(.top, 5)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
self.model.disconnect()
|
||||
@@ -156,6 +238,10 @@ struct NWCSettings: View {
|
||||
.padding()
|
||||
.onAppear() {
|
||||
model.initial_percent = model.settings.donation_percent
|
||||
checkIfCoinosWallet()
|
||||
if isCoinosWallet {
|
||||
fetchCurrentBudget()
|
||||
}
|
||||
}
|
||||
.onChange(of: model.settings.donation_percent) { p in
|
||||
let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey)
|
||||
@@ -186,6 +272,79 @@ struct NWCSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the current wallet is a Coinos one-click wallet
|
||||
private func checkIfCoinosWallet() {
|
||||
// Check condition 1: Relay is coinos.io
|
||||
let isRelayCoinos = nwc.relay.absoluteString == "wss://relay.coinos.io"
|
||||
|
||||
// Check condition 2: LUD16 matches expected format
|
||||
guard let keypair = damus_state.keypair.to_full() else {
|
||||
isCoinosWallet = false
|
||||
return
|
||||
}
|
||||
|
||||
let client = CoinosDeterministicAccountClient(userKeypair: keypair)
|
||||
let expectedLud16 = client.expectedLud16
|
||||
|
||||
isCoinosWallet = isRelayCoinos && nwc.lud16 == expectedLud16
|
||||
}
|
||||
|
||||
/// Fetches the current max weekly budget from Coinos
|
||||
private func fetchCurrentBudget() {
|
||||
guard let keypair = damus_state.keypair.to_full() else { return }
|
||||
|
||||
let client = CoinosDeterministicAccountClient(userKeypair: keypair)
|
||||
|
||||
Task {
|
||||
do {
|
||||
if let config = try await client.getNWCAppConnectionConfig(),
|
||||
let maxAmount = config.max_amount {
|
||||
DispatchQueue.main.async {
|
||||
self.maxWeeklyBudget = maxAmount
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.budgetSyncState = .failure(error: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the max weekly budget on Coinos
|
||||
private func updateMaxWeeklyBudget() {
|
||||
guard let maxWeeklyBudget else { return }
|
||||
guard let keypair = damus_state.keypair.to_full() else { return }
|
||||
|
||||
budgetSyncState = .syncing
|
||||
|
||||
let client = CoinosDeterministicAccountClient(userKeypair: keypair)
|
||||
|
||||
Task {
|
||||
do {
|
||||
// First ensure we're logged in
|
||||
try await client.loginIfNeeded()
|
||||
|
||||
// Update the connection with the new budget
|
||||
_ = try await client.updateNWCConnection(maxAmount: maxWeeklyBudget)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.budgetSyncState = .success
|
||||
|
||||
// Reset success state after a delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
if case .success = self.budgetSyncState {
|
||||
self.budgetSyncState = .undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.budgetSyncState = .failure(error: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountDetailsView: View {
|
||||
let nwc: WalletConnect.ConnectURL
|
||||
let damus_state: DamusState?
|
||||
@@ -233,6 +392,40 @@ struct NWCSettings: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Logarithmic scale conversions
|
||||
|
||||
/// Converts from budget value to a slider position (0-1 range)
|
||||
func budgetToSliderPosition(budget: UInt64) -> Double {
|
||||
// Ensure budget is within bounds
|
||||
let clampedBudget = max(minBudget, min(maxBudget, budget))
|
||||
|
||||
// Calculate the log scale position
|
||||
let minLog = log10(Double(minBudget))
|
||||
let maxLog = log10(Double(maxBudget))
|
||||
let budgetLog = log10(Double(clampedBudget))
|
||||
|
||||
// Convert to 0-1 range
|
||||
return (budgetLog - minLog) / (maxLog - minLog)
|
||||
}
|
||||
|
||||
// Convert from slider position (0-1) to budget value
|
||||
func sliderPositionToBudget(position: Double) -> UInt64 {
|
||||
// Ensure position is within bounds
|
||||
let clampedPosition = max(sliderMin, min(sliderMax, position))
|
||||
|
||||
// Calculate the log scale value
|
||||
let minLog = log10(Double(minBudget))
|
||||
let maxLog = log10(Double(maxBudget))
|
||||
let valueLog = minLog + clampedPosition * (maxLog - minLog)
|
||||
|
||||
// Convert to budget value and round to nearest 100 to make the number look "cleaner"
|
||||
let exactValue = pow(10, valueLog)
|
||||
let roundedValue = round(exactValue / 100) * 100
|
||||
|
||||
return UInt64(roundedValue)
|
||||
}
|
||||
}
|
||||
|
||||
struct NWCSettings_Previews: PreviewProvider {
|
||||
@@ -241,3 +434,16 @@ struct NWCSettings_Previews: PreviewProvider {
|
||||
NWCSettings(damus_state: tds, nwc: test_wallet_connect_url, model: WalletModel(state: .existing(test_wallet_connect_url), settings: tds.settings), settings: tds.settings)
|
||||
}
|
||||
}
|
||||
|
||||
extension NWCSettings {
|
||||
enum BudgetSyncState: Equatable {
|
||||
/// State is unknown
|
||||
case undefined
|
||||
/// Budget is successfully updated
|
||||
case success
|
||||
/// Budget is being updated
|
||||
case syncing
|
||||
/// There was a failure during update
|
||||
case failure(error: String)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//
|
||||
// SendPaymentView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-06-13.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CodeScanner
|
||||
|
||||
fileprivate let SEND_PAYMENT_TIMEOUT: Duration = .seconds(10)
|
||||
|
||||
/// A view that allows a user to pay a lightning invoice
|
||||
struct SendPaymentView: View {
|
||||
|
||||
// MARK: - Helper structures
|
||||
|
||||
/// Represents the state of the invoice payment process
|
||||
enum SendState {
|
||||
case enterInvoice(scannerMessage: String?)
|
||||
case confirmPayment(invoice: Invoice)
|
||||
case enterLnurlAmount(lnurl: String)
|
||||
case processing
|
||||
case completed
|
||||
case failed(error: HumanReadableError)
|
||||
}
|
||||
|
||||
typealias HumanReadableError = ErrorView.UserPresentableError
|
||||
|
||||
|
||||
// MARK: - Immutable members
|
||||
|
||||
let damus_state: DamusState
|
||||
let model: WalletModel
|
||||
let nwc: WalletConnectURL
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
|
||||
// MARK: - State management
|
||||
|
||||
@State private var sendState: SendState = .enterInvoice(scannerMessage: nil) {
|
||||
didSet {
|
||||
switch sendState {
|
||||
case .enterInvoice, .confirmPayment, .processing, .enterLnurlAmount:
|
||||
break
|
||||
case .completed:
|
||||
// Refresh wallet to reflect new balance after payment
|
||||
Task { await WalletConnect.refresh_wallet_information(damus_state: damus_state) }
|
||||
case .failed:
|
||||
// Even when a wallet says it has failed, update balance just in case it is a false negative,
|
||||
// This might prevent the user from accidentally sending a payment twice in case of a bug.
|
||||
Task { await WalletConnect.refresh_wallet_information(damus_state: damus_state) }
|
||||
}
|
||||
}
|
||||
}
|
||||
var isShowingScanner: Bool {
|
||||
if case .enterInvoice = sendState { true } else { false }
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
switch sendState {
|
||||
case .enterInvoice(let scannerMessage):
|
||||
invoiceInputView(scannerMessage: scannerMessage)
|
||||
.padding(40)
|
||||
case .confirmPayment(let invoice):
|
||||
confirmationView(invoice: invoice)
|
||||
.padding(40)
|
||||
case .enterLnurlAmount(let lnurl):
|
||||
LnurlAmountView(
|
||||
damus_state: damus_state,
|
||||
lnurlString: lnurl,
|
||||
onInvoiceFetched: { invoice in
|
||||
sendState = .confirmPayment(invoice: invoice)
|
||||
},
|
||||
onCancel: {
|
||||
sendState = .enterInvoice(scannerMessage: nil)
|
||||
}
|
||||
)
|
||||
case .processing:
|
||||
processingView
|
||||
.padding(40)
|
||||
case .completed:
|
||||
completedView
|
||||
.padding(40)
|
||||
case .failed(error: let error):
|
||||
failedView(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func invoiceInputView(scannerMessage: String?) -> some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Scan Lightning Invoice", comment: "Title for the invoice scanning screen")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
|
||||
CodeScannerView(
|
||||
codeTypes: [.qr],
|
||||
scanMode: .continuous,
|
||||
showViewfinder: true, // The scan only seems to work if it fits the bounding box, so let's make this visible to hint that to the users
|
||||
simulatedData: "lightning:lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r",
|
||||
completion: handleScan
|
||||
)
|
||||
.frame(height: 300)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(Color.accentColor, lineWidth: 2)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
|
||||
VStack(spacing: 15) {
|
||||
Button(action: {
|
||||
if let pastedInvoice = getPasteboardContent() {
|
||||
processUserInput(pastedInvoice)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
Text("Paste from Clipboard", comment: "Button to paste invoice from clipboard")
|
||||
}
|
||||
.frame(minWidth: 250, maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(NeutralButtonStyle())
|
||||
.accessibilityLabel(NSLocalizedString("Paste invoice from clipboard", comment: "Accessibility label for the invoice paste button"))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if let scannerMessage {
|
||||
Text(scannerMessage)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 10)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
func confirmationView(invoice: Invoice) -> some View {
|
||||
let insufficientFunds: Bool = (invoice.amount.amount_sats() ?? 0) > (model.balance ?? 0)
|
||||
return VStack(spacing: 20) {
|
||||
Text("Confirm Payment", comment: "Title for payment confirmation screen")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
|
||||
VStack(spacing: 15) {
|
||||
Text("Amount", comment: "Label for invoice payment amount in confirmation screen")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if case .specific(let amount) = invoice.amount {
|
||||
NumericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(value: (Double(amount) / 1000.0)), number: .decimal), hide_balance: .constant(false))
|
||||
}
|
||||
|
||||
Text("Bolt11 Invoice", comment: "Label for the bolt11 invoice string in confirmation screen")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(verbatim: invoice.abbreviated)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.padding()
|
||||
.background(DamusColors.adaptableGrey)
|
||||
.cornerRadius(10)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
HStack(spacing: 15) {
|
||||
Button(action: {
|
||||
sendState = .enterInvoice(scannerMessage: nil)
|
||||
}) {
|
||||
Text("Back", comment: "Button to go back to invoice input")
|
||||
.font(.headline)
|
||||
.frame(minWidth: 140)
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(NeutralButtonStyle())
|
||||
|
||||
Button(action: {
|
||||
sendState = .processing
|
||||
|
||||
// Process payment
|
||||
guard let payRequestEv = WalletConnect.pay(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: invoice.string, zap_request: nil, delay: nil) else {
|
||||
sendState = .failed(error: .init(
|
||||
user_visible_description: NSLocalizedString("The payment request could not be made to your wallet provider.", comment: "A human-readable error message"),
|
||||
tip: NSLocalizedString("Check if your wallet looks configured correctly and try again. If the error persists, please contact support.", comment: "A human-readable tip for an error when a payment request cannot be made to a wallet."),
|
||||
technical_info: "Cannot form Nostr Event to send to the NWC provider when calling `pay` from the \"send payment\" feature. Wallet provider relay: \"\(nwc.relay)\""
|
||||
))
|
||||
return
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
let result = try await model.waitForResponse(for: payRequestEv.id, timeout: SEND_PAYMENT_TIMEOUT)
|
||||
guard case .pay_invoice(_) = result else {
|
||||
sendState = .failed(error: .init(
|
||||
user_visible_description: NSLocalizedString("Received an incorrect or unexpected response from the wallet provider. This looks like an issue with your wallet provider.", comment: "A human-readable error message"),
|
||||
tip: NSLocalizedString("Try again. If the error persists, please contact your wallet provider and/or our support team.", comment: "A human-readable tip for an error when a payment request cannot be made to a wallet."),
|
||||
technical_info: "Expected a `pay_invoice` response for the request, but received a different type of response from the NWC wallet provider. Wallet provider relay: \"\(nwc.relay)\""
|
||||
))
|
||||
return
|
||||
}
|
||||
sendState = .completed
|
||||
}
|
||||
catch {
|
||||
if let error = error as? WalletModel.WaitError {
|
||||
switch error {
|
||||
case .timeout:
|
||||
sendState = .failed(error: .init(
|
||||
user_visible_description: NSLocalizedString("The payment request did not receive a response and the request timed-out.", comment: "A human-readable error message"),
|
||||
tip: NSLocalizedString("Check if the invoice is valid, your wallet is online, configured correctly, and try again. If the error persists, please contact support and/or your wallet provider.", comment: "A human-readable tip guiding the user on what to do when seeing a timeout error while sending a wallet payment."),
|
||||
technical_info: "Payment request timed-out. Wallet provider relay: \"\(nwc.relay)\""
|
||||
))
|
||||
}
|
||||
}
|
||||
else if let error = error as? WalletConnect.WalletResponseErr,
|
||||
let humanReadableError = error.humanReadableError {
|
||||
sendState = .failed(error: humanReadableError)
|
||||
}
|
||||
else {
|
||||
sendState = .failed(error: .init(
|
||||
user_visible_description: NSLocalizedString("An unexpected error occurred.", comment: "A human-readable error message"),
|
||||
tip: NSLocalizedString("Please try again. If the error persists, please contact support.", comment: "A human-readable tip guiding the user on what to do when seeing an unexpected error while sending a wallet payment."),
|
||||
technical_info: "Unexpected error thrown while waiting for payment request response. Wallet provider relay: \"\(nwc.relay)\"; Error: \(error)"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text("Confirm", comment: "Button to confirm payment")
|
||||
.font(.headline)
|
||||
.frame(minWidth: 140)
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle(padding: 0))
|
||||
.disabled(insufficientFunds)
|
||||
.opacity(insufficientFunds ? 0.5 : 1.0)
|
||||
}
|
||||
|
||||
if insufficientFunds {
|
||||
Text("You do not have enough funds to pay for this invoice.", comment: "Label on invoice payment screen, indicating user has insufficient funds")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 10)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
var processingView: some View {
|
||||
VStack(spacing: 30) {
|
||||
Text("Processing Payment", comment: "Title for payment processing screen")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
.padding()
|
||||
|
||||
Text("Please wait while your payment is being processed…", comment: "Message while payment is being processed")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
var completedView: some View {
|
||||
VStack(spacing: 30) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.resizable()
|
||||
.frame(width: 80, height: 80)
|
||||
.foregroundColor(.green)
|
||||
|
||||
Text("Payment Sent!", comment: "Title for successful payment screen")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
|
||||
Text("Your payment has been successfully sent.", comment: "Message for successful payment")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}) {
|
||||
Text("Done", comment: "Button to dismiss successful payment screen")
|
||||
.font(.headline)
|
||||
.frame(minWidth: 200)
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
func failedView(error: HumanReadableError) -> some View {
|
||||
ScrollView {
|
||||
VStack {
|
||||
ErrorView(damus_state: damus_state, error: error)
|
||||
|
||||
Button(action: {
|
||||
sendState = .enterInvoice(scannerMessage: nil)
|
||||
}) {
|
||||
Text("Try Again", comment: "Button to retry payment")
|
||||
.font(.headline)
|
||||
.frame(minWidth: 200)
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle(padding: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleScan(result: Result<ScanResult, ScanError>) {
|
||||
switch result {
|
||||
case .success(let result):
|
||||
processUserInput(result.string)
|
||||
case .failure(let error):
|
||||
sendState = .enterInvoice(scannerMessage: NSLocalizedString("Failed to scan QR code, please try again.", comment: "Error message for failed QR scan"))
|
||||
}
|
||||
}
|
||||
|
||||
func processUserInput(_ text: String) {
|
||||
if let result = parseScanData(text) {
|
||||
switch result {
|
||||
case .invoice(let invoice):
|
||||
if invoice.amount == .any {
|
||||
sendState = .enterInvoice(scannerMessage: NSLocalizedString("Sorry, we do not support paying invoices without amount yet. Please scan an invoice with an amount.", comment: "A user-readable message indicating that the lightning invoice they scanned or pasted is not supported and is missing an amount."))
|
||||
} else {
|
||||
sendState = .confirmPayment(invoice: invoice)
|
||||
}
|
||||
case .lnurl(let lnurlString):
|
||||
sendState = .enterLnurlAmount(lnurl: lnurlString)
|
||||
}
|
||||
} else {
|
||||
sendState = .enterInvoice(scannerMessage: NSLocalizedString("This does not appear to be a valid Lightning invoice or LNURL.", comment: "A user-readable message indicating that the scanned or pasted content was not a valid lightning invoice or LNURL."))
|
||||
}
|
||||
}
|
||||
|
||||
func parseScanData(_ text: String) -> ScanData? {
|
||||
let processedString = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
|
||||
if let invoice = Invoice.from(string: processedString) {
|
||||
return .invoice(invoice)
|
||||
}
|
||||
|
||||
if let _ = processedString.range(of: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", options: .regularExpression) {
|
||||
guard let lnurl = lnaddress_to_lnurl(processedString) else { return nil }
|
||||
return .lnurl(lnurl)
|
||||
}
|
||||
|
||||
if processedString.hasPrefix("lnurl") {
|
||||
return .lnurl(processedString)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
enum ScanData {
|
||||
case invoice(Invoice)
|
||||
case lnurl(String)
|
||||
}
|
||||
|
||||
// Helper function to get pasteboard content
|
||||
func getPasteboardContent() -> String? {
|
||||
return UIPasteboard.general.string
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ let WALLET_WARNING_THRESHOLD: UInt64 = 100000
|
||||
struct WalletView: View {
|
||||
let damus_state: DamusState
|
||||
@State var show_settings: Bool = false
|
||||
@State var show_send_sheet: Bool = false
|
||||
@ObservedObject var model: WalletModel
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@State private var showBalance: Bool = false
|
||||
@@ -59,6 +60,19 @@ struct WalletView: View {
|
||||
VStack(spacing: 5) {
|
||||
|
||||
BalanceView(balance: model.balance, hide_balance: $settings.hide_wallet_balance)
|
||||
|
||||
Button(action: {
|
||||
show_send_sheet = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "paperplane.fill")
|
||||
Text("Send", comment: "Button label to send bitcoin payment from wallet")
|
||||
.font(.headline)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
.padding(.bottom, 20)
|
||||
|
||||
TransactionsView(damus_state: damus_state, transactions: model.transactions, hide_balance: $settings.hide_wallet_balance)
|
||||
}
|
||||
@@ -104,23 +118,17 @@ struct WalletView: View {
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
.sheet(isPresented: $show_send_sheet) {
|
||||
SendPaymentView(damus_state: damus_state, model: model, nwc: nwc)
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updateWalletInformation() async {
|
||||
guard let url = damus_state.settings.nostr_wallet_connect,
|
||||
let nwc = WalletConnectURL(str: url) else {
|
||||
return
|
||||
}
|
||||
|
||||
let flusher: OnFlush? = nil
|
||||
|
||||
let delay = 0.0 // We don't need a delay when fetching a transaction list or balance
|
||||
|
||||
WalletConnect.request_transaction_list(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher)
|
||||
WalletConnect.request_balance_information(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher)
|
||||
return
|
||||
await WalletConnect.update_wallet_information(damus_state: damus_state)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>follow_pack_user_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@FOLLOW_PACK_USERS@</string>
|
||||
<key>FOLLOW_PACK_USERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>user</string>
|
||||
<key>other</key>
|
||||
<string>users</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>followed_by_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@
|
||||
"comment" : "Amount of money required to publish to the Nostr relay. In English, this would look something like '10 sats / event', meaning it costs 10 sats to publish one event."
|
||||
},
|
||||
"%@ %@" : {
|
||||
"comment" : "Sentence composed of 2 variables to describe how many imports were performed from loading a NostrScript. In source English, the first variable is the number of imports, and the second variable is 'Import' or 'Imports'.\nSentence composed of 2 variables to describe how many people are following a user. In source English, the first variable is the number of followers, and the second variable is 'Follower' or 'Followers'.\nSentence composed of 2 variables to describe how many profiles a user is following. In source English, the first variable is the number of profiles being followed, and the second variable is 'Following'.\nSentence composed of 2 variables to describe how many quoted reposts. In source English, the first variable is the number of reposts, and the second variable is 'Repost' or 'Reposts'.\nSentence composed of 2 variables to describe how many reactions there are on a post. In source English, the first variable is the number of reactions, and the second variable is 'Reaction' or 'Reactions'.\nSentence composed of 2 variables to describe how many relay servers a user is connected. In source English, the first variable is the number of relay servers, and the second variable is 'Relay' or 'Relays'.\nSentence composed of 2 variables to describe how many reposts. In source English, the first variable is the number of reposts, and the second variable is 'Repost' or 'Reposts'.\nSentence composed of 2 variables to describe how many zap payments there are on a post. In source English, the first variable is the number of zap payments, and the second variable is 'Zap' or 'Zaps'.",
|
||||
"comment" : "Sentence composed of 2 variables to describe how many imports were performed from loading a NostrScript. In source English, the first variable is the number of imports, and the second variable is 'Import' or 'Imports'.\nSentence composed of 2 variables to describe how many people are following a user. In source English, the first variable is the number of followers, and the second variable is 'Follower' or 'Followers'.\nSentence composed of 2 variables to describe how many people are in the follow pack. In source English, the first variable is the number of users, and the second variable is 'user' or 'users'.\nSentence composed of 2 variables to describe how many profiles a user is following. In source English, the first variable is the number of profiles being followed, and the second variable is 'Following'.\nSentence composed of 2 variables to describe how many quoted reposts. In source English, the first variable is the number of reposts, and the second variable is 'Repost' or 'Reposts'.\nSentence composed of 2 variables to describe how many reactions there are on a post. In source English, the first variable is the number of reactions, and the second variable is 'Reaction' or 'Reactions'.\nSentence composed of 2 variables to describe how many relay servers a user is connected. In source English, the first variable is the number of relay servers, and the second variable is 'Relay' or 'Relays'.\nSentence composed of 2 variables to describe how many reposts. In source English, the first variable is the number of reposts, and the second variable is 'Repost' or 'Reposts'.\nSentence composed of 2 variables to describe how many zap payments there are on a post. In source English, the first variable is the number of zap payments, and the second variable is 'Zap' or 'Zaps'.",
|
||||
"localizations" : {
|
||||
"en-US" : {
|
||||
"stringUnit" : {
|
||||
@@ -84,6 +84,48 @@
|
||||
"%lld%%" : {
|
||||
"comment" : "Percentage of additional zap that should be sent to support Damus development."
|
||||
},
|
||||
"♾️ Other" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"⚾️ Sports" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"✈️ Travel" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"🍱 Food" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"🎨 Art" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"🎶 Music" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"🏃 Health" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"🏛️ Politics" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"🏝️ Lifestyle" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"💻 Tech" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"📚 Humanities" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"🔭 Science" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"🛐 Religion" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"₿ Bitcoin" : {
|
||||
"comment" : "Interest topic label"
|
||||
},
|
||||
"1 month" : {
|
||||
"comment" : "A duration of 1 month to be shown to the user. Most likely in the context of how long they want to mute a piece of content for."
|
||||
},
|
||||
@@ -160,7 +202,7 @@
|
||||
"comment" : "Heading for some advice text to help the user with an error"
|
||||
},
|
||||
"All" : {
|
||||
"comment" : "Human-readable short description of the 'friends filter' when it is set to 'all'\nLabel for filter for all notifications."
|
||||
"comment" : "Human-readable short description of the 'trusted network filter' when it is disabled, and therefore is showing all content.\nLabel for filter for all notifications."
|
||||
},
|
||||
"All recent notes" : {
|
||||
"comment" : "A label indicating that the notes being displayed below it are all recent notes"
|
||||
@@ -174,6 +216,9 @@
|
||||
"Always show onboarding" : {
|
||||
"comment" : "Developer mode setting to always show onboarding suggestions."
|
||||
},
|
||||
"Amount" : {
|
||||
"comment" : "Label for invoice payment amount in confirmation screen"
|
||||
},
|
||||
"An additional percentage of each zap will be sent to support Damus development" : {
|
||||
"comment" : "Text indicating that they can contribute zaps to support Damus development."
|
||||
},
|
||||
@@ -183,6 +228,9 @@
|
||||
"An unexpected error happened while trying to perform this action. Please contact support." : {
|
||||
"comment" : "Error message for a failed reset/repair operation"
|
||||
},
|
||||
"An unexpected error occurred." : {
|
||||
"comment" : "A human-readable error message"
|
||||
},
|
||||
"An unexpected error occurred. Please contact Damus support via [Nostr](damus:npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955) or [email](support@damus.io) with the error message below." : {
|
||||
"comment" : "Label explaining there was an error, and suggesting next steps"
|
||||
},
|
||||
@@ -261,6 +309,9 @@
|
||||
"Automatically translate notes" : {
|
||||
"comment" : "Toggle to automatically translate notes."
|
||||
},
|
||||
"Back" : {
|
||||
"comment" : "Button to go back to invoice input"
|
||||
},
|
||||
"Be the first to access upcoming premium features: Automatic translations, longer note storage, and more" : {
|
||||
"comment" : "Description of new features to be expected"
|
||||
},
|
||||
@@ -273,6 +324,9 @@
|
||||
"Blur images" : {
|
||||
"comment" : "Setting to blur images"
|
||||
},
|
||||
"Bolt11 Invoice" : {
|
||||
"comment" : "Label for the bolt11 invoice string in confirmation screen"
|
||||
},
|
||||
"Bookmarks" : {
|
||||
"comment" : "Sidebar menu label for Bookmarks view.\nTitle of bookmarks view"
|
||||
},
|
||||
@@ -298,7 +352,7 @@
|
||||
"comment" : "User-visible heading for an error message indicating a note has an unknown kind or is unsupported for viewing."
|
||||
},
|
||||
"Cancel" : {
|
||||
"comment" : "Alert button to cancel out of alert for muting a user.\nButton to cancel a repost.\nButton to cancel any interaction with the QRCode link.\nButton to cancel out of alert that creates a new mutelist.\nButton to cancel out of posting a note.\nButton to cancel out of search text entry mode.\nButton to cancel the upload.\nCancel button text for dismissing profile status settings view.\nCancel button text for dismissing updating image url.\nCancel deleting bookmarks.\nCancel deleting the user.\nCancel out of logging out the user.\nCancel out of search view.\nCancel the user-requested operation.\nText for button to cancel out of connecting Nostr Wallet Connect lightning wallet."
|
||||
"comment" : "Alert button to cancel out of alert for muting a user.\nButton to cancel a repost.\nButton to cancel any interaction with the QRCode link.\nButton to cancel out of alert that creates a new mutelist.\nButton to cancel out of posting a note.\nButton to cancel out of search text entry mode.\nButton to cancel the LNURL payment process.\nButton to cancel the upload.\nCancel button text for dismissing profile status settings view.\nCancel button text for dismissing updating image url.\nCancel deleting bookmarks.\nCancel deleting the user.\nCancel out of logging out the user.\nCancel out of search view.\nCancel the user-requested operation.\nText for button to cancel out of connecting Nostr Wallet Connect lightning wallet."
|
||||
},
|
||||
"Cancelled" : {
|
||||
"comment" : "Title indicating that the user has cancelled."
|
||||
@@ -306,6 +360,12 @@
|
||||
"Changing this setting will cause the cache to be cleared. This will free space, but images may take longer to load again. Are you sure you want to proceed?" : {
|
||||
"comment" : "Message explaining consequences of changing the 'enable animation' setting"
|
||||
},
|
||||
"Check if the invoice is valid, your wallet is online, configured correctly, and try again. If the error persists, please contact support and/or your wallet provider." : {
|
||||
"comment" : "A human-readable tip guiding the user on what to do when seeing a timeout error while sending a wallet payment."
|
||||
},
|
||||
"Check if your wallet looks configured correctly and try again. If the error persists, please contact support." : {
|
||||
"comment" : "A human-readable tip for an error when a payment request cannot be made to a wallet."
|
||||
},
|
||||
"Check the address and/or the relay list." : {
|
||||
"comment" : "Human readable tip for error"
|
||||
},
|
||||
@@ -351,6 +411,12 @@
|
||||
"Configure Damus Purple" : {
|
||||
"comment" : "Button to allow Damus Purple to be configured"
|
||||
},
|
||||
"Confirm" : {
|
||||
"comment" : "Button to confirm payment"
|
||||
},
|
||||
"Confirm Payment" : {
|
||||
"comment" : "Title for payment confirmation screen"
|
||||
},
|
||||
"Confirmation" : {
|
||||
"comment" : "Confirmation dialog title"
|
||||
},
|
||||
@@ -387,8 +453,11 @@
|
||||
"Content filters" : {
|
||||
"comment" : "Section title for content filtering/moderation configuration."
|
||||
},
|
||||
"Content settings" : {
|
||||
"comment" : "Title for an onboarding screen showing user some content settings"
|
||||
},
|
||||
"Continue" : {
|
||||
"comment" : "Button to dismiss suggested users view and continue to the main app\nContinue with bookmarks.\nContinue with deleting the user.\nContinue with the user-requested operation.\nPrompt to user to continue"
|
||||
"comment" : "Button to dismiss suggested users view and continue to the main app\nButton to proceed with LNURL payment process.\nContinue with bookmarks.\nContinue with deleting the user.\nContinue with the user-requested operation.\nPrompt to user to continue"
|
||||
},
|
||||
"Conversations" : {
|
||||
"comment" : "Label for filter for seeing notes and replies that involve conversations between the signed in user and the current profile."
|
||||
@@ -468,6 +537,9 @@
|
||||
"Create new wallet" : {
|
||||
"comment" : "Button text for creating a new wallet."
|
||||
},
|
||||
"Created by %@" : {
|
||||
"comment" : "Lets the user know who created this follow pack."
|
||||
},
|
||||
"Current balance" : {
|
||||
"comment" : "Label for displaying current wallet balance"
|
||||
},
|
||||
@@ -535,7 +607,7 @@
|
||||
"comment" : "Navigation title for DMs view, where DM is the English abbreviation for Direct Message.\nNavigation title for view of DMs, where DM is an English abbreviation for Direct Message.\nPicker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message.\nSetting to enable DM Local Notification\nToolbar label for DMs view, where DM is the English abbreviation for Direct Message."
|
||||
},
|
||||
"Done" : {
|
||||
"comment" : "Button to dismiss wallet selection view for paying Lightning invoice.\nButton to leave edit mode for modifying the list of relays."
|
||||
"comment" : "Button to dismiss successful payment screen\nButton to dismiss wallet selection view for paying Lightning invoice.\nButton to leave edit mode for modifying the list of relays."
|
||||
},
|
||||
"Duration" : {
|
||||
"comment" : "Label for profile status expiration duration picker.\nThe duration in which to mute the given item."
|
||||
@@ -573,6 +645,9 @@
|
||||
"Encrypted" : {
|
||||
"comment" : "Heading indicating that this application keeps private messaging end-to-end encrypted."
|
||||
},
|
||||
"Enter Amount" : {
|
||||
"comment" : "Header text for LNURL payment amount entry screen"
|
||||
},
|
||||
"Enter your account key" : {
|
||||
"comment" : "Prompt for user to enter an account key to login."
|
||||
},
|
||||
@@ -586,7 +661,10 @@
|
||||
"comment" : "Error label shown when user tries to disable push notifications but something fails"
|
||||
},
|
||||
"Error fetching lightning invoice" : {
|
||||
"comment" : "Message to display when there was an error fetching a lightning invoice while attempting to zap."
|
||||
"comment" : "Error message when there was an error fetching a lightning invoice\nMessage to display when there was an error fetching a lightning invoice while attempting to zap."
|
||||
},
|
||||
"Error fetching LNURL payment information" : {
|
||||
"comment" : "Error message when LNURL fetch fails"
|
||||
},
|
||||
"Error retrieving muted event" : {
|
||||
"comment" : "Text for an item that application failed to retrieve the muted event for."
|
||||
@@ -636,6 +714,9 @@
|
||||
"Failed to parse" : {
|
||||
"comment" : "NostrScript error message when it fails to parse a script."
|
||||
},
|
||||
"Failed to scan QR code, please try again." : {
|
||||
"comment" : "Error message for failed QR scan"
|
||||
},
|
||||
"Find a Wallet" : {
|
||||
"comment" : "The heading for one of the \"Why add Zaps?\" boxes"
|
||||
},
|
||||
@@ -660,6 +741,9 @@
|
||||
"Follow me on Nostr" : {
|
||||
"comment" : "Text on QR code view to prompt viewer looking at screen to follow the user."
|
||||
},
|
||||
"Follow Packs" : {
|
||||
"comment" : "A label indicating that the items below it are follow packs"
|
||||
},
|
||||
"Followed by %@" : {
|
||||
"comment" : "Text to indicate that the user is followed by one of our follows."
|
||||
},
|
||||
@@ -715,9 +799,6 @@
|
||||
"Free" : {
|
||||
"comment" : "Dropdown option for selecting Free plan for DeepL translation service."
|
||||
},
|
||||
"Friends of friends" : {
|
||||
"comment" : "Human-readable short description of the 'friends filter' when it is set to 'friends-of-friends'"
|
||||
},
|
||||
"General" : {
|
||||
"comment" : "Section header for general damus notifications user configuration"
|
||||
},
|
||||
@@ -763,8 +844,11 @@
|
||||
"Hide all 🤙's" : {
|
||||
"comment" : "Section footer describing OnlyZaps mode"
|
||||
},
|
||||
"Hide balance" : {
|
||||
"comment" : "Setting to hide wallet balance."
|
||||
},
|
||||
"Hide notes with #nsfw tags" : {
|
||||
"comment" : "Setting to hide notes with the #nsfw (not safe for work) tags"
|
||||
"comment" : "Setting to hide notes with not safe for work tags\nSetting to hide notes with the #nsfw (not safe for work) tags"
|
||||
},
|
||||
"Hide notifications that tag many profiles" : {
|
||||
"comment" : "Label for notification settings toggle that hides notifications that tag many people."
|
||||
@@ -781,6 +865,9 @@
|
||||
"Home" : {
|
||||
"comment" : "Navigation bar title for Home view where notes and replies appear from those who the user is following."
|
||||
},
|
||||
"How much would you like to send?" : {
|
||||
"comment" : "Instruction text for LNURL payment amount"
|
||||
},
|
||||
"How would you like to connect to your Coinos wallet?" : {
|
||||
"comment" : "Question for the user when connecting a Coinos wallet."
|
||||
},
|
||||
@@ -826,6 +913,9 @@
|
||||
"Invalid lightning address" : {
|
||||
"comment" : "Message to display when there was an error attempting to zap due to an invalid lightning address."
|
||||
},
|
||||
"Invalid lightning invoice received" : {
|
||||
"comment" : "Error message when the lightning invoice received from LNURL is invalid"
|
||||
},
|
||||
"Invalid Nostr wallet connection string" : {
|
||||
"comment" : "Error message when an invalid Nostr wallet connection string is provided."
|
||||
},
|
||||
@@ -925,6 +1015,9 @@
|
||||
"MANUAL SETUP" : {
|
||||
"comment" : "Label for manual wallet setup."
|
||||
},
|
||||
"Max weekly budget" : {
|
||||
"comment" : "Label for setting the maximum weekly budget for Coinos wallet"
|
||||
},
|
||||
"Maybe later" : {
|
||||
"comment" : "Text for button to disconnect from Nostr Wallet Connect lightning wallet."
|
||||
},
|
||||
@@ -995,7 +1088,7 @@
|
||||
"comment" : "Ask the user if they are new to Nostr"
|
||||
},
|
||||
"Next" : {
|
||||
"comment" : "Button to continue with account creation."
|
||||
"comment" : "Button to continue with account creation.\nNext button title"
|
||||
},
|
||||
"No" : {
|
||||
"comment" : "Do not discard changes.\nUser confirm No"
|
||||
@@ -1006,6 +1099,9 @@
|
||||
"No content available to share" : {
|
||||
"comment" : "Title indicating that there was no available content to share"
|
||||
},
|
||||
"No cover image" : {
|
||||
"comment" : "Text letting user know there is no cover image."
|
||||
},
|
||||
"No image is currently setup" : {
|
||||
"comment" : "Accessibility value on image control"
|
||||
},
|
||||
@@ -1090,8 +1186,33 @@
|
||||
"Notes & Replies" : {
|
||||
"comment" : "Label for filter for seeing notes and replies (instead of only notes)."
|
||||
},
|
||||
"Notes from %@" : {
|
||||
"comment" : "Text to indicate that notes from one pubkey in our trusted network are shown below."
|
||||
},
|
||||
"Notes from %@ & %@" : {
|
||||
"comment" : "Text to indicate that notes from two pubkeys in our trusted network are shown below.",
|
||||
"localizations" : {
|
||||
"en-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Notes from %1$@ & %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Notes from %@, %@ & %@" : {
|
||||
"comment" : "Text to indicate that notes from three pubkeys in our trusted network are shown below.",
|
||||
"localizations" : {
|
||||
"en-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Notes from %1$@, %2$@ & %3$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Notes with the #nsfw tag usually contains adult content or other \"Not safe for work\" content" : {
|
||||
"comment" : "Section footer clarifying what #nsfw (not safe for work) tags mean"
|
||||
"comment" : "Explanation of what NSFW means\nSection footer clarifying what #nsfw (not safe for work) tags mean"
|
||||
},
|
||||
"Nothing to see here. Check back later!" : {
|
||||
"comment" : "Indicates that there are no notes in the timeline to view."
|
||||
@@ -1165,9 +1286,18 @@
|
||||
"Orange-pill" : {
|
||||
"comment" : "Button label that allows the user to start a direct message conversation with the user shown on-screen, to orange-pill them (i.e. help them to setup zaps)"
|
||||
},
|
||||
"Other preferences" : {
|
||||
"comment" : "Screen title for content preferences screen during onboarding"
|
||||
},
|
||||
"Paid Relay" : {
|
||||
"comment" : "Text indicating that this is a paid relay."
|
||||
},
|
||||
"Paste from Clipboard" : {
|
||||
"comment" : "Button to paste invoice from clipboard"
|
||||
},
|
||||
"Paste invoice from clipboard" : {
|
||||
"comment" : "Accessibility label for the invoice paste button"
|
||||
},
|
||||
"Paste NWC Address" : {
|
||||
"comment" : "Text for button to connect a lightning wallet."
|
||||
},
|
||||
@@ -1180,11 +1310,14 @@
|
||||
"Pay the Lightning invoice" : {
|
||||
"comment" : "Navigation bar title for view to pay Lightning invoice."
|
||||
},
|
||||
"Payment Sent!" : {
|
||||
"comment" : "Title for successful payment screen"
|
||||
},
|
||||
"Pending" : {
|
||||
"comment" : "Label to display that authentication to a server is pending."
|
||||
},
|
||||
"People" : {
|
||||
"comment" : "Label for filter for seeing only people follows."
|
||||
"comment" : "Label for filter for seeing only people follows.\nLabel for filter for seeing the people in this follow pack."
|
||||
},
|
||||
"People will be able to send you cash from your profile. No money goes to Damus." : {
|
||||
"comment" : "The description for one of the \"Why add Zaps?\" boxes"
|
||||
@@ -1204,6 +1337,9 @@
|
||||
"Please choose relays from the list below to filter the current feed:" : {
|
||||
"comment" : "Instructions on how to filter a specific timeline feed by choosing relay servers to filter on."
|
||||
},
|
||||
"Please contact support" : {
|
||||
"comment" : "Human readable error tip"
|
||||
},
|
||||
"Please contact support for further help." : {
|
||||
"comment" : "Human readable tips for what to do for a failure to find the relay list"
|
||||
},
|
||||
@@ -1225,12 +1361,18 @@
|
||||
"Please double-check the checkout web page, or go to the Side Menu → \"Purple\" to check your account status. If you have already paid, but still don't see your account active, please save the URL of the checkout page where you came from, contact our support, and give us the URL to help you with this issue." : {
|
||||
"comment" : "User-facing tips on what to do if a Purple welcome link doesn't work"
|
||||
},
|
||||
"Please enter a valid amount" : {
|
||||
"comment" : "Error message when no valid amount is entered for LNURL payment"
|
||||
},
|
||||
"Please go to Settings > First Aid > Repair relay list, or contact support." : {
|
||||
"comment" : "Human readable tip for error"
|
||||
},
|
||||
"Please make sure you have logged-in with your private key." : {
|
||||
"comment" : "Human readable tip for error"
|
||||
},
|
||||
"Please pick your interests. This will help us recommend accounts to follow." : {
|
||||
"comment" : "Instruction for interest selection"
|
||||
},
|
||||
"Please try again later or contact support if the issue persists." : {
|
||||
"comment" : "Human readable tip for error"
|
||||
},
|
||||
@@ -1240,18 +1382,27 @@
|
||||
"Please try again, check the URL for typos, or contact support for further help." : {
|
||||
"comment" : "User visible error tips"
|
||||
},
|
||||
"Please try again. If the error persists, please contact support." : {
|
||||
"comment" : "A human-readable tip guiding the user on what to do when seeing an unexpected error while sending a wallet payment."
|
||||
},
|
||||
"Please try opening this content on another Nostr app that supports this type of content." : {
|
||||
"comment" : "User-visible advice on what to do if they see the error indicating a note has an unknown kind or is unsupported for viewing."
|
||||
},
|
||||
"Please verify your credentials or permissions." : {
|
||||
"comment" : "Tip for unauthorized access"
|
||||
},
|
||||
"Please wait while your payment is being processed…" : {
|
||||
"comment" : "Message while payment is being processed"
|
||||
},
|
||||
"Point your camera to a QR code…" : {
|
||||
"comment" : "Text on QR code camera view instructing user to point to QR code"
|
||||
},
|
||||
"Post" : {
|
||||
"comment" : "Button to post a note."
|
||||
},
|
||||
"Posts" : {
|
||||
"comment" : "Label for filter for seeing the posts from the people in this follow pack."
|
||||
},
|
||||
"Private" : {
|
||||
"comment" : "Button text to indicate that the zap type is a private zap.\nHeading indicating that this application keeps personally identifiable information private. A sentence describing what is done to keep data private comes after this heading.\nPicker option to indicate that a zap should be sent privately and not identify the user to the public."
|
||||
},
|
||||
@@ -1276,6 +1427,12 @@
|
||||
"Pro" : {
|
||||
"comment" : "Dropdown option for selecting Pro plan for DeepL translation service."
|
||||
},
|
||||
"Processing Payment" : {
|
||||
"comment" : "Title for payment processing screen"
|
||||
},
|
||||
"Processing..." : {
|
||||
"comment" : "Text to indicate that the app is in the process of fetching an invoice."
|
||||
},
|
||||
"Production" : {
|
||||
"comment" : "Label indicating the production environment for Damus Purple\nLabel indicating the production environment for Push notification functionality"
|
||||
},
|
||||
@@ -1339,6 +1496,9 @@
|
||||
"Reactions" : {
|
||||
"comment" : "Navigation bar title for Reactions view.\nSection header for reactions settings\nTitle of emoji reactions view"
|
||||
},
|
||||
"Received an incorrect or unexpected response from the wallet provider. This looks like an issue with your wallet provider." : {
|
||||
"comment" : "A human-readable error message"
|
||||
},
|
||||
"Recommended" : {
|
||||
"comment" : "Title of the tab that shows the list of relays recommended by Damus."
|
||||
},
|
||||
@@ -1378,6 +1538,9 @@
|
||||
"Repair relay list" : {
|
||||
"comment" : "Button to repair relay list."
|
||||
},
|
||||
"Replies outside your trusted network" : {
|
||||
"comment" : "Section title in thread for replies from outside of the current user's trusted network, which is their follows and follows of follows."
|
||||
},
|
||||
"Reply" : {
|
||||
"comment" : "Accessibility label for reply button"
|
||||
},
|
||||
@@ -1437,6 +1600,9 @@
|
||||
"Reset contact list" : {
|
||||
"comment" : "Button to reset contact list."
|
||||
},
|
||||
"Reset tips on launch" : {
|
||||
"comment" : "Developer mode setting to reset tips upon app first launch. Tips are visual contextual hints that highlight new, interesting, or unused features users have not discovered yet."
|
||||
},
|
||||
"Retry" : {
|
||||
"comment" : "Button to retry completing account creation after an error occurred."
|
||||
},
|
||||
@@ -1491,6 +1657,9 @@
|
||||
"Scan for QR Code" : {
|
||||
"comment" : "Context menu option to scan image for a QR Code."
|
||||
},
|
||||
"Scan Lightning Invoice" : {
|
||||
"comment" : "Title for the invoice scanning screen"
|
||||
},
|
||||
"Scan NWC Address" : {
|
||||
"comment" : "Text for button to connect a lightning wallet."
|
||||
},
|
||||
@@ -1533,9 +1702,18 @@
|
||||
"Select default wallet" : {
|
||||
"comment" : "Prompt selection of user's default wallet"
|
||||
},
|
||||
"Select your interests" : {
|
||||
"comment" : "Title for a screen asking the user for interests"
|
||||
},
|
||||
"Select Your Interests" : {
|
||||
"comment" : "Screen title for interest selection"
|
||||
},
|
||||
"self" : {
|
||||
"comment" : "Part of a larger sentence 'Replying to self' in US English. 'self' indicates that the user is replying to themself and no one else."
|
||||
},
|
||||
"Send" : {
|
||||
"comment" : "Button label to send bitcoin payment from wallet"
|
||||
},
|
||||
"Send a message to start the conversation..." : {
|
||||
"comment" : "Text prompt for user to send a message to the other user."
|
||||
},
|
||||
@@ -1584,6 +1762,9 @@
|
||||
"Show" : {
|
||||
"comment" : "Button to show a note which has been muted.\nToggle to show or hide user's secret account login key."
|
||||
},
|
||||
"Show Bitcoin-heavy profile suggestions" : {
|
||||
"comment" : "Setting label during onboarding"
|
||||
},
|
||||
"Show general statuses" : {
|
||||
"comment" : "Settings toggle for enabling general user statuses"
|
||||
},
|
||||
@@ -1602,6 +1783,9 @@
|
||||
"Show profile action sheets" : {
|
||||
"comment" : "Setting to show profile action sheets when clicking on a user's profile picture"
|
||||
},
|
||||
"Show replies from your trusted network first" : {
|
||||
"comment" : "Setting to show replies in threads from the current user's trusted network first."
|
||||
},
|
||||
"Show wallet selector" : {
|
||||
"comment" : "Toggle to show or hide selection of wallet."
|
||||
},
|
||||
@@ -1626,6 +1810,9 @@
|
||||
"SOFTWARE" : {
|
||||
"comment" : "Text label indicating which relay software is used to run this Nostr relay."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"Someone posted a note" : {
|
||||
"comment" : "Title label for push notification where someone posted a note"
|
||||
},
|
||||
@@ -1647,6 +1834,9 @@
|
||||
"Sorry, this QR code looks incompatible with Damus. Please try another one." : {
|
||||
"comment" : "Text on QR code camera view telling the user a QR is incompatible"
|
||||
},
|
||||
"Sorry, we do not support paying invoices without amount yet. Please scan an invoice with an amount." : {
|
||||
"comment" : "A user-readable message indicating that the lightning invoice they scanned or pasted is not supported and is missing an amount."
|
||||
},
|
||||
"Spam" : {
|
||||
"comment" : "Description of report type for spam.\nSection header for Universe/Search spam"
|
||||
},
|
||||
@@ -1671,6 +1861,9 @@
|
||||
"Successfully synced" : {
|
||||
"comment" : "Label indicating success in syncing notification preferences"
|
||||
},
|
||||
"Successfully updated" : {
|
||||
"comment" : "Label indicating success in updating budget"
|
||||
},
|
||||
"Suggested hashtags" : {
|
||||
"comment" : "A label indicating that the items below it are suggested hashtags"
|
||||
},
|
||||
@@ -1719,6 +1912,15 @@
|
||||
"The camera was not capable of scanning the requested codes." : {
|
||||
"comment" : "Camera's bad output error label"
|
||||
},
|
||||
"The maximum amount of funds that are allowed to be sent out from this wallet each week." : {
|
||||
"comment" : "Description explaining the purpose of the 'Max weekly budget' setting for Coinos one-click setup wallets"
|
||||
},
|
||||
"The payment request could not be made to your wallet provider." : {
|
||||
"comment" : "A human-readable error message"
|
||||
},
|
||||
"The payment request did not receive a response and the request timed-out." : {
|
||||
"comment" : "A human-readable error message"
|
||||
},
|
||||
"The social network you control" : {
|
||||
"comment" : "Quick description of what Damus is"
|
||||
},
|
||||
@@ -1743,6 +1945,9 @@
|
||||
"This device's in-app purchase is registered to a different Nostr account. Unable to manage this Purple account. If you believe this was a mistake, please contact us via support@damus.io." : {
|
||||
"comment" : "Notice label that user cannot manage their In-App purchases"
|
||||
},
|
||||
"This does not appear to be a valid Lightning invoice or LNURL." : {
|
||||
"comment" : "A user-readable message indicating that the scanned or pasted content was not a valid lightning invoice or LNURL."
|
||||
},
|
||||
"This feature is not implemented by your wallet." : {
|
||||
"comment" : "Error description for not implemented feature"
|
||||
},
|
||||
@@ -1776,6 +1981,12 @@
|
||||
"Toggle key visibility" : {
|
||||
"comment" : "Accessibility label for toggling the visibility of the private key input field"
|
||||
},
|
||||
"Toggle visibility of content from outside your trusted network" : {
|
||||
"comment" : "Title of tip that informs users what trusted network means and that they can toggle the visibility of content from outside their trusted network."
|
||||
},
|
||||
"Toggle visibility of replies from outside your trusted network" : {
|
||||
"comment" : "Title of tip that informs users what trusted network means and that they can toggle the visibility of threaded replies from outside their trusted network."
|
||||
},
|
||||
"Top hits" : {
|
||||
"comment" : "A label indicating that the notes being displayed below it are all top note search results"
|
||||
},
|
||||
@@ -1806,12 +2017,24 @@
|
||||
"Truncate timeline text" : {
|
||||
"comment" : "Setting to truncate text in timeline"
|
||||
},
|
||||
"Trusted Network" : {
|
||||
"comment" : "Human-readable short description of the 'trusted network filter' when it is enabled, and therefore showing content from only the trusted network."
|
||||
},
|
||||
"Try Again" : {
|
||||
"comment" : "Button to retry payment"
|
||||
},
|
||||
"Try again. If the error persists, please contact your wallet provider and/or our support team." : {
|
||||
"comment" : "A human-readable tip for an error when a payment request cannot be made to a wallet."
|
||||
},
|
||||
"Try checking the link again, your internet connection, or contact the person who provided you the link for help." : {
|
||||
"comment" : "Tips on what to do if a note cannot be found."
|
||||
},
|
||||
"Try restarting your wallet or contacting support if the problem persists." : {
|
||||
"comment" : "Tip for internal error"
|
||||
},
|
||||
"Tweak these settings to better match your preferences" : {
|
||||
"comment" : "Instructions for content preferences screen during onboarding"
|
||||
},
|
||||
"Type %@ to delete" : {
|
||||
"comment" : "Text field prompt asking user to type DELETE in all caps to confirm that they want to proceed with deleting their account."
|
||||
},
|
||||
@@ -1824,6 +2047,9 @@
|
||||
"Undistract mode" : {
|
||||
"comment" : "Developer mode setting to scramble text and images to avoid distractions during development."
|
||||
},
|
||||
"Unexpected error loading user suggestions" : {
|
||||
"comment" : "Human readable error label"
|
||||
},
|
||||
"Unfollow" : {
|
||||
"comment" : "Button to unfollow a user."
|
||||
},
|
||||
@@ -1849,11 +2075,17 @@
|
||||
"comment" : "Label represnting a button that the user can tap to unmute a given hashtag so they start seeing it in their feed again."
|
||||
},
|
||||
"Untitled" : {
|
||||
"comment" : "Title of longform event if it is untitled."
|
||||
"comment" : "Title of follow list event if it is untitled.\nTitle of longform event if it is untitled."
|
||||
},
|
||||
"Untitled Follow Pack" : {
|
||||
"comment" : "Default title for a follow pack if no title is specified"
|
||||
},
|
||||
"Update" : {
|
||||
"comment" : "Update button text for updating image url."
|
||||
},
|
||||
"Updating" : {
|
||||
"comment" : "Label indicating budget update is in progress"
|
||||
},
|
||||
"Upload" : {
|
||||
"comment" : "Button to proceed with uploading."
|
||||
},
|
||||
@@ -2004,6 +2236,9 @@
|
||||
"You clicked on a Purple welcome link, but we could not find your checkout. This is likely a bug." : {
|
||||
"comment" : "Error label upon continuing in the app from a Damus Purple purchase"
|
||||
},
|
||||
"You do not have enough funds to pay for this invoice." : {
|
||||
"comment" : "Label on invoice payment screen, indicating user has insufficient funds"
|
||||
},
|
||||
"You do not have permission to alter this relay list." : {
|
||||
"comment" : "Human readable error description"
|
||||
},
|
||||
@@ -2034,6 +2269,9 @@
|
||||
"Your Name" : {
|
||||
"comment" : "Label for Your Name section of user profile form."
|
||||
},
|
||||
"Your payment has been successfully sent." : {
|
||||
"comment" : "Message for successful payment"
|
||||
},
|
||||
"Your profile will not be shared with Coinos." : {
|
||||
"comment" : "Label text for users to reassure them that their nsec is not shared with a third party."
|
||||
},
|
||||
@@ -2055,6 +2293,9 @@
|
||||
"Your transaction quota has been exceeded." : {
|
||||
"comment" : "Error description for quota exceeded"
|
||||
},
|
||||
"Your trusted network is comprised of profiles you follow and profiles that they follow." : {
|
||||
"comment" : "Description of the tip that informs users what trusted network means."
|
||||
},
|
||||
"Your wallet does not have sufficient balance for this transaction." : {
|
||||
"comment" : "Error description for insufficient balance"
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -2,6 +2,22 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>follow_pack_user_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@FOLLOW_PACK_USERS@</string>
|
||||
<key>FOLLOW_PACK_USERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>user</string>
|
||||
<key>other</key>
|
||||
<string>users</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>followed_by_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -82,6 +98,22 @@
|
||||
<string>Imports</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>notes_from_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@OTHERS@</string>
|
||||
<key>OTHERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>Notes from %2$@, %3$@, %4$@ & %1$d other in your trusted network</string>
|
||||
<key>other</key>
|
||||
<string>Notes from %2$@, %3$@, %4$@ & %1$d others in your trusted network</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>people_reposted_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"project" : "damus.xcodeproj",
|
||||
"targetLocale" : "en-US",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "16E140",
|
||||
"toolBuildNumber" : "16F6",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "16.3"
|
||||
"toolVersion" : "16.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
Binary file not shown.
@@ -2,6 +2,20 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>follow_pack_user_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@FOLLOW_PACK_USERS@</string>
|
||||
<key>FOLLOW_PACK_USERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>other</key>
|
||||
<string>ユーザー</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>followed_by_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -72,6 +86,20 @@
|
||||
<string>インポート</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>notes_from_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@OTHERS@</string>
|
||||
<key>OTHERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>other</key>
|
||||
<string>信頼したネットワークの2$@、%3$@、%4$@他%1$d人による投稿</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>people_reposted_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
||||
Binary file not shown.
@@ -2,6 +2,22 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>follow_pack_user_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@FOLLOW_PACK_USERS@</string>
|
||||
<key>FOLLOW_PACK_USERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>gebruiker</string>
|
||||
<key>other</key>
|
||||
<string>gebruikers</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>followed_by_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -82,6 +98,22 @@
|
||||
<string>Importeringen</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>notes_from_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@OTHERS@</string>
|
||||
<key>OTHERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>Notities van %2$@, %3$@ en %4$@; %1$d ander in je netwerk</string>
|
||||
<key>other</key>
|
||||
<string>Notities van %2$@, %3$@ en %4$@; %1$d anderen in je netwerk</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>people_reposted_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
||||
Binary file not shown.
@@ -2,6 +2,20 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>follow_pack_user_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@FOLLOW_PACK_USERS@</string>
|
||||
<key>FOLLOW_PACK_USERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>other</key>
|
||||
<string>ผู้ใช้</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>followed_by_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -72,6 +86,20 @@
|
||||
<string>นำเข้า</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>notes_from_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@OTHERS@</string>
|
||||
<key>OTHERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>other</key>
|
||||
<string>โน้ตจาก %2$@, %3$@, %4$@ & %1$d และคนอื่นๆในเครือข่ายที่น่าเชื่อถือของคุณ</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>people_reposted_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
||||
@@ -167,7 +167,23 @@ class Bech32ObjectTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(expectedEncoding, actualEncoding)
|
||||
}
|
||||
|
||||
|
||||
func testTLVEncoding_NeventFromNostrEvent_ValidContent() throws {
|
||||
let relays = ["wss://relay.damus.io", "wss://relay.nostr.band"]
|
||||
let nevent = NEvent(event: test_note, relays: relays)
|
||||
|
||||
XCTAssertEqual(nevent.noteid, test_note.id)
|
||||
XCTAssertEqual(nevent.relays, relays)
|
||||
XCTAssertEqual(nevent.author, test_note.pubkey)
|
||||
XCTAssertEqual(nevent.kind, test_note.kind)
|
||||
|
||||
let expectedEncoding = "nevent1qqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3vamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmnyqgsgydql3q4ka27d9wnlrmus4tvkrnc8ftc4h8h5fgyln54gl0a7dgsrqsqqqqqpppe7n6"
|
||||
|
||||
let actualEncoding = Bech32Object.encode(.nevent(NEvent(event: test_note, relays: relays)))
|
||||
|
||||
XCTAssertEqual(expectedEncoding, actualEncoding)
|
||||
}
|
||||
|
||||
func testTLVEncoding_NProfileExample_ValidContent() throws {
|
||||
guard let author = try bech32_decode("npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6") else {
|
||||
XCTFail()
|
||||
|
||||
@@ -9,18 +9,6 @@ import XCTest
|
||||
@testable import damus
|
||||
|
||||
|
||||
extension Block {
|
||||
var asInvoice: Invoice? {
|
||||
switch self {
|
||||
case .invoice(let invoice):
|
||||
return invoice
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final class InvoiceTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
|
||||
@@ -25,7 +25,7 @@ class LikeTests: XCTestCase {
|
||||
keypair: test_keypair,
|
||||
tags: [cindy.tag, bob.tag])!
|
||||
let id = liked.id
|
||||
let like_ev = make_like_event(keypair: test_keypair_full, liked: liked)!
|
||||
let like_ev = make_like_event(keypair: test_keypair_full, liked: liked, relayURL: nil)!
|
||||
|
||||
XCTAssertTrue(like_ev.referenced_pubkeys.contains(test_keypair.pubkey))
|
||||
XCTAssertTrue(like_ev.referenced_pubkeys.contains(cindy))
|
||||
@@ -36,12 +36,12 @@ class LikeTests: XCTestCase {
|
||||
func testToReactionEmoji() {
|
||||
let liked = NostrEvent(content: "awesome #[0] post", keypair: test_keypair, tags: [["p", "cindy"], ["e", "bob"]])!
|
||||
|
||||
let emptyReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "")!
|
||||
let plusReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "+")!
|
||||
let minusReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "-")!
|
||||
let heartReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "❤️")!
|
||||
let thumbsUpReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "👍")!
|
||||
let shakaReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "🤙")!
|
||||
let emptyReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "", relayURL: nil)!
|
||||
let plusReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "+", relayURL: nil)!
|
||||
let minusReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "-", relayURL: nil)!
|
||||
let heartReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "❤️", relayURL: nil)!
|
||||
let thumbsUpReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "👍", relayURL: nil)!
|
||||
let shakaReaction = make_like_event(keypair: test_keypair_full, liked: liked, content: "🤙", relayURL: nil)!
|
||||
|
||||
XCTAssertEqual(to_reaction_emoji(ev: emptyReaction), "❤️")
|
||||
XCTAssertEqual(to_reaction_emoji(ev: plusReaction), "❤️")
|
||||
|
||||
@@ -15,6 +15,7 @@ final class LocalizationUtilTests: XCTestCase {
|
||||
|
||||
// Test cases of the localization string key, and the expected en-US strings for a count of 0, 1, and 2.
|
||||
let keys = [
|
||||
["follow_pack_user_count", "users", "user", "users"],
|
||||
["followers_count", "Followers", "Follower", "Followers"],
|
||||
["following_count", "Following", "Following", "Following"],
|
||||
["hellthread_notifications_disabled", "Hide notifications that tag more than 0 profiles", "Hide notifications that tag more than 1 profile", "Hide notifications that tag more than 2 profiles"],
|
||||
|
||||
@@ -174,7 +174,49 @@ final class PostViewTests: XCTestCase {
|
||||
func testQuoteRepost() {
|
||||
let post = build_post(state: test_damus_state, post: .init(), action: .quoting(test_note), uploadedMedias: [], pubkeys: [])
|
||||
|
||||
XCTAssertEqual(post.tags, [["q", test_note.id.hex()]])
|
||||
XCTAssertEqual(post.tags, [["q", test_note.id.hex(), "", jack_keypair.pubkey.hex()], ["p", jack_keypair.pubkey.hex()]])
|
||||
}
|
||||
|
||||
func testBuildPostRecognizesStringsAsNpubs() throws {
|
||||
// given
|
||||
let expectedLink = "nostr:\(test_pubkey.npub)"
|
||||
let content = NSMutableAttributedString(string: "@test", attributes: [
|
||||
NSAttributedString.Key.link: "damus:\(expectedLink)"
|
||||
])
|
||||
|
||||
// when
|
||||
let post = build_post(
|
||||
state: test_damus_state,
|
||||
post: content,
|
||||
action: .posting(.user(test_pubkey)),
|
||||
uploadedMedias: [],
|
||||
pubkeys: []
|
||||
)
|
||||
|
||||
// then
|
||||
XCTAssertEqual(post.content, expectedLink)
|
||||
}
|
||||
|
||||
func testBuildPostRecognizesUrlsAsNpubs() throws {
|
||||
// given
|
||||
guard let npubUrl = URL(string: "damus:nostr:\(test_pubkey.npub)") else {
|
||||
return XCTFail("Could not create URL")
|
||||
}
|
||||
let content = NSMutableAttributedString(string: "@test", attributes: [
|
||||
NSAttributedString.Key.link: npubUrl
|
||||
])
|
||||
|
||||
// when
|
||||
let post = build_post(
|
||||
state: test_damus_state,
|
||||
post: content,
|
||||
action: .posting(.user(test_pubkey)),
|
||||
uploadedMedias: [],
|
||||
pubkeys: []
|
||||
)
|
||||
|
||||
// then
|
||||
XCTAssertEqual(post.content, "nostr:\(test_pubkey.npub)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ class damusUITests: XCTestCase {
|
||||
try self.login()
|
||||
}
|
||||
|
||||
app.buttons[AID.onboarding_interest_option_button.rawValue].firstMatch.tapIfExists(timeout: 5)
|
||||
app.buttons[AID.onboarding_interest_page_next_page.rawValue].tapIfExists(timeout: 5)
|
||||
app.buttons[AID.onboarding_content_settings_page_next_page.rawValue].tapIfExists(timeout: 5)
|
||||
app.buttons[AID.onboarding_sheet_skip_button.rawValue].tapIfExists(timeout: 5)
|
||||
app.buttons[AID.post_composer_cancel_button.rawValue].tapIfExists(timeout: 5)
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -368,7 +368,7 @@ class NdbNote: Codable, Equatable, Hashable {
|
||||
// Extension to make NdbNote compatible with NostrEvent's original API
|
||||
extension NdbNote {
|
||||
var is_textlike: Bool {
|
||||
return kind == 1 || kind == 42 || kind == 30023 || kind == 9802
|
||||
return kind == 1 || kind == 42 || kind == 30023 || kind == 9802 || kind == 39089
|
||||
}
|
||||
|
||||
var is_quote_repost: NoteId? {
|
||||
|
||||
@@ -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 ];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user