diff --git a/CHANGELOG.md b/CHANGELOG.md index 03bb95a1a1..0cbfffd171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +## [1.6-24] - 2023-10-22 - AppStore Rejection Cope + +### Added + +- Improve discoverability of profile zaps with zappability badges and profile action sheets (Daniel D’Aquino) +- Add suggested hashtags to universe view (Daniel D’Aquino) +- Suggest first post during onboarding (Daniel D’Aquino) +- Add expiry date for images in cache to be auto-deleted after a preset time to save space on storage (Daniel D’Aquino) +- Add QR scan nsec logins. (Jericho Hasselbush) + + +### Changed + +- Improved status view design (ericholguin) +- Improve clear cache functionality (Daniel D’Aquino) + + +### Fixed + +- Reduce size of event menu hitbox (William Casarin) +- Do not show DMs from muted users (Daniel D’Aquino) +- Add more spacing between display name and username, and prefix username with `@` character (Daniel D’Aquino) +- Broadcast quoted notes when posting a note with quotes (Daniel D’Aquino) + + +[1.6-24]: https://github.com/damus-io/damus/releases/tag/v1.6-24 + ## [1.6-23] - 2023-10-06 - Appstore Release ### Added diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 54e9a632c7..e018ec1da2 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0E8A4BB72AE4359200065E81 /* NostrFilter+Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */; }; 3165648B295B70D500C64604 /* LinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3165648A295B70D500C64604 /* LinkView.swift */; }; 3169CAE6294E69C000EE4006 /* EmptyTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3169CAE5294E69C000EE4006 /* EmptyTimelineView.swift */; }; 3169CAED294FCCFC00EE4006 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3169CAEC294FCCFC00EE4006 /* Constants.swift */; }; @@ -298,7 +299,7 @@ 4CB883A82975FC1800DC99E7 /* Zaps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883A72975FC1800DC99E7 /* Zaps.swift */; }; 4CB883AA297612FF00DC99E7 /* ZapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883A9297612FF00DC99E7 /* ZapTests.swift */; }; 4CB883AE2976FA9300DC99E7 /* FormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883AD2976FA9300DC99E7 /* FormatTests.swift */; }; - 4CB883B0297705DD00DC99E7 /* ZapButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883AF297705DD00DC99E7 /* ZapButton.swift */; }; + 4CB883B0297705DD00DC99E7 /* NoteZapButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883AF297705DD00DC99E7 /* NoteZapButton.swift */; }; 4CB883B6297730E400DC99E7 /* LNUrls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883B5297730E400DC99E7 /* LNUrls.swift */; }; 4CB8FC232A41ABA800763C51 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB8FC222A41ABA500763C51 /* AboutView.swift */; }; 4CB9D4A72992D02B00A9A7E4 /* ProfileNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB9D4A62992D02B00A9A7E4 /* ProfileNameView.swift */; }; @@ -380,10 +381,6 @@ 4FE60CDD295E1C5E00105A1F /* Wallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE60CDC295E1C5E00105A1F /* Wallet.swift */; }; 50088DA129E8271A008A1FDF /* WebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50088DA029E8271A008A1FDF /* WebSocket.swift */; }; 501F8C802A0220E1001AFC1D /* KeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C7F2A0220E1001AFC1D /* KeychainStorage.swift */; }; - BA3759922ABCCEBA0018D73B /* CameraService+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598F2ABCCEBA0018D73B /* CameraService+Extensions.swift */; }; - BA3759932ABCCEBA0018D73B /* CameraModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759902ABCCEBA0018D73B /* CameraModel.swift */; }; - BA3759942ABCCEBA0018D73B /* CameraService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759912ABCCEBA0018D73B /* CameraService.swift */; }; - BA3759972ABCCF360018D73B /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759962ABCCF360018D73B /* CameraPreview.swift */; }; 501F8C822A0224EB001AFC1D /* KeychainStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C812A0224EB001AFC1D /* KeychainStorageTests.swift */; }; 504323A72A34915F006AE6DC /* RelayModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504323A62A34915F006AE6DC /* RelayModel.swift */; }; 504323A92A3495B6006AE6DC /* RelayModelCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504323A82A3495B6006AE6DC /* RelayModelCache.swift */; }; @@ -419,9 +416,13 @@ 9609F058296E220800069BF3 /* BannerImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9609F057296E220800069BF3 /* BannerImageView.swift */; }; 9C83F89329A937B900136C08 /* TextViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C83F89229A937B900136C08 /* TextViewWrapper.swift */; }; 9CA876E229A00CEA0003B9A3 /* AttachMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */; }; + ADFE73552AD4793100EC7326 /* QRScanNSECView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFE73542AD4793100EC7326 /* QRScanNSECView.swift */; }; BA37598A2ABCCDE40018D73B /* ImageResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759892ABCCDE30018D73B /* ImageResizer.swift */; }; BA37598D2ABCCE500018D73B /* PhotoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */; }; BA37598E2ABCCE500018D73B /* VideoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */; }; + BA3759922ABCCEBA0018D73B /* CameraService+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598F2ABCCEBA0018D73B /* CameraService+Extensions.swift */; }; + BA3759932ABCCEBA0018D73B /* CameraModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759902ABCCEBA0018D73B /* CameraModel.swift */; }; + BA3759942ABCCEBA0018D73B /* CameraService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759912ABCCEBA0018D73B /* CameraService.swift */; }; BA3759972ABCCF360018D73B /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759962ABCCF360018D73B /* CameraPreview.swift */; }; BA4AB0AE2A63B9270070A32A /* AddEmojiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AB0AD2A63B9270070A32A /* AddEmojiView.swift */; }; BA4AB0B02A63B94D0070A32A /* EmojiListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AB0AF2A63B94D0070A32A /* EmojiListItemView.swift */; }; @@ -429,18 +430,26 @@ BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; }; D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; }; D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71DC1EB2A9129C3006E207C /* PostViewTests.swift */; }; + D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = D723C38D2AB8D83400065664 /* ContentFilters.swift */; }; + D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */; }; + D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2D042AD9C1B5002AFF62 /* MockDamusState.swift */; }; + D72A2D072AD9C1FB002AFF62 /* MockProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2D062AD9C1FB002AFF62 /* MockProfiles.swift */; }; D7315A2A2ACDF3B70036E30A /* DamusCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */; }; D7315A2C2ACDF4DA0036E30A /* DamusCacheManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */; }; - D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = D723C38D2AB8D83400065664 /* ContentFilters.swift */; }; + D76874F32AE3632B00FB0F68 /* ProfileZapLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */; }; + D77BFA0B2AE3051200621634 /* ProfileActionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */; }; + D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */; }; D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; }; D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; }; D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; }; + D7A343EE2AD0D77C00CED48B /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D7A343ED2AD0D77C00CED48B /* InlineSnapshotTesting */; }; + D7A343F02AD0D77C00CED48B /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D7A343EF2AD0D77C00CED48B /* SnapshotTesting */; }; D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */; }; D7FF94002AC7AC5300FD969D /* RelayURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FF93FF2AC7AC5200FD969D /* RelayURL.swift */; }; E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; }; E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E990020E2955F837003BBC5A /* EditMetadataView.swift */; }; E9E4ED0B295867B900DD7078 /* ThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E4ED0A295867B900DD7078 /* ThreadView.swift */; }; - F71694EA2A662232001F4053 /* SuggestedUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* SuggestedUsersView.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 */; }; @@ -475,6 +484,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrFilter+Hashable.swift"; sourceTree = ""; }; 3165648A295B70D500C64604 /* LinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkView.swift; sourceTree = ""; }; 3169CAE5294E69C000EE4006 /* EmptyTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyTimelineView.swift; sourceTree = ""; }; 3169CAEC294FCCFC00EE4006 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Constants.swift; path = damus/Util/Constants.swift; sourceTree = SOURCE_ROOT; }; @@ -938,10 +948,6 @@ 4C9B0DED2A65A75F00CBDA21 /* AttrStringTestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttrStringTestExtensions.swift; sourceTree = ""; }; 4C9B0DF22A65C46800CBDA21 /* ProfileEditButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditButton.swift; sourceTree = ""; }; 4C9BB83029C0ED4F00FC4E37 /* DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayName.swift; sourceTree = ""; }; - BA37598F2ABCCEBA0018D73B /* CameraService+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CameraService+Extensions.swift"; sourceTree = ""; }; - BA3759902ABCCEBA0018D73B /* CameraModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraModel.swift; sourceTree = ""; }; - BA3759912ABCCEBA0018D73B /* CameraService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraService.swift; sourceTree = ""; }; - BA3759962ABCCF360018D73B /* CameraPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = ""; }; 4C9BB83329C12D9900FC4E37 /* EventProfileName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventProfileName.swift; sourceTree = ""; }; 4C9F18E129AA9B6C008C55EC /* CustomizeZapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeZapView.swift; sourceTree = ""; }; 4C9F18E329ABDE6D008C55EC /* MaybeAnonPfpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaybeAnonPfpView.swift; sourceTree = ""; }; @@ -989,7 +995,7 @@ 4CB883A72975FC1800DC99E7 /* Zaps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zaps.swift; sourceTree = ""; }; 4CB883A9297612FF00DC99E7 /* ZapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapTests.swift; sourceTree = ""; }; 4CB883AD2976FA9300DC99E7 /* FormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatTests.swift; sourceTree = ""; }; - 4CB883AF297705DD00DC99E7 /* ZapButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapButton.swift; sourceTree = ""; }; + 4CB883AF297705DD00DC99E7 /* NoteZapButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteZapButton.swift; sourceTree = ""; }; 4CB883B5297730E400DC99E7 /* LNUrls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNUrls.swift; sourceTree = ""; }; 4CB8FC222A41ABA500763C51 /* AboutView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 4CB9D4A62992D02B00A9A7E4 /* ProfileNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNameView.swift; sourceTree = ""; }; @@ -1111,12 +1117,14 @@ 9609F057296E220800069BF3 /* BannerImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerImageView.swift; sourceTree = ""; }; 9C83F89229A937B900136C08 /* TextViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewWrapper.swift; sourceTree = ""; }; 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachMediaUtility.swift; sourceTree = ""; }; + ADFE73542AD4793100EC7326 /* QRScanNSECView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRScanNSECView.swift; sourceTree = ""; }; BA3759892ABCCDE30018D73B /* ImageResizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageResizer.swift; sourceTree = ""; }; BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCaptureProcessor.swift; sourceTree = ""; }; BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCaptureProcessor.swift; sourceTree = ""; }; + BA37598F2ABCCEBA0018D73B /* CameraService+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CameraService+Extensions.swift"; sourceTree = ""; }; + BA3759902ABCCEBA0018D73B /* CameraModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraModel.swift; sourceTree = ""; }; + BA3759912ABCCEBA0018D73B /* CameraService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraService.swift; sourceTree = ""; }; BA3759962ABCCF360018D73B /* CameraPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = ""; }; - D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManager.swift; sourceTree = ""; }; - D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManagerTests.swift; sourceTree = ""; }; BA4AB0AD2A63B9270070A32A /* AddEmojiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEmojiView.swift; sourceTree = ""; }; BA4AB0AF2A63B94D0070A32A /* EmojiListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiListItemView.swift; sourceTree = ""; }; BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = ""; }; @@ -1125,6 +1133,14 @@ D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; D71DC1EB2A9129C3006E207C /* PostViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostViewTests.swift; sourceTree = ""; }; D723C38D2AB8D83400065664 /* ContentFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentFilters.swift; sourceTree = ""; }; + D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventViewTests.swift; sourceTree = ""; }; + D72A2D042AD9C1B5002AFF62 /* MockDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDamusState.swift; sourceTree = ""; }; + D72A2D062AD9C1FB002AFF62 /* MockProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProfiles.swift; sourceTree = ""; }; + D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManager.swift; sourceTree = ""; }; + D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManagerTests.swift; sourceTree = ""; }; + D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileZapLinkView.swift; sourceTree = ""; }; + D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = ""; }; + D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = ""; }; D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = ""; }; D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = ""; }; D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = ""; }; @@ -1133,7 +1149,7 @@ E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsView.swift; sourceTree = ""; }; E990020E2955F837003BBC5A /* EditMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditMetadataView.swift; sourceTree = ""; }; E9E4ED0A295867B900DD7078 /* ThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadView.swift; sourceTree = ""; }; - F71694E92A662232001F4053 /* SuggestedUsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedUsersView.swift; sourceTree = ""; }; + F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSuggestionsView.swift; sourceTree = ""; }; F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedUsersViewModel.swift; sourceTree = ""; }; F71694ED2A6624F9001F4053 /* suggested_users.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = suggested_users.json; sourceTree = ""; }; F71694F12A67314D001F4053 /* SuggestedUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedUserView.swift; sourceTree = ""; }; @@ -1165,6 +1181,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D7A343EE2AD0D77C00CED48B /* InlineSnapshotTesting in Frameworks */, + D7A343F02AD0D77C00CED48B /* SnapshotTesting in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1250,7 +1268,6 @@ 4C3EA66C28FF782800C48A62 /* amount.c */, 4C3EA66E28FF787100C48A62 /* overflows.h */, 4C3EA67228FF79F600C48A62 /* structeq.h */, - BA3759952ABCCF360018D73B /* Camera */, 4C3EA67328FF7A2600C48A62 /* cppmagic.h */, 4C3EA67428FF7A5A00C48A62 /* take.c */, 4C3EA67628FF7A9800C48A62 /* talstr.c */, @@ -1285,6 +1302,7 @@ 4C363A9928283854006E126D /* Reply.swift */, 4C363A9B282838B9006E126D /* EventRef.swift */, 4C363AA328296DEE006E126D /* SearchModel.swift */, + 0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */, 4C3AC79A28306D7B00E1F516 /* Contacts.swift */, 4C285C85283892E7008A31F1 /* CreateAccountModel.swift */, 4C63334F283D40E500B1C9C3 /* HomeModel.swift */, @@ -1682,6 +1700,7 @@ 4C3AC79E2833115300E1F516 /* FollowButtonView.swift */, 4C3AC79C2833036D00E1F516 /* FollowingView.swift */, 4C90BD17283A9EE5008EE7EF /* LoginView.swift */, + ADFE73542AD4793100EC7326 /* QRScanNSECView.swift */, 4C363A8D28236FE4006E126D /* NoteContentView.swift */, 4C75EFAC28049CFB0006080F /* PostButton.swift */, 4C75EFA327FA577B0006080F /* PostView.swift */, @@ -1710,6 +1729,8 @@ 50DA11252A16A23F00236234 /* Launch.storyboard */, 5C513FCB2984ACA60072348F /* QRCodeView.swift */, 643EA5C7296B764E005081BB /* RelayFilterView.swift */, + D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */, + D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */, ); path = Views; sourceTree = ""; @@ -2070,7 +2091,7 @@ 5C513FB9297F72980072348F /* CustomPicker.swift */, 4CF0ABE22981BC7D00D66079 /* UserView.swift */, 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */, - 4CB883AF297705DD00DC99E7 /* ZapButton.swift */, + 4CB883AF297705DD00DC99E7 /* NoteZapButton.swift */, 4C42812B298C848200DBF26F /* TranslateView.swift */, 7CFF6316299FEFE5005D382A /* SelectableText.swift */, 4C8EC52429D1FA6C0085D9A8 /* DamusColors.swift */, @@ -2149,6 +2170,7 @@ 4CE6DEF627F7A08200C66700 /* damusTests */ = { isa = PBXGroup; children = ( + D72A2D032AD9C165002AFF62 /* Mocking */, 4C9B0DEC2A65A74000CBDA21 /* Util */, 4C0C03962A61E2670098B3B8 /* Fixtures */, 4C7D097D2A0C58B900943473 /* WalletConnectTests.swift */, @@ -2179,6 +2201,7 @@ 4C684A562A7FFAE6005E6031 /* UrlTests.swift */, D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */, D71DC1EB2A9129C3006E207C /* PostViewTests.swift */, + D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */, D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */, ); path = damusTests; @@ -2219,6 +2242,7 @@ 4C9F18E129AA9B6C008C55EC /* CustomizeZapView.swift */, 4CA3FA0F29F593D000FDB3C3 /* ZapTypePicker.swift */, 4C73C5132A4437C10062CAC0 /* ZapUserView.swift */, + D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */, ); path = Zaps; sourceTree = ""; @@ -2302,16 +2326,25 @@ BA3759952ABCCF360018D73B /* Camera */ = { isa = PBXGroup; children = ( - BAA8C3262AEC570800696158 /* CameraView.swift */, + BAA8C3262AEC570800696158 /* CameraView.swift */, BA3759962ABCCF360018D73B /* CameraPreview.swift */, ); path = Camera; sourceTree = ""; }; + D72A2D032AD9C165002AFF62 /* Mocking */ = { + isa = PBXGroup; + children = ( + D72A2D042AD9C1B5002AFF62 /* MockDamusState.swift */, + D72A2D062AD9C1FB002AFF62 /* MockProfiles.swift */, + ); + path = Mocking; + sourceTree = ""; + }; F71694E82A66221E001F4053 /* Onboarding */ = { isa = PBXGroup; children = ( - F71694E92A662232001F4053 /* SuggestedUsersView.swift */, + F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */, F71694F12A67314D001F4053 /* SuggestedUserView.swift */, F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */, F71694ED2A6624F9001F4053 /* suggested_users.json */, @@ -2376,6 +2409,10 @@ 4CE6DEF527F7A08200C66700 /* PBXTargetDependency */, ); name = damusTests; + packageProductDependencies = ( + D7A343ED2AD0D77C00CED48B /* InlineSnapshotTesting */, + D7A343EF2AD0D77C00CED48B /* SnapshotTesting */, + ); productName = damusTests; productReference = 4CE6DEF327F7A08200C66700 /* damusTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -2464,6 +2501,7 @@ 4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */, 4CCF9AB02A1FE80B00E03CFB /* XCRemoteSwiftPackageReference "GSPlayer" */, 4C27C9302A64766F007DBC75 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, + D7A343EC2AD0D77C00CED48B /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, ); productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */; projectDirPath = ""; @@ -2547,8 +2585,10 @@ 4C4793062A993E5300489948 /* json_parser.c in Sources */, 4C4793052A993E3200489948 /* builder.c in Sources */, 4C4793042A993DC000489948 /* midl.c in Sources */, + 0E8A4BB72AE4359200065E81 /* NostrFilter+Hashable.swift in Sources */, 4C4793012A993CDA00489948 /* mdb.c in Sources */, 4CE9FBBA2A6B3C63007E485C /* nostrdb.c in Sources */, + ADFE73552AD4793100EC7326 /* QRScanNSECView.swift in Sources */, 4C3AC79D2833036D00E1F516 /* FollowingView.swift in Sources */, 5CF72FC229B9142F00124A13 /* ShareAction.swift in Sources */, 4C32B9522A9AD44700DC3548 /* Message.swift in Sources */, @@ -2794,6 +2834,7 @@ 4CC14FF52A740BB7007AEB17 /* NoteId.swift in Sources */, 4C19AE512A5CEF7C00C90DB7 /* NostrScript.swift in Sources */, 4C32B95E2A9AD44700DC3548 /* FlatBufferObject.swift in Sources */, + D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */, 4C3EA64F28FF59F200C48A62 /* tal.c in Sources */, 4CB88393296F798300DC99E7 /* ReactionsModel.swift in Sources */, 5C42E78C29DB76D90086AAC1 /* EmptyUserSearchView.swift in Sources */, @@ -2809,7 +2850,7 @@ BA3759942ABCCEBA0018D73B /* CameraService.swift in Sources */, 4CB8838629656C8B00DC99E7 /* NIP05.swift in Sources */, 4CF0ABD82981980C00D66079 /* Lists.swift in Sources */, - F71694EA2A662232001F4053 /* SuggestedUsersView.swift in Sources */, + F71694EA2A662232001F4053 /* OnboardingSuggestionsView.swift in Sources */, 4C12536A2A76D3850004F4B8 /* RelaysChangedNotify.swift in Sources */, 4C30AC8029A6A53F00E2BD5A /* ProfilePicturesView.swift in Sources */, 5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */, @@ -2857,7 +2898,7 @@ 4C5E54062A9671F800FF6E60 /* UserStatusSheet.swift in Sources */, F71694F42A6732B7001F4053 /* GradientFollowButton.swift in Sources */, 4C3AC7A728369BA200E1F516 /* SearchHomeView.swift in Sources */, - 4CB883B0297705DD00DC99E7 /* ZapButton.swift in Sources */, + 4CB883B0297705DD00DC99E7 /* NoteZapButton.swift in Sources */, 4C363A922825FCF2006E126D /* ProfileUpdate.swift in Sources */, 4C3BEFDA281DCA1400B3DE84 /* LikeCounter.swift in Sources */, 4C32B9502A9AD44700DC3548 /* FlatBufferBuilder.swift in Sources */, @@ -2928,6 +2969,8 @@ E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */, 4C75EFBB2804A34C0006080F /* ProofOfWork.swift in Sources */, 4C3AC7A52836987600E1F516 /* MainTabView.swift in Sources */, + D76874F32AE3632B00FB0F68 /* ProfileZapLinkView.swift in Sources */, + D77BFA0B2AE3051200621634 /* ProfileActionSheetView.swift in Sources */, 4C1A9A1F29DDD24B00516EAC /* AppearanceSettingsView.swift in Sources */, 3AA59D1D2999B0400061C48E /* DraftsModel.swift in Sources */, 3169CAED294FCCFC00EE4006 /* Constants.swift in Sources */, @@ -2957,12 +3000,14 @@ 3A30410129AB12AA008A0F29 /* EventGroupViewTests.swift in Sources */, 501F8C822A0224EB001AFC1D /* KeychainStorageTests.swift in Sources */, 3ACBCB78295FE5C70037388A /* TimeAgoTests.swift in Sources */, + D72A2D072AD9C1FB002AFF62 /* MockProfiles.swift in Sources */, 4C4F14A72A2A61A30045A0B9 /* NostrScriptTests.swift in Sources */, D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */, 4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */, 4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */, 4C7D097E2A0C58B900943473 /* WalletConnectTests.swift in Sources */, 4CB883AA297612FF00DC99E7 /* ZapTests.swift in Sources */, + D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */, 4CB8839A297322D200DC99E7 /* DMTests.swift in Sources */, D7315A2C2ACDF4DA0036E30A /* DamusCacheManagerTests.swift in Sources */, 4C9054852A6AEAA000811EEC /* NdbTests.swift in Sources */, @@ -2970,6 +3015,7 @@ F944F56E29EA9CCC0067B3BF /* DamusParseContentTests.swift in Sources */, 3A5E47C72A4A76C800C0D090 /* TrieTests.swift in Sources */, 4CB883AE2976FA9300DC99E7 /* FormatTests.swift in Sources */, + D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */, 4C363AA02828A8DD006E126D /* LikeTests.swift in Sources */, 4C90BD1C283AC38E008EE7EF /* Bech32Tests.swift in Sources */, 50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */, @@ -3246,7 +3292,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\""; DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_PREVIEWS = YES; @@ -3295,7 +3341,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\""; DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_PREVIEWS = YES; @@ -3485,6 +3531,14 @@ minimumVersion = 0.2.26; }; }; + D7A343EC2AD0D77C00CED48B /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.14.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3503,6 +3557,16 @@ package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; productName = secp256k1; }; + D7A343ED2AD0D77C00CED48B /* InlineSnapshotTesting */ = { + isa = XCSwiftPackageProductDependency; + package = D7A343EC2AD0D77C00CED48B /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; + productName = InlineSnapshotTesting; + }; + D7A343EF2AD0D77C00CED48B /* SnapshotTesting */ = { + isa = XCSwiftPackageProductDependency; + package = D7A343EC2AD0D77C00CED48B /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; + productName = SnapshotTesting; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 4CE6DEDB27F7A08100C66700 /* Project object */; diff --git a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c8409240c6..1990dd3508 100644 --- a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,6 +33,24 @@ "state" : { "revision" : "76bb7971da7fbf429de1c84f1244adf657242fee" } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "5b356adceabff6ca027f6574aac79e9fee145d26", + "version" : "1.14.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "74203046135342e4a4a627476dd6caf8b28fe11b", + "version" : "509.0.0" + } } ], "version" : 2 diff --git a/damus/Assets.xcassets/Colors/DamusAdaptableWhite.colorset/Contents.json b/damus/Assets.xcassets/Colors/DamusAdaptableWhite.colorset/Contents.json new file mode 100644 index 0000000000..9c0e331e97 --- /dev/null +++ b/damus/Assets.xcassets/Colors/DamusAdaptableWhite.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/damus/Components/DamusColors.swift b/damus/Components/DamusColors.swift index 4eb7f43d49..259e388ca2 100644 --- a/damus/Components/DamusColors.swift +++ b/damus/Components/DamusColors.swift @@ -11,6 +11,7 @@ import SwiftUI class DamusColors { static let adaptableGrey = Color("DamusAdaptableGrey") static let adaptableBlack = Color("DamusAdaptableBlack") + static let adaptableWhite = Color("DamusAdaptableWhite") static let white = Color("DamusWhite") static let black = Color("DamusBlack") static let brown = Color("DamusBrown") diff --git a/damus/Components/InvoiceView.swift b/damus/Components/InvoiceView.swift index a38f7bfce3..cc99f7410b 100644 --- a/damus/Components/InvoiceView.swift +++ b/damus/Components/InvoiceView.swift @@ -39,7 +39,12 @@ struct InvoiceView: View { if settings.show_wallet_selector { present_sheet(.select_wallet(invoice: invoice.string)) } else { - open_with_wallet(wallet: settings.default_wallet.model, invoice: invoice.string) + do { + try open_with_wallet(wallet: settings.default_wallet.model, invoice: invoice.string) + } + catch { + present_sheet(.select_wallet(invoice: invoice.string)) + } } } label: { RoundedRectangle(cornerRadius: 20, style: .circular) @@ -82,21 +87,26 @@ struct InvoiceView: View { } } -func open_with_wallet(wallet: Wallet.Model, invoice: String) { +enum OpenWalletError: Error { + case no_wallet_to_open + case store_link_invalid + case system_cannot_open_store_link +} + +func open_with_wallet(wallet: Wallet.Model, invoice: String) throws { if let url = URL(string: "\(wallet.link)\(invoice)"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } else { guard let store_link = wallet.appStoreLink else { - // TODO: do something here if we don't have an appstore link - return + throw OpenWalletError.no_wallet_to_open } guard let url = URL(string: store_link) else { - return + throw OpenWalletError.store_link_invalid } guard UIApplication.shared.canOpenURL(url) else { - return + throw OpenWalletError.system_cannot_open_store_link } UIApplication.shared.open(url) diff --git a/damus/Components/NeutralButtonStyle.swift b/damus/Components/NeutralButtonStyle.swift index 3d61b19e49..f7aa797f46 100644 --- a/damus/Components/NeutralButtonStyle.swift +++ b/damus/Components/NeutralButtonStyle.swift @@ -20,6 +20,20 @@ struct NeutralButtonStyle: ButtonStyle { } } +struct NeutralCircleButtonStyle: ButtonStyle { + func makeBody(configuration: Self.Configuration) -> some View { + return configuration.label + .padding(20) + .background(DamusColors.neutral1) + .cornerRadius(9999) + .overlay( + RoundedRectangle(cornerRadius: 9999) + .stroke(DamusColors.neutral3, lineWidth: 1) + ) + .scaleEffect(configuration.isPressed ? 0.95 : 1) + } +} + struct NeutralButtonStyle_Previews: PreviewProvider { static var previews: some View { diff --git a/damus/Components/ZapButton.swift b/damus/Components/NoteZapButton.swift similarity index 90% rename from damus/Components/ZapButton.swift rename to damus/Components/NoteZapButton.swift index 5ad432bc55..3feef66028 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/NoteZapButton.swift @@ -1,5 +1,5 @@ // -// ZapButton.swift +// NoteZapButton.swift // damus // // Created by William Casarin on 2023-01-17. @@ -18,6 +18,19 @@ enum ZappingError { case bad_lnurl case canceled case send_failed + + func humanReadableMessage() -> String { + switch self { + case .fetching_invoice: + return NSLocalizedString("Error fetching lightning invoice", comment: "Message to display when there was an error fetching a lightning invoice while attempting to zap.") + case .bad_lnurl: + return NSLocalizedString("Invalid lightning address", comment: "Message to display when there was an error attempting to zap due to an invalid lightning address.") + case .canceled: + return NSLocalizedString("Zap attempt from connected wallet was canceled.", comment: "Message to display when a zap from the user's connected wallet was canceled.") + case .send_failed: + return NSLocalizedString("Zap attempt from connected wallet failed.", comment: "Message to display when sending a zap from the user's connected wallet failed.") + } + } } struct ZappingEvent { @@ -26,7 +39,7 @@ struct ZappingEvent { let target: ZapTarget } -struct ZapButton: View { +struct NoteZapButton: View { let damus_state: DamusState let target: ZapTarget let lnurl: String @@ -144,7 +157,7 @@ struct ZapButton_Previews: PreviewProvider { let pending_zap = PendingZap(amount_msat: 1000, target: ZapTarget.note(id: test_note.id, author: test_note.pubkey), request: .normal(test_zap_request), type: .pub, state: .external(.init(state: .fetching_invoice))) let zaps = ZapsDataModel([.pending(pending_zap)]) - ZapButton(damus_state: test_damus_state, target: ZapTarget.note(id: test_note.id, author: test_note.pubkey), lnurl: "lnurl", zaps: zaps) + NoteZapButton(damus_state: test_damus_state, target: ZapTarget.note(id: test_note.id, author: test_note.pubkey), lnurl: "lnurl", zaps: zaps) } } diff --git a/damus/Components/SelectableText.swift b/damus/Components/SelectableText.swift index 51346f3977..975d5810ba 100644 --- a/damus/Components/SelectableText.swift +++ b/damus/Components/SelectableText.swift @@ -11,12 +11,19 @@ import SwiftUI struct SelectableText: View { let attributedString: AttributedString + let textAlignment: NSTextAlignment @State private var selectedTextHeight: CGFloat = .zero @State private var selectedTextWidth: CGFloat = .zero let size: EventViewKind + init(attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) { + self.attributedString = attributedString + self.textAlignment = textAlignment ?? NSTextAlignment.natural + self.size = size + } + var body: some View { GeometryReader { geo in TextViewRepresentable( @@ -24,6 +31,7 @@ struct SelectableText: View { textColor: UIColor.label, font: eventviewsize_to_uifont(size), fixedWidth: selectedTextWidth, + textAlignment: self.textAlignment, height: $selectedTextHeight ) .padding([.leading, .trailing], -1.0) @@ -48,6 +56,7 @@ struct SelectableText: View { let textColor: UIColor let font: UIFont let fixedWidth: CGFloat + let textAlignment: NSTextAlignment @Binding var height: CGFloat @@ -61,12 +70,14 @@ struct SelectableText: View { view.textContainerInset = .zero view.textContainerInset.left = 1.0 view.textContainerInset.right = 1.0 + view.textAlignment = textAlignment return view } func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { let mutableAttributedString = createNSAttributedString() uiView.attributedText = mutableAttributedString + uiView.textAlignment = self.textAlignment let newHeight = mutableAttributedString.height(containerWidth: fixedWidth) diff --git a/damus/Components/Status/UserStatusSheet.swift b/damus/Components/Status/UserStatusSheet.swift index dec711352d..10620e1b7a 100644 --- a/damus/Components/Status/UserStatusSheet.swift +++ b/damus/Components/Status/UserStatusSheet.swift @@ -46,17 +46,26 @@ enum StatusDuration: CustomStringConvertible, CaseIterable { } let formatter = DateComponentsFormatter() - formatter.unitsStyle = .full + formatter.unitsStyle = .abbreviated formatter.allowedUnits = [.minute, .hour, .day, .weekOfMonth] return formatter.string(from: timeInterval) ?? "\(timeInterval) seconds" } } +enum Fields{ + case status + case link +} + struct UserStatusSheet: View { + let damus_state: DamusState let postbox: PostBox let keypair: Keypair @State var duration: StatusDuration = .never + @State var show_link: Bool = false + + @FocusState var focusedTextField : Fields? @ObservedObject var status: UserStatusModel @Environment(\.dismiss) var dismiss @@ -86,45 +95,15 @@ struct UserStatusSheet: View { } var body: some View { - VStack(alignment: .leading, spacing: 20) { - Text("Set Status", comment: "Title of view that allows the user to set their profile status (e.g. working, studying, coding)") - .font(.largeTitle) - - TextField(text: status_binding, label: { - Text("📋 Working", comment: "Placeholder as an example of what the user could set as their profile status.") - }) - - HStack { - Image("link") - - TextField(text: url_binding, label: { - Text("https://example.com", comment: "Placeholder as an example of what the user could set so that the link is opened when the status is tapped.") - }) - } - + VStack { HStack { - Text("Clear status", comment: "Label to prompt user to select an expiration time for the profile status to clear.") - - Spacer() - - Picker(NSLocalizedString("Duration", comment: "Label for profile status expiration duration picker."), selection: $duration) { - ForEach(StatusDuration.allCases, id: \.self) { d in - Text(verbatim: d.description) - .tag(d) - } - } - } - - Toggle(isOn: $status.playing_enabled, label: { - Text("Broadcast music playing on Apple Music", comment: "Toggle to enable or disable broadcasting what music is being played on Apple Music in their profile status.") - }) - - HStack(alignment: .center) { Button(action: { dismiss() }, label: { Text("Cancel", comment: "Cancel button text for dismissing profile status settings view.") + .padding(10) }) + .buttonStyle(NeutralButtonStyle()) Spacer() @@ -140,21 +119,98 @@ struct UserStatusSheet: View { dismiss() }, label: { - Text("Save", comment: "Save button text for saving profile status settings.") + Text("Share", comment: "Save button text for saving profile status settings.") }) - .buttonStyle(GradientButtonStyle()) + .buttonStyle(GradientButtonStyle(padding: 10)) + } + .padding() + + Divider() + + ZStack { + ProfilePicView(pubkey: keypair.pubkey, size: 120.0, highlight: .custom(DamusColors.white, 3.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) + .padding(.top, 130) + + VStack(spacing: 0) { + HStack { + TextField(NSLocalizedString("Staying humble...", comment: "Placeholder as an example of what the user could set as their profile status."), text: status_binding, axis: .vertical) + .focused($focusedTextField, equals: Fields.status) + .task { + self.focusedTextField = .status + } + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .lineLimit(3) + .frame(width: 175) + } + .padding(10) + .background(DamusColors.adaptableWhite) + .cornerRadius(15) + .shadow(color: DamusColors.neutral3, radius: 15) + + Circle() + .fill(DamusColors.adaptableWhite) + .frame(width: 12, height: 12) + .padding(.trailing, 140) + + Circle() + .fill(DamusColors.adaptableWhite) + .frame(width: 7, height: 7) + .padding(.trailing, 120) + + } + .padding(.leading, 60) + } + + VStack { + HStack { + Image("link") + .foregroundColor(DamusColors.neutral3) + + TextField(text: url_binding, label: { + Text("Add an external link", comment: "Placeholder as an example of what the user could set so that the link is opened when the status is tapped.") + }) + .focused($focusedTextField, equals: Fields.link) + } + .padding(10) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(DamusColors.neutral3, lineWidth: 1) + ) + } + .padding() + + Toggle(isOn: $status.playing_enabled, label: { + Text("Broadcast music playing on Apple Music", comment: "Toggle to enable or disable broadcasting what music is being played on Apple Music in their profile status.") + }) + .tint(DamusColors.purple) + .padding(.horizontal) + + HStack { + Text("Clear status", comment: "Label to prompt user to select an expiration time for the profile status to clear.") + + Spacer() + + Picker(NSLocalizedString("Duration", comment: "Label for profile status expiration duration picker."), selection: $duration) { + ForEach(StatusDuration.allCases, id: \.self) { d in + Text(verbatim: d.description) + .tag(d) + } + } + .pickerStyle(.segmented) } - .padding([.top], 30) + .padding() Spacer() } - .padding(30) + .padding(.top) } } struct UserStatusSheet_Previews: PreviewProvider { static var previews: some View { - UserStatusSheet(postbox: test_damus_state.postbox, keypair: test_keypair, status: .init()) + UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.postbox, keypair: test_keypair, status: .init()) } } diff --git a/damus/Components/WebsiteLink.swift b/damus/Components/WebsiteLink.swift index 90063dd003..8bb23f534d 100644 --- a/damus/Components/WebsiteLink.swift +++ b/damus/Components/WebsiteLink.swift @@ -9,33 +9,57 @@ import SwiftUI struct WebsiteLink: View { let url: URL + let style: StyleVariant @Environment(\.openURL) var openURL + + init(url: URL, style: StyleVariant? = nil) { + self.url = url + self.style = style ?? .normal + } var body: some View { HStack { Image("link") - .foregroundColor(.gray) - .font(.footnote) + .resizable() + .frame(width: 16, height: 16) + .foregroundColor(self.style == .accent ? .white : .gray) + .padding(.vertical, 5) + .padding([.leading], 10) Button(action: { openURL(url) }, label: { Text(link_text) .font(.footnote) - .foregroundColor(.accentColor) + .foregroundColor(self.style == .accent ? .white : .accentColor) .truncationMode(.tail) .lineLimit(1) }) + .padding(.vertical, 5) + .padding([.trailing], 10) } + .background( + self.style == .accent ? + AnyView(RoundedRectangle(cornerRadius: 50).fill(PinkGradient)) + : AnyView(Color.clear) + ) } var link_text: String { url.host ?? url.absoluteString } + + enum StyleVariant { + case normal + case accent + } } struct WebsiteLink_Previews: PreviewProvider { static var previews: some View { WebsiteLink(url: URL(string: "https://jb55.com")!) + .previewDisplayName("Normal") + WebsiteLink(url: URL(string: "https://jb55.com")!, style: .accent) + .previewDisplayName("Accent") } } diff --git a/damus/ContentView.swift b/damus/ContentView.swift index dc897f09fa..05a2378166 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -22,11 +22,12 @@ enum Sheets: Identifiable { case post(PostAction) case report(ReportTarget) case event(NostrEvent) + case profile_action(Pubkey) case zap(ZapSheet) case select_wallet(SelectWallet) case filter case user_status - case suggestedUsers + case onboardingSuggestions static func zap(target: ZapTarget, lnurl: String) -> Sheets { return .zap(ZapSheet(target: target, lnurl: lnurl)) @@ -42,10 +43,11 @@ enum Sheets: Identifiable { case .user_status: return "user_status" case .post(let action): return "post-" + (action.ev?.id.hex() ?? "") case .event(let ev): return "event-" + ev.id.hex() + case .profile_action(let pubkey): return "profile-action-" + pubkey.npub case .zap(let sheet): return "zap-" + hex_encode(sheet.target.id) case .select_wallet: return "select-wallet" case .filter: return "filter" - case .suggestedUsers: return "suggested-users" + case .onboardingSuggestions: return "onboarding-suggestions" } } } @@ -74,7 +76,7 @@ struct ContentView: View { @State private var isSideBarOpened = false var home: HomeModel = HomeModel() @StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator() - @AppStorage("has_seen_suggested_users") private var hasSeenSuggestedUsers = false + @AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false let sub_id = UUID().description @Environment(\.colorScheme) var colorScheme @@ -300,9 +302,9 @@ struct ContentView: View { self.connect() try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers) setup_notifications() - if !hasSeenSuggestedUsers { - active_sheet = .suggestedUsers - hasSeenSuggestedUsers = true + if !hasSeenOnboardingSuggestions { + active_sheet = .onboardingSuggestions + hasSeenOnboardingSuggestions = true } } .sheet(item: $active_sheet) { item in @@ -312,9 +314,12 @@ struct ContentView: View { case .post(let action): PostView(action: action, damus_state: damus_state!) case .user_status: - UserStatusSheet(postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status) + UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status) + .presentationDragIndicator(.visible) case .event: EventDetailView() + case .profile_action(let pubkey): + ProfileActionSheetView(damus_state: damus_state!, pubkey: pubkey) case .zap(let zapsheet): CustomizeZapView(state: damus_state!, target: zapsheet.target, lnurl: zapsheet.lnurl) case .select_wallet(let select): @@ -328,8 +333,8 @@ struct ContentView: View { } else { RelayFilterView(state: damus_state!, timeline: timeline) } - case .suggestedUsers: - SuggestedUsersView(model: SuggestedUsersViewModel(damus_state: damus_state!)) + case .onboardingSuggestions: + OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!)) } } .onOpenURL { url in @@ -443,7 +448,12 @@ struct ContentView: View { present_sheet(.select_wallet(invoice: inv)) } else { let wallet = damus_state!.settings.default_wallet.model - open_with_wallet(wallet: wallet, invoice: inv) + do { + try open_with_wallet(wallet: wallet, invoice: inv) + } + catch { + present_sheet(.select_wallet(invoice: inv)) + } } case .sent_from_nwc: break diff --git a/damus/Info.plist b/damus/Info.plist index b4e50bf497..00aada81dd 100644 --- a/damus/Info.plist +++ b/damus/Info.plist @@ -67,10 +67,10 @@ NSCameraUsageDescription - Damus needs access to your camera if you want to upload photos from it + Damus needs access to your camera for creating photo posts NSAppleMusicUsageDescription Damus needs access to your media library for playback statuses NSMicrophoneUsageDescription - Damus needs access to your microphone if you want to upload recorded videos from it + Damus needs access to your microphone for creating video recording posts diff --git a/damus/Models/DraftsModel.swift b/damus/Models/DraftsModel.swift index 97fdc6d534..ab71d839fb 100644 --- a/damus/Models/DraftsModel.swift +++ b/damus/Models/DraftsModel.swift @@ -7,7 +7,7 @@ import Foundation -class DraftArtifacts { +class DraftArtifacts: Equatable { var content: NSMutableAttributedString var media: [UploadedMedia] @@ -15,6 +15,13 @@ class DraftArtifacts { self.content = content self.media = media } + + static func == (lhs: DraftArtifacts, rhs: DraftArtifacts) -> Bool { + return ( + lhs.media == rhs.media && + lhs.content.string == rhs.content.string // Comparing the text content is not perfect but acceptable in this case because attributes for our post editor are determined purely from text content + ) + } } class Drafts: ObservableObject { diff --git a/damus/Models/EventsModel.swift b/damus/Models/EventsModel.swift index 96248b8640..25a61badb6 100644 --- a/damus/Models/EventsModel.swift +++ b/damus/Models/EventsModel.swift @@ -64,7 +64,8 @@ class EventsModel: ObservableObject { case .ok: break case .eose: - load_profiles(profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events), damus_state: state) + let txn = NdbTxn(ndb: self.state.ndb) + load_profiles(context: "events_model", profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events), damus_state: state, txn: txn) } } } diff --git a/damus/Models/FollowersModel.swift b/damus/Models/FollowersModel.swift index 4faee3f03f..1f5ca6caeb 100644 --- a/damus/Models/FollowersModel.swift +++ b/damus/Models/FollowersModel.swift @@ -53,8 +53,8 @@ class FollowersModel: ObservableObject { has_contact.insert(ev.pubkey) } - func load_profiles(relay_id: String) { - let authors = find_profiles_to_fetch_from_keys(profiles: damus_state.profiles, pks: contacts ?? []) + func load_profiles(relay_id: String, txn: NdbTxn) { + let authors = find_profiles_to_fetch_from_keys(profiles: damus_state.profiles, pks: contacts ?? [], txn: txn) if authors.isEmpty { return } @@ -83,7 +83,8 @@ class FollowersModel: ObservableObject { case .eose(let sub_id): if sub_id == self.sub_id { - load_profiles(relay_id: relay_id) + let txn = NdbTxn(ndb: self.damus_state.ndb) + load_profiles(relay_id: relay_id, txn: txn) } else if sub_id == self.profiles_id { damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id]) } diff --git a/damus/Models/FollowingModel.swift b/damus/Models/FollowingModel.swift index 45f84fe1ef..87c67074e8 100644 --- a/damus/Models/FollowingModel.swift +++ b/damus/Models/FollowingModel.swift @@ -22,11 +22,11 @@ class FollowingModel { self.hashtags = hashtags } - func get_filter() -> NostrFilter { + func get_filter(txn: NdbTxn) -> NostrFilter { var f = NostrFilter(kinds: [.metadata]) f.authors = self.contacts.reduce(into: Array()) { acc, pk in // don't fetch profiles we already have - if damus_state.profiles.has_fresh_profile(id: pk) { + if damus_state.profiles.has_fresh_profile(id: pk, txn: txn) { return } acc.append(pk) @@ -34,8 +34,8 @@ class FollowingModel { return f } - func subscribe() { - let filter = get_filter() + func subscribe(txn: NdbTxn) { + let filter = get_filter(txn: txn) if (filter.authors?.count ?? 0) == 0 { needs_sub = false return diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index 4ee4d752b6..266e853bf3 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -430,14 +430,15 @@ class HomeModel { case .eose(let sub_id): + let txn = NdbTxn(ndb: damus_state.ndb) if sub_id == dms_subid { var dms = dms.dms.flatMap { $0.events } dms.append(contentsOf: incoming_dms) - load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(dms), damus_state: damus_state) + load_profiles(context: "dms", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(dms), damus_state: damus_state, txn: txn) } else if sub_id == notifications_subid { - load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_keys(notifications.uniq_pubkeys()), damus_state: damus_state) + load_profiles(context: "notifications", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_keys(notifications.uniq_pubkeys()), damus_state: damus_state, txn: txn) } else if sub_id == home_subid { - load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus_state) + load_profiles(context: "home", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus_state, txn: txn) } self.loading = false diff --git a/damus/Models/NostrFilter+Hashable.swift b/damus/Models/NostrFilter+Hashable.swift new file mode 100644 index 0000000000..6c6b8dbc6f --- /dev/null +++ b/damus/Models/NostrFilter+Hashable.swift @@ -0,0 +1,19 @@ +// +// NostrFilter+Hashable.swift +// damus +// +// Created by Davide De Rosa on 10/21/23. +// + +import Foundation + +// FIXME: fine-tune here what's involved in comparing search filters +extension NostrFilter: Hashable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.hashtag == rhs.hashtag + } + + func hash(into hasher: inout Hasher) { + hasher.combine(hashtag) + } +} diff --git a/damus/Models/ProfileModel.swift b/damus/Models/ProfileModel.swift index 71229d3173..15941596bb 100644 --- a/damus/Models/ProfileModel.swift +++ b/damus/Models/ProfileModel.swift @@ -123,8 +123,9 @@ class ProfileModel: ObservableObject, Equatable { break //notify(.notice, notice) case .eose: + let txn = NdbTxn(ndb: damus.ndb) if resp.subid == sub_id { - load_profiles(profiles_subid: prof_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus) + load_profiles(context: "profile", profiles_subid: prof_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus, txn: txn) } progress += 1 break diff --git a/damus/Models/SearchHomeModel.swift b/damus/Models/SearchHomeModel.swift index b655b3e397..ff78035afa 100644 --- a/damus/Models/SearchHomeModel.swift +++ b/damus/Models/SearchHomeModel.swift @@ -17,7 +17,7 @@ class SearchHomeModel: ObservableObject { let damus_state: DamusState let base_subid = UUID().description let profiles_subid = UUID().description - let limit: UInt32 = 250 + let limit: UInt32 = 500 //let multiple_events_per_pubkey: Bool = false init(damus_state: DamusState) { @@ -83,38 +83,38 @@ class SearchHomeModel: ObservableObject { // global events are not realtime unsubscribe(to: relay_id) - load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.all_events), damus_state: damus_state) + let txn = NdbTxn(ndb: damus_state.ndb) + load_profiles(context: "universe", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.all_events), damus_state: damus_state, txn: txn) } - - + break } } } -func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad, cache: EventCache) -> [Pubkey] { +func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad, cache: EventCache, txn: NdbTxn) -> [Pubkey] { switch load { case .from_events(let events): - return find_profiles_to_fetch_from_events(profiles: profiles, events: events, cache: cache) + return find_profiles_to_fetch_from_events(profiles: profiles, events: events, cache: cache, txn: txn) case .from_keys(let pks): - return find_profiles_to_fetch_from_keys(profiles: profiles, pks: pks) + return find_profiles_to_fetch_from_keys(profiles: profiles, pks: pks, txn: txn) } } -func find_profiles_to_fetch_from_keys(profiles: Profiles, pks: [Pubkey]) -> [Pubkey] { - Array(Set(pks.filter { pk in !profiles.has_fresh_profile(id: pk) })) +func find_profiles_to_fetch_from_keys(profiles: Profiles, pks: [Pubkey], txn: NdbTxn) -> [Pubkey] { + Array(Set(pks.filter { pk in !profiles.has_fresh_profile(id: pk, txn: txn) })) } -func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent], cache: EventCache) -> [Pubkey] { +func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent], cache: EventCache, txn: NdbTxn) -> [Pubkey] { var pubkeys = Set() for ev in events { // lookup profiles from boosted events - if ev.known_kind == .boost, let bev = ev.get_inner_event(cache: cache), !profiles.has_fresh_profile(id: bev.pubkey) { + if ev.known_kind == .boost, let bev = ev.get_inner_event(cache: cache), !profiles.has_fresh_profile(id: bev.pubkey, txn: txn) { pubkeys.insert(bev.pubkey) } - if !profiles.has_fresh_profile(id: ev.pubkey) { + if !profiles.has_fresh_profile(id: ev.pubkey, txn: txn) { pubkeys.insert(ev.pubkey) } } @@ -127,27 +127,42 @@ enum PubkeysToLoad { case from_keys([Pubkey]) } -func load_profiles(profiles_subid: String, relay_id: String, load: PubkeysToLoad, damus_state: DamusState) { - let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load, cache: damus_state.events) +func load_profiles(context: String, profiles_subid: String, relay_id: String, load: PubkeysToLoad, damus_state: DamusState, txn: NdbTxn) { + let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load, cache: damus_state.events, txn: txn) + guard !authors.isEmpty else { return } - print("loading \(authors.count) profiles from \(relay_id)") - - let filter = NostrFilter(kinds: [.metadata], - authors: authors) - - damus_state.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { sub_id, conn_ev in - guard case .nostr_event(let ev) = conn_ev, - case .eose = ev, - sub_id == profiles_subid - else { - return + print("load_profiles[\(context)]: requesting \(authors.count) profiles from \(relay_id)") + + let filter = NostrFilter(kinds: [.metadata], authors: authors) + + damus_state.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in + + let now = UInt64(Date.now.timeIntervalSince1970) + switch conn_ev { + case .ws_event: + break + case .nostr_event(let ev): + guard ev.subid == profiles_subid, rid == relay_id else { return } + + switch ev { + case .event(_, let ev): + if ev.known_kind == .metadata { + damus_state.ndb.write_profile_last_fetched(pubkey: ev.pubkey, fetched_at: now) + } + case .eose: + print("load_profiles[\(context)]: done loading \(authors.count) profiles from \(relay_id)") + damus_state.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id]) + case .ok: + break + case .notice: + break + } } - print("done loading \(authors.count) profiles from \(relay_id)") - damus_state.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id]) + } } diff --git a/damus/Models/SearchModel.swift b/damus/Models/SearchModel.swift index a80eb5558a..520ea7cf13 100644 --- a/damus/Models/SearchModel.swift +++ b/damus/Models/SearchModel.swift @@ -80,7 +80,8 @@ class SearchModel: ObservableObject { self.loading = false if sub_id == self.sub_id { - load_profiles(profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state) + let txn = NdbTxn(ndb: state.ndb) + load_profiles(context: "search", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state, txn: txn) } } } diff --git a/damus/Models/ThreadModel.swift b/damus/Models/ThreadModel.swift index 9c44f32983..760b5010f1 100644 --- a/damus/Models/ThreadModel.swift +++ b/damus/Models/ThreadModel.swift @@ -120,7 +120,8 @@ class ThreadModel: ObservableObject { } if sub_id == self.base_subid { - load_profiles(profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(Array(event_map)), damus_state: damus_state) + let txn = NdbTxn(ndb: damus_state.ndb) + load_profiles(context: "thread", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(Array(event_map)), damus_state: damus_state, txn: txn) } } diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift index 2bab8c1e0a..a9137addae 100644 --- a/damus/Models/UserSettingsStore.swift +++ b/damus/Models/UserSettingsStore.swift @@ -112,6 +112,9 @@ class UserSettingsStore: ObservableObject { @Setting(key: "hide_nsfw_tagged_content", default_value: false) var hide_nsfw_tagged_content: Bool + + @Setting(key: "show_profile_action_sheet_on_pfp_click", default_value: true) + var show_profile_action_sheet_on_pfp_click: Bool @Setting(key: "zap_vibration", default_value: true) var zap_vibration: Bool diff --git a/damus/Models/Wallet.swift b/damus/Models/Wallet.swift index 30682dfc86..a90b25f0dd 100644 --- a/damus/Models/Wallet.swift +++ b/damus/Models/Wallet.swift @@ -51,7 +51,7 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable { switch self { case .system_default_wallet: return .init(index: -1, tag: "systemdefaultwallet", displayName: NSLocalizedString("Local default", comment: "Dropdown option label for system default for Lightning wallet."), - link: "lightning:", appStoreLink: "lightning:", image: "") + link: "lightning:", appStoreLink: nil, image: "") case .strike: return .init(index: 0, tag: "strike", displayName: "Strike", link: "strike:", appStoreLink: "https://apps.apple.com/us/app/strike-bitcoin-payments/id1488724463", image: "strike") diff --git a/damus/Models/ZapsModel.swift b/damus/Models/ZapsModel.swift index 3a9aeb34c0..2b1cb9fd3c 100644 --- a/damus/Models/ZapsModel.swift +++ b/damus/Models/ZapsModel.swift @@ -55,7 +55,8 @@ class ZapsModel: ObservableObject { break case .eose: let events = state.events.lookup_zaps(target: target).map { $0.request.ev } - load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: state) + let txn = NdbTxn(ndb: state.ndb) + load_profiles(context: "zaps_model", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: state, txn: txn) case .event(_, let ev): guard ev.kind == 9735, let zapper = state.profiles.lookup_zapper(pubkey: target.pubkey), diff --git a/damus/Nostr/Profiles.swift b/damus/Nostr/Profiles.swift index 10dec04cbb..ad9a5ddeb2 100644 --- a/damus/Nostr/Profiles.swift +++ b/damus/Nostr/Profiles.swift @@ -30,7 +30,7 @@ class ProfileData { class Profiles { private var ndb: Ndb - static let db_freshness_threshold: TimeInterval = 24 * 60 * 60 + static let db_freshness_threshold: TimeInterval = 24 * 60 * 8 @MainActor private var profiles: [Pubkey: ProfileData] = [:] @@ -93,9 +93,24 @@ class Profiles { return ndb.lookup_profile_key(pubkey) } - func has_fresh_profile(id: Pubkey) -> Bool { - guard let recv = lookup_with_timestamp(id).unsafeUnownedValue?.receivedAt else { return false } - return Date.now.timeIntervalSince(Date(timeIntervalSince1970: Double(recv))) < Profiles.db_freshness_threshold + func has_fresh_profile(id: Pubkey, txn: NdbTxn) -> Bool { + guard let fetched_at = ndb.read_profile_last_fetched(txn: txn, pubkey: id) + else { + return false + } + + // In situations where a batch of profiles was fetched all at once, + // this will reduce the herding of the profile requests + let fuzz = Double.random(in: -60...60) + let threshold = Profiles.db_freshness_threshold + fuzz + let fetch_date = Date(timeIntervalSince1970: Double(fetched_at)) + + let since = Date.now.timeIntervalSince(fetch_date) + let fresh = since < threshold + + //print("fresh = \(fresh): fetch_date \(since) < threshold \(threshold) \(id)") + + return fresh } } diff --git a/damus/Util/Extensions/KFOptionSetter+.swift b/damus/Util/Extensions/KFOptionSetter+.swift index e675144800..264fa77abe 100644 --- a/damus/Util/Extensions/KFOptionSetter+.swift +++ b/damus/Util/Extensions/KFOptionSetter+.swift @@ -28,6 +28,18 @@ extension KFOptionSetter { options.scaleFactor = UIScreen.main.scale options.onlyLoadFirstFrame = disable_animation + switch imageContext { + case .pfp: + options.diskCacheExpiration = .never + break + case .banner: + options.diskCacheExpiration = .days(14) + break + case .note: + options.diskCacheExpiration = .days(7) + break + } + return self } diff --git a/damus/Util/LNUrls.swift b/damus/Util/LNUrls.swift index c4614c338d..9d00b1a195 100644 --- a/damus/Util/LNUrls.swift +++ b/damus/Util/LNUrls.swift @@ -29,10 +29,10 @@ class LNUrls { guard tries < 5 else { return nil } self.endpoints[pubkey] = .failed(tries: tries + 1) case .fetched(let pr): - print("lnurls.lookup_or_fetch fetched \(lnurl)") + //print("lnurls.lookup_or_fetch fetched \(lnurl)") return pr case .fetching(let task): - print("lnurls.lookup_or_fetch already fetching \(lnurl)") + //print("lnurls.lookup_or_fetch already fetching \(lnurl)") return await task.value case .not_fetched: print("lnurls.lookup_or_fetch not fetched \(lnurl)") diff --git a/damus/Util/Router.swift b/damus/Util/Router.swift index d7d72d25c1..ac6731e700 100644 --- a/damus/Util/Router.swift +++ b/damus/Util/Router.swift @@ -188,8 +188,7 @@ enum Route: Hashable { hasher.combine(reactions.target) case .Search(let search): hasher.combine("search") - hasher.combine(search.sub_id) - hasher.combine(search.profiles_subid) + hasher.combine(search.search) case .EULA: hasher.combine("eula") case .Login: @@ -218,6 +217,9 @@ class NavigationCoordinator: ObservableObject { @Published var path = [Route]() func push(route: Route) { + guard route != path.last else { + return + } path.append(route) } diff --git a/damus/Views/ActionBar/EventActionBar.swift b/damus/Views/ActionBar/EventActionBar.swift index 3cb18a5a75..e0aa1ca1e4 100644 --- a/damus/Views/ActionBar/EventActionBar.swift +++ b/damus/Views/ActionBar/EventActionBar.swift @@ -84,7 +84,7 @@ struct EventActionBar: View { if let lnurl = self.lnurl { Spacer() - ZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: self.damus_state.events.get_cache_data(self.event.id).zaps_model) + NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: self.damus_state.events.get_cache_data(self.event.id).zaps_model) } Spacer() diff --git a/damus/Views/DirectMessagesView.swift b/damus/Views/DirectMessagesView.swift index 352cef7103..2ff3da747b 100644 --- a/damus/Views/DirectMessagesView.swift +++ b/damus/Views/DirectMessagesView.swift @@ -22,11 +22,12 @@ struct DirectMessagesView: View { func MainContent(requests: Bool) -> some View { ScrollView { LazyVStack(spacing: 0) { - if model.dms.isEmpty, !model.loading { + let dms = requests ? model.message_requests : model.friend_dms + let filtered_dms = filter_dms(dms: dms) + if filtered_dms.isEmpty, !model.loading { EmptyTimelineView() } else { - let dms = requests ? model.message_requests : model.friend_dms - ForEach(dms, id: \.pubkey) { dm in + ForEach(filtered_dms, id: \.pubkey) { dm in MaybeEvent(dm) .padding(.top, 10) } @@ -36,6 +37,12 @@ struct DirectMessagesView: View { } } + func filter_dms(dms: [DirectMessageModel]) -> [DirectMessageModel] { + return dms.filter({ dm in + return damus_state.settings.friend_filter.filter(contacts: damus_state.contacts, pubkey: dm.pubkey) && !damus_state.contacts.is_muted(dm.pubkey) + }) + } + var options: EventViewOptions { if self.damus_state.settings.translate_dms { return [.truncate_content, .no_action_bar] @@ -46,8 +53,7 @@ struct DirectMessagesView: View { func MaybeEvent(_ model: DirectMessageModel) -> some View { Group { - let ok = damus_state.settings.friend_filter.filter(contacts: damus_state.contacts, pubkey: model.pubkey) - if ok, let ev = model.events.last { + if let ev = model.events.last { EventView(damus: damus_state, event: ev, pubkey: model.pubkey, options: options) .onTapGesture { self.model.set_active_dm_model(model) diff --git a/damus/Views/Events/EventMenu.swift b/damus/Views/Events/EventMenu.swift index 0c2aead794..183824f56e 100644 --- a/damus/Views/Events/EventMenu.swift +++ b/damus/Views/Events/EventMenu.swift @@ -37,7 +37,7 @@ struct EventMenuContext: View { Color.clear } // Hitbox frame size - .frame(width: 100, height: 70) + .frame(width: 50, height: 35) ) } .padding([.bottom], 4) diff --git a/damus/Views/Events/EventProfile.swift b/damus/Views/Events/EventProfile.swift index 6d8071209d..ac5130c740 100644 --- a/damus/Views/Events/EventProfile.swift +++ b/damus/Views/Events/EventProfile.swift @@ -37,9 +37,9 @@ struct EventProfile: View { var body: some View { HStack(alignment: .center, spacing: 10) { - ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation) + ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation, show_zappability: true) .onTapGesture { - damus_state.nav.push(route: .ProfileByKey(pubkey: pubkey)) + show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: pubkey) } VStack(alignment: .leading, spacing: 0) { diff --git a/damus/Views/FollowingView.swift b/damus/Views/FollowingView.swift index 045daa0cd1..16dab49c16 100644 --- a/damus/Views/FollowingView.swift +++ b/damus/Views/FollowingView.swift @@ -151,7 +151,8 @@ struct FollowingView: View { } .tabViewStyle(.page(indexDisplayMode: .never)) .onAppear { - following.subscribe() + let txn = NdbTxn(ndb: self.damus_state.ndb) + following.subscribe(txn: txn) } .onDisappear { following.unsubscribe() diff --git a/damus/Views/Images/ImageContextMenuModifier.swift b/damus/Views/Images/ImageContextMenuModifier.swift index 167fce0368..bf0093a77e 100644 --- a/damus/Views/Images/ImageContextMenuModifier.swift +++ b/damus/Views/Images/ImageContextMenuModifier.swift @@ -61,7 +61,12 @@ struct ImageContextMenuModifier: ViewModifier { no_link_found.toggle() } else { if qrCodeLink.contains("lnurl") { - open_with_wallet(wallet: settings.default_wallet.model, invoice: qrCodeLink) + do { + try open_with_wallet(wallet: settings.default_wallet.model, invoice: qrCodeLink) + } + catch { + present_sheet(.select_wallet(invoice: qrCodeLink)) + } } else if let _ = URL(string: qrCodeLink) { open_link_confirm.toggle() } diff --git a/damus/Views/LoginView.swift b/damus/Views/LoginView.swift index d5901f2228..cd9e741ef0 100644 --- a/damus/Views/LoginView.swift +++ b/damus/Views/LoginView.swift @@ -30,6 +30,13 @@ enum ParsedKey { } return false } + + var is_priv: Bool { + if case .priv = self { + return true + } + return false + } } struct LoginView: View { @@ -37,6 +44,7 @@ struct LoginView: View { @State var is_pubkey: Bool = false @State var error: String? = nil @State private var credential_handler = CredentialHandler() + @State private var shouldSaveKey: Bool = true var nav: NavigationCoordinator func get_error(parsed_key: ParsedKey?) -> String? { @@ -57,7 +65,7 @@ struct LoginView: View { SignInHeader() .padding(.top, 100) - SignInEntry(key: $key) + SignInEntry(key: $key, shouldSaveKey: $shouldSaveKey) let parsed = parse_key(key) @@ -83,7 +91,7 @@ struct LoginView: View { Button(action: { Task { do { - try await process_login(p, is_pubkey: is_pubkey) + try await process_login(p, is_pubkey: is_pubkey, shouldSaveKey: shouldSaveKey) } catch { self.error = error.localizedDescription } @@ -168,37 +176,39 @@ enum LoginError: LocalizedError { } } -func process_login(_ key: ParsedKey, is_pubkey: Bool) async throws { - switch key { - case .priv(let priv): - try handle_privkey(priv) - case .pub(let pub): - try clear_saved_privkey() - save_pubkey(pubkey: pub) - - case .nip05(let id): - guard let nip05 = await get_nip05_pubkey(id: id) else { - throw LoginError.nip05_failed - } - - // this is a weird way to login anyways - /* - var bootstrap_relays = load_bootstrap_relays(pubkey: nip05.pubkey) - for relay in nip05.relays { - if !(bootstrap_relays.contains { $0 == relay }) { - bootstrap_relays.append(relay) - } - } - */ - save_pubkey(pubkey: nip05.pubkey) - - case .hex(let hexstr): - if is_pubkey, let pubkey = hex_decode_pubkey(hexstr) { +func process_login(_ key: ParsedKey, is_pubkey: Bool, shouldSaveKey: Bool = true) async throws { + if shouldSaveKey { + switch key { + case .priv(let priv): + try handle_privkey(priv) + case .pub(let pub): try clear_saved_privkey() + save_pubkey(pubkey: pub) + + case .nip05(let id): + guard let nip05 = await get_nip05_pubkey(id: id) else { + throw LoginError.nip05_failed + } - save_pubkey(pubkey: pubkey) - } else if let privkey = hex_decode_privkey(hexstr) { - try handle_privkey(privkey) + // this is a weird way to login anyways + /* + var bootstrap_relays = load_bootstrap_relays(pubkey: nip05.pubkey) + for relay in nip05.relays { + if !(bootstrap_relays.contains { $0 == relay }) { + bootstrap_relays.append(relay) + } + } + */ + save_pubkey(pubkey: nip05.pubkey) + + case .hex(let hexstr): + if is_pubkey, let pubkey = hex_decode_pubkey(hexstr) { + try clear_saved_privkey() + + save_pubkey(pubkey: pubkey) + } else if let privkey = hex_decode_privkey(hexstr) { + try handle_privkey(privkey) + } } } @@ -213,7 +223,16 @@ func process_login(_ key: ParsedKey, is_pubkey: Bool) async throws { save_pubkey(pubkey: pk) } - guard let keypair = get_saved_keypair() else { + func handle_transient_privkey(_ key: ParsedKey) -> Keypair? { + if case let .priv(priv) = key, let pubkey = privkey_to_pubkey(privkey: priv) { + return Keypair(pubkey: pubkey, privkey: priv) + } + return nil + } + + let keypair = shouldSaveKey ? get_saved_keypair() : handle_transient_privkey(key) + + guard let keypair = keypair else { return } @@ -265,11 +284,15 @@ func get_nip05_pubkey(id: String) async -> NIP05User? { struct KeyInput: View { let title: String let key: Binding + let shouldSaveKey: Binding + var privKeyFound: Binding @State private var is_secured: Bool = true - init(_ title: String, key: Binding) { + init(_ title: String, key: Binding, shouldSaveKey: Binding, privKeyFound: Binding) { self.title = title self.key = key + self.shouldSaveKey = shouldSaveKey + self.privKeyFound = privKeyFound } var body: some View { @@ -281,6 +304,8 @@ struct KeyInput: View { self.key.wrappedValue = pastedkey } } + SignInScan(shouldSaveKey: shouldSaveKey, loginKey: key, privKeyFound: privKeyFound) + if is_secured { SecureField("", text: key) .nsecLoginStyle(key: key.wrappedValue, title: title) @@ -323,18 +348,79 @@ struct SignInHeader: View { struct SignInEntry: View { let key: Binding - + let shouldSaveKey: Binding + @State private var privKeyFound: Bool = false var body: some View { VStack(alignment: .leading) { Text("Enter your account key", comment: "Prompt for user to enter an account key to login.") .fontWeight(.medium) .padding(.top, 30) - KeyInput(NSLocalizedString("nsec1...", comment: "Prompt for user to enter in an account key to login. This text shows the characters the key could start with if it was a private key."), key: key) + KeyInput(NSLocalizedString("nsec1...", comment: "Prompt for user to enter in an account key to login. This text shows the characters the key could start with if it was a private key."), + key: key, + shouldSaveKey: shouldSaveKey, + privKeyFound: $privKeyFound) + if privKeyFound { + Toggle("Save Key in Secure Keychain", isOn: shouldSaveKey) + } } } } +struct SignInScan: View { + @State var showQR: Bool = false + @State var qrkey: ParsedKey? + @Binding var shouldSaveKey: Bool + @Binding var loginKey: String + @Binding var privKeyFound: Bool + let generator = UINotificationFeedbackGenerator() + + var body: some View { + VStack { + Button(action: { showQR.toggle() }, label: { + Image(systemName: "qrcode.viewfinder")}) + .foregroundColor(.gray) + + } + .sheet(isPresented: $showQR, onDismiss: { + if qrkey == nil { resetView() }} + ) { + QRScanNSECView(showQR: $showQR, + privKeyFound: $privKeyFound, + codeScannerCompletion: { scannerCompletion($0) }) + } + .onChange(of: showQR) { show in + if showQR { resetView() } + } + } + + func handleQRString(_ string: String) { + qrkey = parse_key(string) + if let key = qrkey, key.is_priv { + loginKey = string + privKeyFound = true + shouldSaveKey = false + generator.notificationOccurred(.success) + } + } + + func scannerCompletion(_ result: Result) { + switch result { + case .success(let success): + handleQRString(success.string) + case .failure: + return + } + } + + func resetView() { + loginKey = "" + qrkey = nil + privKeyFound = false + shouldSaveKey = true + } +} + struct CreateAccountPrompt: View { var nav: NavigationCoordinator var body: some View { diff --git a/damus/Views/Onboarding/OnboardingSuggestionsView.swift b/damus/Views/Onboarding/OnboardingSuggestionsView.swift new file mode 100644 index 0000000000..3a4eb3075e --- /dev/null +++ b/damus/Views/Onboarding/OnboardingSuggestionsView.swift @@ -0,0 +1,123 @@ +// +// OnboardingSuggestionsView.swift +// damus +// +// Created by klabo on 7/17/23. +// + +import SwiftUI + +fileprivate let first_post_example_1: String = NSLocalizedString("Hello everybody!\n\nThis is my first post on Damus, I am happy to meet you all 🤙. What’s up?\n\n#introductions", comment: "First post example given to the user during onboarding, as a suggestion as to what they could post first") +fileprivate let first_post_example_2: String = NSLocalizedString("This is my first post on Nostr 💜. I love drawing and folding Origami!\n\nNice to meet you all! #introductions #plebchain ", comment: "First post example given to the user during onboarding, as a suggestion as to what they could post first") +fileprivate let first_post_example_3: String = NSLocalizedString("For #Introductions! I’m a software developer.\n\nMy side interests include languages and I am striving to be a #polyglot - I am a native English speaker and can speak French, German and Japanese.", comment: "First post example given to the user during onboarding, as a suggestion as to what they could post first") +fileprivate let first_post_example_4: String = NSLocalizedString("Howdy! I’m a graphic designer during the day and coder at night, but I’m also trying to spend more time outdoors.\n\nHope to meet folks who are on their own journeys to a peaceful and free life!", comment: "First post example given to the user during onboarding, as a suggestion as to what they could post first") + +struct OnboardingSuggestionsView: View { + + @StateObject var model: SuggestedUsersViewModel + @State var current_page: Int = 0 + let first_post_examples: [String] = [first_post_example_1, first_post_example_2, first_post_example_3, first_post_example_4] + let initial_text_suffix: String = "\n\n#introductions" + + @Environment(\.presentationMode) private var presentationMode + + func next_page() { + withAnimation { + current_page += 1 + } + } + + 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")) + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(leading: Button(action: { + self.next_page() + }, label: { + Text(NSLocalizedString("Skip", comment: "Button to dismiss the suggested users screen")) + .font(.subheadline.weight(.semibold)) + })) + .tag(0) + + PostView( + action: .posting(.user(model.damus_state.pubkey)), + damus_state: model.damus_state, + prompt_view: { + AnyView( + HStack { + Image(systemName: "sparkles") + Text(NSLocalizedString("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 + ) + .tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + } +} + +fileprivate struct SuggestedUsersPageView: View { + var model: SuggestedUsersViewModel + var next_page: (() -> Void) + + 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) + } + } + } header: { + SuggestedUsersSectionHeader(group: group, model: model) + } + } + } + .listStyle(.plain) + + Spacer() + + Button(action: { + self.next_page() + }) { + Text(NSLocalizedString("Continue", comment: "Button to dismiss suggested users view and continue to the main app")) + .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) + } + .buttonStyle(GradientButtonStyle()) + .padding([.leading, .trailing], 24) + .padding(.bottom, 16) + } + } +} + +struct SuggestedUsersSectionHeader: View { + let group: SuggestedUserGroup + let model: SuggestedUsersViewModel + var body: some View { + HStack { + Text(group.title.uppercased()) + Spacer() + Button(NSLocalizedString("Follow All", comment: "Button to follow all users in this section")) { + model.follow(pubkeys: group.users) + } + .font(.subheadline.weight(.semibold)) + } + } +} + +struct SuggestedUsersView_Previews: PreviewProvider { + static var previews: some View { + OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: test_damus_state)) + } +} diff --git a/damus/Views/Onboarding/SuggestedUsersView.swift b/damus/Views/Onboarding/SuggestedUsersView.swift deleted file mode 100644 index 4ce268ccf5..0000000000 --- a/damus/Views/Onboarding/SuggestedUsersView.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// SuggestedUsersView.swift -// damus -// -// Created by klabo on 7/17/23. -// - -import SwiftUI - -struct SuggestedUsersView: View { - - @StateObject var model: SuggestedUsersViewModel - - @Environment(\.presentationMode) private var presentationMode - - var body: some View { - NavigationView { - 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) - } - } - } header: { - SuggestedUsersSectionHeader(group: group, model: model) - } - } - } - .listStyle(.plain) - - Spacer() - - Button(action: { - presentationMode.wrappedValue.dismiss() - }) { - Text(NSLocalizedString("Continue", comment: "Button to dismiss suggested users view and continue to the main app")) - .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) - } - .buttonStyle(GradientButtonStyle()) - .padding([.leading, .trailing], 24) - .padding(.bottom, 16) - } - .navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow")) - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(trailing: Button(action: { - presentationMode.wrappedValue.dismiss() - }, label: { - Text(NSLocalizedString("Skip", comment: "Button to dismiss the suggested users screen")) - .font(.subheadline.weight(.semibold)) - })) - } - } -} - -struct SuggestedUsersSectionHeader: View { - let group: SuggestedUserGroup - let model: SuggestedUsersViewModel - var body: some View { - HStack { - Text(group.title.uppercased()) - Spacer() - Button(NSLocalizedString("Follow All", comment: "Button to follow all users in this section")) { - model.follow(pubkeys: group.users) - } - .font(.subheadline.weight(.semibold)) - } - } -} - -struct SuggestedUsersView_Previews: PreviewProvider { - static var previews: some View { - SuggestedUsersView(model: SuggestedUsersViewModel(damus_state: test_damus_state)) - } -} diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift index e07e0c9324..46d3bf2633 100644 --- a/damus/Views/PostView.swift +++ b/damus/Views/PostView.swift @@ -62,9 +62,28 @@ struct PostView: View { @StateObject var image_upload: ImageUploadModel = ImageUploadModel() @StateObject var tagModel: TagModel = TagModel() + + @State private var current_placeholder_index = 0 let action: PostAction let damus_state: DamusState + let prompt_view: (() -> AnyView)? + let placeholder_messages: [String] + let initial_text_suffix: String? + + init( + action: PostAction, + damus_state: DamusState, + prompt_view: (() -> AnyView)? = nil, + placeholder_messages: [String]? = nil, + initial_text_suffix: String? = nil + ) { + self.action = action + self.damus_state = damus_state + self.prompt_view = prompt_view + self.placeholder_messages = placeholder_messages ?? [POST_PLACEHOLDER] + self.initial_text_suffix = initial_text_suffix + } @Environment(\.presentationMode) var presentationMode @@ -84,7 +103,7 @@ struct PostView: View { } return true } - let new_post = build_post(post: self.post, action: action, uploadedMedias: uploadedMedias, references: refs) + let new_post = build_post(state: damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: refs) notify(.post(.post(new_post))) @@ -151,12 +170,10 @@ struct PostView: View { } } .disabled(posting_disabled) - .font(.system(size: 14, weight: .bold)) - .frame(width: 80, height: 30) - .foregroundColor(.white) - .background(LINEAR_GRADIENT) .opacity(posting_disabled ? 0.5 : 1.0) - .clipShape(Capsule()) + .bold() + .buttonStyle(GradientButtonStyle(padding: 10)) + } func isEmpty() -> Bool { @@ -214,12 +231,19 @@ struct PostView: View { var TextEntry: some View { ZStack(alignment: .topLeading) { - TextViewWrapper(attributedText: $post, textHeight: $textHeight, cursorIndex: newCursorIndex, getFocusWordForMention: { word, range in - focusWordAttributes = (word, range) - self.newCursorIndex = nil - }, updateCursorPosition: { newCursorIndex in - self.newCursorIndex = newCursorIndex - }) + TextViewWrapper( + attributedText: $post, + textHeight: $textHeight, + initialTextSuffix: initial_text_suffix, + cursorIndex: newCursorIndex, + getFocusWordForMention: { word, range in + focusWordAttributes = (word, range) + self.newCursorIndex = nil + }, + updateCursorPosition: { newCursorIndex in + self.newCursorIndex = newCursorIndex + } + ) .environmentObject(tagModel) .focused($focus) .textInputAutocapitalization(.sentences) @@ -230,22 +254,33 @@ struct PostView: View { .frame(height: get_valid_text_height()) if post.string.isEmpty { - Text(POST_PLACEHOLDER) + Text(self.placeholder_messages[self.current_placeholder_index]) .padding(.top, 8) .padding(.leading, 4) .foregroundColor(Color(uiColor: .placeholderText)) .allowsHitTesting(false) } } + .onAppear { + // Schedule a timer to switch messages every 3 seconds + Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { timer in + withAnimation { + self.current_placeholder_index = (self.current_placeholder_index + 1) % self.placeholder_messages.count + } + } + } } var TopBar: some View { VStack { HStack(spacing: 5.0) { - Button(NSLocalizedString("Cancel", comment: "Button to cancel out of posting a note.")) { + Button(action: { self.cancel() - } - .foregroundColor(.primary) + }, label: { + Text(NSLocalizedString("Cancel", comment: "Button to cancel out of posting a note.")) + .padding(10) + }) + .buttonStyle(NeutralButtonStyle()) if let error { Text(error) @@ -261,9 +296,14 @@ struct PostView: View { ProgressView(value: progress, total: 1.0) .progressViewStyle(.linear) } + + Divider() + .foregroundColor(DamusColors.neutral3) + .padding(.top, 5) } .frame(height: 30) .padding() + .padding(.top, 15) } func handle_upload(media: MediaUpload) { @@ -312,7 +352,12 @@ struct PostView: View { HStack(alignment: .top) { ProfilePicView(pubkey: damus_state.pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) - TextEntry + VStack(alignment: .leading) { + if let prompt_view { + prompt_view() + } + TextEntry + } } .id("post") @@ -369,6 +414,7 @@ struct PostView: View { } Editor(deviceSize: deviceSize) + .padding(.top, 5) } } .frame(maxHeight: searching == nil ? deviceSize.size.height : 70) @@ -587,7 +633,7 @@ func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts? } -func build_post(post: NSMutableAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId]) -> NostrPost { +func build_post(state: DamusState, post: NSMutableAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId]) -> NostrPost { post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in if let link = attributes[.link] as? String { let normalized_link: String @@ -610,7 +656,7 @@ func build_post(post: NSMutableAttributedString, action: PostAction, uploadedMed let imagesString = uploadedMedias.map { $0.uploadedURL.absoluteString }.joined(separator: " ") - let img_meta_tags = uploadedMedias.compactMap { $0.metadata?.to_tag() } + var tags = uploadedMedias.compactMap { $0.metadata?.to_tag() } if !imagesString.isEmpty { content.append(" " + imagesString + " ") @@ -618,7 +664,12 @@ func build_post(post: NSMutableAttributedString, action: PostAction, uploadedMed if case .quoting(let ev) = action { content.append(" nostr:" + bech32_note_id(ev.id)) + + if let quoted_ev = state.events.lookup(ev.id) { + tags.append(["p", quoted_ev.pubkey.hex()]) + } } - return NostrPost(content: content, references: references, kind: .text, tags: img_meta_tags) + return NostrPost(content: content, references: references, kind: .text, tags: tags) } + diff --git a/damus/Views/Profile/AboutView.swift b/damus/Views/Profile/AboutView.swift index 948f86a220..905b8001d5 100644 --- a/damus/Views/Profile/AboutView.swift +++ b/damus/Views/Profile/AboutView.swift @@ -10,15 +10,23 @@ import SwiftUI struct AboutView: View { let state: DamusState let about: String - let max_about_length = 280 + let max_about_length: Int + let text_alignment: NSTextAlignment @State var show_full_about: Bool = false @State private var about_string: AttributedString? = nil + init(state: DamusState, about: String, max_about_length: Int? = nil, text_alignment: NSTextAlignment? = nil) { + self.state = state + self.about = about + self.max_about_length = max_about_length ?? 280 + self.text_alignment = text_alignment ?? .natural + } + var body: some View { Group { if let about_string { let truncated_about = show_full_about ? about_string : about_string.truncateOrNil(maxLength: max_about_length) - SelectableText(attributedString: truncated_about ?? about_string, size: .subheadline) + SelectableText(attributedString: truncated_about ?? about_string, textAlignment: self.text_alignment, size: .subheadline) if truncated_about != nil { if show_full_about { diff --git a/damus/Views/Profile/EventProfileName.swift b/damus/Views/Profile/EventProfileName.swift index bde6c2a206..28cec949de 100644 --- a/damus/Views/Profile/EventProfileName.swift +++ b/damus/Views/Profile/EventProfileName.swift @@ -66,12 +66,14 @@ struct EventProfileName: View { .font(.body.weight(.bold)) case .both(username: let username, displayName: let displayName): - Text(verbatim: displayName) - .font(.body.weight(.bold)) - - Text(verbatim: username) - .foregroundColor(.gray) - .font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size)) + HStack(spacing: 6) { + Text(verbatim: displayName) + .font(.body.weight(.bold)) + + Text(verbatim: "@\(username)") + .foregroundColor(.gray) + .font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size)) + } } /* diff --git a/damus/Views/Profile/MaybeAnonPfpView.swift b/damus/Views/Profile/MaybeAnonPfpView.swift index 0b4256e4d5..f5cf1249ee 100644 --- a/damus/Views/Profile/MaybeAnonPfpView.swift +++ b/damus/Views/Profile/MaybeAnonPfpView.swift @@ -21,16 +21,16 @@ struct MaybeAnonPfpView: View { } var body: some View { - Group { + ZStack { if is_anon { Image("question") .resizable() .font(.largeTitle) .frame(width: size, height: size) } else { - ProfilePicView(pubkey: pubkey, size: size, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation) + ProfilePicView(pubkey: pubkey, size: size, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true) .onTapGesture { - state.nav.push(route: Route.ProfileByKey(pubkey: pubkey)) + show_profile_action_sheet_if_enabled(damus_state: state, pubkey: pubkey) } } } diff --git a/damus/Views/Profile/ProfileNameView.swift b/damus/Views/Profile/ProfileNameView.swift index eb1821d76c..44c585b4a2 100644 --- a/damus/Views/Profile/ProfileNameView.swift +++ b/damus/Views/Profile/ProfileNameView.swift @@ -7,84 +7,6 @@ import SwiftUI -fileprivate struct KeyView: View { - let pubkey: Pubkey - - @Environment(\.colorScheme) var colorScheme - - @State private var isCopied = false - - func keyColor() -> Color { - colorScheme == .light ? DamusColors.black : DamusColors.white - } - - private func copyPubkey(_ pubkey: String) { - UIPasteboard.general.string = pubkey - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - withAnimation { - isCopied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - withAnimation { - isCopied = false - } - } - } - } - - func pubkey_context_menu(pubkey: Pubkey) -> some View { - return self.contextMenu { - Button { - UIPasteboard.general.string = pubkey.npub - } label: { - Label(NSLocalizedString("Copy Account ID", comment: "Context menu option for copying the ID of the account that created the note."), image: "copy2") - } - } - } - - var body: some View { - let bech32 = pubkey.npub - - HStack { - Text(verbatim: "\(abbrev_pubkey(bech32, amount: 16))") - .font(.footnote) - .foregroundColor(keyColor()) - .padding(5) - .padding([.leading, .trailing], 5) - .background(RoundedRectangle(cornerRadius: 11).foregroundColor(DamusColors.adaptableGrey)) - - if isCopied { - HStack { - Image("check-circle") - .resizable() - .frame(width: 20, height: 20) - Text(NSLocalizedString("Copied", comment: "Label indicating that a user's key was copied.")) - .font(.footnote) - .layoutPriority(1) - } - .foregroundColor(DamusColors.green) - } else { - HStack { - Button { - copyPubkey(bech32) - } label: { - Label { - Text("Public key", comment: "Label indicating that the text is a user's public account key.") - } icon: { - Image("copy2") - .resizable() - .contentShape(Rectangle()) - .foregroundColor(.accentColor) - .frame(width: 20, height: 20) - } - .labelStyle(IconOnlyLabelStyle()) - .symbolRenderingMode(.hierarchical) - } - } - } - } - } -} - struct ProfileNameView: View { let pubkey: Pubkey let damus: DamusState @@ -116,7 +38,7 @@ struct ProfileNameView: View { Spacer() - KeyView(pubkey: pubkey) + PubkeyView(pubkey: pubkey) .pubkey_context_menu(pubkey: pubkey) } } diff --git a/damus/Views/Profile/ProfilePicView.swift b/damus/Views/Profile/ProfilePicView.swift index 54afbbc532..779b7e843d 100644 --- a/damus/Views/Profile/ProfilePicView.swift +++ b/damus/Views/Profile/ProfilePicView.swift @@ -69,38 +69,59 @@ struct ProfilePicView: View { let highlight: Highlight let profiles: Profiles let disable_animation: Bool + let zappability_indicator: Bool @State var picture: String? - init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil) { + init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil, show_zappability: Bool? = nil) { self.pubkey = pubkey self.profiles = profiles self.size = size self.highlight = highlight self._picture = State(initialValue: picture) self.disable_animation = disable_animation + self.zappability_indicator = show_zappability ?? false + } + + func get_lnurl() -> String? { + return profiles.lookup_with_timestamp(pubkey).unsafeUnownedValue?.lnurl } var body: some View { - InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(pubkey)), pubkey: pubkey, size: size, highlight: highlight, disable_animation: disable_animation) - .onReceive(handle_notify(.profile_updated)) { updated in - guard updated.pubkey == self.pubkey else { - return - } - - switch updated { - case .manual(_, let profile): - if let pic = profile.picture { - self.picture = pic + ZStack (alignment: Alignment(horizontal: .trailing, vertical: .bottom)) { + InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(pubkey)), pubkey: pubkey, size: size, highlight: highlight, disable_animation: disable_animation) + .onReceive(handle_notify(.profile_updated)) { updated in + guard updated.pubkey == self.pubkey else { + return } - case .remote(pubkey: let pk): - let profile_txn = profiles.lookup(id: pk) - let profile = profile_txn.unsafeUnownedValue - if let pic = profile?.picture { - self.picture = pic + + switch updated { + case .manual(_, let profile): + if let pic = profile.picture { + self.picture = pic + } + case .remote(pubkey: let pk): + let profile_txn = profiles.lookup(id: pk) + let profile = profile_txn.unsafeUnownedValue + if let pic = profile?.picture { + self.picture = pic + } } } + + if self.zappability_indicator, let lnurl = self.get_lnurl(), lnurl != "" { + Image("zap.fill") + .resizable() + .frame( + width: size * 0.24, + height: size * 0.24 + ) + .padding(size * 0.04) + .foregroundColor(.white) + .background(Color.orange) + .clipShape(Circle()) } + } } } diff --git a/damus/Views/Profile/ProfileView.swift b/damus/Views/Profile/ProfileView.swift index 583a82e485..ac9adbefdc 100644 --- a/damus/Views/Profile/ProfileView.swift +++ b/damus/Views/Profile/ProfileView.swift @@ -221,39 +221,13 @@ struct ProfileView: View { .accentColor(DamusColors.white) } - func lnButton(lnurl: String, unownedProfile: Profile?, pubkey: Pubkey) -> some View { - let reactions = unownedProfile?.reactions ?? true - let button_img = reactions ? "zap.fill" : "zap" - let lud16 = unownedProfile?.lud16 - - return Button(action: { [lnurl] in - present_sheet(.zap(target: .profile(self.profile.pubkey), lnurl: lnurl)) - }) { - Image(button_img) - .foregroundColor(button_img == "zap.fill" ? .orange : Color.primary) + func lnButton(unownedProfile: Profile?, record: ProfileRecord?) -> some View { + return ProfileZapLinkView(unownedProfileRecord: record, profileModel: self.profile) { reactions_enabled, lud16, lnurl in + Image(reactions_enabled ? "zap.fill" : "zap") + .foregroundColor(reactions_enabled ? .orange : Color.primary) .profile_button_style(scheme: colorScheme) - .contextMenu { [lud16, reactions, lnurl] in - if reactions == false { - Text("OnlyZaps Enabled", comment: "Non-tappable text in context menu that shows up when the zap button on profile is long pressed to indicate that the user has enabled OnlyZaps, meaning that they would like to be only zapped and not accept reactions to their notes.") - } - - if let lud16 { - Button { - UIPasteboard.general.string = lud16 - } label: { - Label(lud16, image: "copy2") - } - } else { - Button { - UIPasteboard.general.string = lnurl - } label: { - Label(NSLocalizedString("Copy LNURL", comment: "Context menu option for copying a user's Lightning URL."), image: "copy") - } - } - } - + .cornerRadius(24) } - .cornerRadius(24) } var dmButton: some View { @@ -283,7 +257,7 @@ struct ProfileView: View { let lnurl = record.lnurl, lnurl != "" { - lnButton(lnurl: lnurl, unownedProfile: profile, pubkey: pubkey) + lnButton(unownedProfile: profile, record: record) } dmButton diff --git a/damus/Views/ProfileActionSheetView.swift b/damus/Views/ProfileActionSheetView.swift new file mode 100644 index 0000000000..4ce08c1728 --- /dev/null +++ b/damus/Views/ProfileActionSheetView.swift @@ -0,0 +1,348 @@ +// +// ProfileActionSheetView.swift +// damus +// +// Created by Daniel D’Aquino on 2023-10-20. +// + +import SwiftUI + +struct ProfileActionSheetView: View { + let damus_state: DamusState + let pfp_size: CGFloat = 90.0 + + @StateObject var profile: ProfileModel + @StateObject var zap_button_model: ZapButtonModel = ZapButtonModel() + @State private var sheetHeight: CGFloat = .zero + + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var colorScheme + @Environment(\.presentationMode) var presentationMode + + init(damus_state: DamusState, pubkey: Pubkey) { + self.damus_state = damus_state + self._profile = StateObject(wrappedValue: ProfileModel(pubkey: pubkey, damus: damus_state)) + } + + func imageBorderColor() -> Color { + colorScheme == .light ? DamusColors.white : DamusColors.black + } + + func profile_data() -> ProfileRecord? { + let profile_txn = damus_state.profiles.lookup_with_timestamp(profile.pubkey) + return profile_txn.unsafeUnownedValue + } + + func get_profile() -> Profile? { + return self.profile_data()?.profile + } + + var followButton: some View { + return ProfileActionSheetFollowButton( + target: .pubkey(self.profile.pubkey), + follows_you: self.profile.follows(pubkey: damus_state.pubkey), + follow_state: damus_state.contacts.follow_state(profile.pubkey) + ) + } + + var dmButton: some View { + let dm_model = damus_state.dms.lookup_or_create(profile.pubkey) + return VStack(alignment: .center, spacing: 10) { + Button( + action: { + damus_state.nav.push(route: Route.DMChat(dms: dm_model)) + dismiss() + }, + label: { + Image("messages") + .profile_button_style(scheme: colorScheme) + } + ) + .buttonStyle(NeutralCircleButtonStyle()) + Text(NSLocalizedString("Message", comment: "Button label that allows the user to start a direct message conversation with the user shown on-screen")) + .foregroundStyle(.secondary) + .font(.caption) + } + } + + var zapButton: some View { + if let lnurl = self.profile_data()?.lnurl, lnurl != "" { + return AnyView(ProfileActionSheetZapButton(damus_state: damus_state, profile: profile, lnurl: lnurl)) + } + else { + return AnyView(EmptyView()) + } + } + + var profileName: some View { + let display_name = Profile.displayName(profile: self.get_profile(), pubkey: self.profile.pubkey).displayName + return HStack(alignment: .center, spacing: 10) { + Text(display_name) + .font(.title) + } + } + + var body: some View { + VStack(alignment: .center) { + ProfilePicView(pubkey: profile.pubkey, size: pfp_size, highlight: .custom(imageBorderColor(), 4.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) + if let url = self.profile_data()?.profile?.website_url { + WebsiteLink(url: url, style: .accent) + .padding(.top, -15) + } + + profileName + + PubkeyView(pubkey: profile.pubkey) + + if let about = self.profile_data()?.profile?.about { + AboutView(state: damus_state, about: about, max_about_length: 140, text_alignment: .center) + .padding(.top) + } + + HStack(spacing: 20) { + self.followButton + self.zapButton + self.dmButton + } + .padding() + + Button( + action: { + damus_state.nav.push(route: Route.ProfileByKey(pubkey: profile.pubkey)) + dismiss() + }, + label: { + HStack { + Spacer() + Text(NSLocalizedString("View full profile", comment: "A button label that allows the user to see the full profile of the profile they are previewing")) + Image(systemName: "arrow.up.right") + Spacer() + } + + } + ) + + .buttonStyle(NeutralCircleButtonStyle()) + } + .padding() + .padding(.top, 20) + .overlay { + GeometryReader { geometry in + Color.clear.preference(key: InnerHeightPreferenceKey.self, value: geometry.size.height) + } + } + .onPreferenceChange(InnerHeightPreferenceKey.self) { newHeight in + sheetHeight = newHeight + } + .presentationDetents([.height(sheetHeight)]) + } +} + +fileprivate struct ProfileActionSheetFollowButton: View { + @Environment(\.colorScheme) var colorScheme + + let target: FollowTarget + let follows_you: Bool + @State var follow_state: FollowState + + var body: some View { + VStack(alignment: .center, spacing: 10) { + Button( + action: { + follow_state = perform_follow_btn_action(follow_state, target: target) + }, + label: { + switch follow_state { + case .unfollows: + Image("user-add-down") + .foregroundColor(Color.primary) + .profile_button_style(scheme: colorScheme) + default: + Image("user-added") + .foregroundColor(Color.green) + .profile_button_style(scheme: colorScheme) + } + + } + ) + .buttonStyle(NeutralCircleButtonStyle()) + + Text(verbatim: "\(follow_btn_txt(follow_state, follows_you: follows_you))") + .foregroundStyle(.secondary) + .font(.caption) + } + .onReceive(handle_notify(.followed)) { follow in + guard case .pubkey(let pk) = follow, + pk == target.pubkey else { return } + + self.follow_state = .follows + } + .onReceive(handle_notify(.unfollowed)) { unfollow in + guard case .pubkey(let pk) = unfollow, + pk == target.pubkey else { return } + + self.follow_state = .unfollows + } + } +} + + +fileprivate struct ProfileActionSheetZapButton: View { + enum ZappingState: Equatable { + case not_zapped + case zapping + case zap_success + case zap_failure(error: ZappingError) + + func error_message() -> String? { + switch self { + case .zap_failure(let error): + return error.humanReadableMessage() + default: + return nil + } + } + } + + let damus_state: DamusState + @StateObject var profile: ProfileModel + let lnurl: String + @State var zap_state: ZappingState = .not_zapped + @State var show_error_alert: Bool = false + + @Environment(\.colorScheme) var colorScheme + + func receive_zap(zap_ev: ZappingEvent) { + print("Received zap event") + guard zap_ev.target == ZapTarget.profile(self.profile.pubkey) else { + return + } + + switch zap_ev.type { + case .failed(let err): + zap_state = .zap_failure(error: err) + show_error_alert = true + break + case .got_zap_invoice(let inv): + if damus_state.settings.show_wallet_selector { + present_sheet(.select_wallet(invoice: inv)) + } else { + let wallet = damus_state.settings.default_wallet.model + do { + try open_with_wallet(wallet: wallet, invoice: inv) + } + catch { + present_sheet(.select_wallet(invoice: inv)) + } + } + break + case .sent_from_nwc: + zap_state = .zap_success + break + } + } + + var button_label: String { + switch zap_state { + case .not_zapped: + return NSLocalizedString("Zap", comment: "Button label that allows the user to zap (i.e. send a Bitcoin tip via the lightning network) the user shown on-screen") + case .zapping: + return NSLocalizedString("Zapping", comment: "Button label indicating that a zap action is in progress (i.e. the user is currently sending a Bitcoin tip via the lightning network to the user shown on-screen) ") + case .zap_success: + return NSLocalizedString("Zapped!", comment: "Button label indicating that a zap action was successful (i.e. the user is successfully sent a Bitcoin tip via the lightning network to the user shown on-screen) ") + case .zap_failure(_): + return NSLocalizedString("Zap failed", comment: "Button label indicating that a zap action was unsuccessful (i.e. the user was unable to send a Bitcoin tip via the lightning network to the user shown on-screen) ") + } + } + + var body: some View { + VStack(alignment: .center, spacing: 10) { + Button( + action: { + send_zap(damus_state: damus_state, target: .profile(self.profile.pubkey), lnurl: lnurl, is_custom: false, comment: nil, amount_sats: nil, zap_type: damus_state.settings.default_zap_type) + zap_state = .zapping + }, + label: { + switch zap_state { + case .not_zapped: + Image("zap") + .foregroundColor(Color.primary) + .profile_button_style(scheme: colorScheme) + case .zapping: + ProgressView() + .foregroundColor(Color.primary) + .profile_button_style(scheme: colorScheme) + case .zap_success: + Image("checkmark") + .foregroundColor(Color.green) + .profile_button_style(scheme: colorScheme) + case .zap_failure: + Image("close") + .foregroundColor(Color.red) + .profile_button_style(scheme: colorScheme) + } + + } + ) + .disabled({ + switch zap_state { + case .not_zapped: + return false + default: + return true + } + }()) + .buttonStyle(NeutralCircleButtonStyle()) + + Text(button_label) + .foregroundStyle(.secondary) + .font(.caption) + } + .onReceive(handle_notify(.zapping)) { zap_ev in + receive_zap(zap_ev: zap_ev) + } + .simultaneousGesture(LongPressGesture().onEnded {_ in + present_sheet(.zap(target: .profile(self.profile.pubkey), lnurl: lnurl)) + }) + .alert(isPresented: $show_error_alert) { + Alert( + title: Text(NSLocalizedString("Zap failed", comment: "Title of an alert indicating that a zap action failed")), + message: Text(zap_state.error_message() ?? ""), + dismissButton: .default(Text(NSLocalizedString("OK", comment: "Button label to dismiss an error dialog"))) + ) + } + .onChange(of: zap_state) { new_zap_state in + switch new_zap_state { + case .zap_success, .zap_failure: + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + withAnimation { + zap_state = .not_zapped + } + } + break + default: + break + } + } + } +} + +struct InnerHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = .zero + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +func show_profile_action_sheet_if_enabled(damus_state: DamusState, pubkey: Pubkey) { + if damus_state.settings.show_profile_action_sheet_on_pfp_click { + notify(.present_sheet(Sheets.profile_action(pubkey))) + } + else { + damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey)) + } +} + +#Preview { + ProfileActionSheetView(damus_state: test_damus_state, pubkey: test_pubkey) +} diff --git a/damus/Views/PubkeyView.swift b/damus/Views/PubkeyView.swift index 2a4fcfaef4..6d31f4b059 100644 --- a/damus/Views/PubkeyView.swift +++ b/damus/Views/PubkeyView.swift @@ -7,6 +7,89 @@ import SwiftUI +struct PubkeyView: View { + let pubkey: Pubkey + + @Environment(\.colorScheme) var colorScheme + + @State private var isCopied = false + + func keyColor() -> Color { + colorScheme == .light ? DamusColors.black : DamusColors.white + } + + private func copyPubkey(_ pubkey: String) { + UIPasteboard.general.string = pubkey + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + withAnimation { + isCopied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + withAnimation { + isCopied = false + } + } + } + } + + func pubkey_context_menu(pubkey: Pubkey) -> some View { + return self.contextMenu { + Button { + UIPasteboard.general.string = pubkey.npub + } label: { + Label(NSLocalizedString("Copy Account ID", comment: "Context menu option for copying the ID of the account that created the note."), image: "copy2") + } + } + } + + var body: some View { + let bech32 = pubkey.npub + + HStack { + Text(verbatim: "\(abbrev_pubkey(bech32, amount: 16))") + .font(.footnote) + .foregroundColor(keyColor()) + .padding(5) + .padding([.leading], 5) + + HStack { + if isCopied { + Image("check-circle") + .resizable() + .foregroundColor(DamusColors.green) + .frame(width: 20, height: 20) + Text(NSLocalizedString("Copied", comment: "Label indicating that a user's key was copied.")) + .font(.footnote) + .layoutPriority(1) + .foregroundColor(DamusColors.green) + } else { + Button { + copyPubkey(bech32) + } label: { + Label { + Text("Public key", comment: "Label indicating that the text is a user's public account key.") + } icon: { + Image("copy2") + .resizable() + .contentShape(Rectangle()) + .foregroundColor(colorScheme == .light ? DamusColors.darkGrey : DamusColors.lightGrey) + .frame(width: 20, height: 20) + } + .labelStyle(IconOnlyLabelStyle()) + .symbolRenderingMode(.hierarchical) + + } + } + } + .padding([.trailing], 10) + } + .background(RoundedRectangle(cornerRadius: 11).foregroundColor(colorScheme == .light ? DamusColors.adaptableGrey : DamusColors.neutral1)) + } +} + +#Preview { + PubkeyView(pubkey: test_pubkey) +} + func abbrev_pubkey(_ pubkey: String, amount: Int = 8) -> String { return pubkey.prefix(amount) + ":" + pubkey.suffix(amount) } diff --git a/damus/Views/QRScanNSECView.swift b/damus/Views/QRScanNSECView.swift new file mode 100644 index 0000000000..eddca24218 --- /dev/null +++ b/damus/Views/QRScanNSECView.swift @@ -0,0 +1,66 @@ +// +// QRScanNSECView.swift +// damus +// +// Created by Jericho Hasselbush on 9/29/23. +// + +import SwiftUI +import VisionKit + +struct QRScanNSECView: View { + @Binding var showQR: Bool + @Binding var privKeyFound: Bool + var codeScannerCompletion: (Result) -> Void + var body: some View { + ZStack { + ZStack { + DamusGradient() + } + VStack { + Text("Scan Your Private Key QR", + comment: "Text to prompt scanning a QR code of a user's privkey to login to their profile.") + .padding(.top, 50) + .font(.system(size: 24, weight: .heavy)) + + Spacer() + CodeScannerView(codeTypes: [.qr], + scanMode: .continuous, + scanInterval: 2.0, + showViewfinder: false, + simulatedData: "", + shouldVibrateOnSuccess: false, + isTorchOn: false, + isGalleryPresented: .constant(false), + videoCaptureDevice: .default(for: .video), + completion: codeScannerCompletion) + .scaledToFit() + .frame(width: 300, height: 300) + .cornerRadius(10) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(DamusColors.white, lineWidth: 5.0)) + .shadow(radius: 10) + + Button(action: { showQR = false }) { + VStack { + Image(systemName: privKeyFound ? "sparkle.magnifyingglass" : "magnifyingglass") + .font(privKeyFound ? .title : .title3) + }} + .padding(.top) + .buttonStyle(GradientButtonStyle()) + + Spacer() + + Spacer() + } + } + } +} + +#Preview { + @State var showQR = true + @State var privKeyFound = false + @State var shouldSaveKey = true + return QRScanNSECView(showQR: $showQR, + privKeyFound: $privKeyFound, + codeScannerCompletion: { _ in }) +} diff --git a/damus/Views/Relays/RelayConfigView.swift b/damus/Views/Relays/RelayConfigView.swift index 9f01d41152..2fbb3d4996 100644 --- a/damus/Views/Relays/RelayConfigView.swift +++ b/damus/Views/Relays/RelayConfigView.swift @@ -12,6 +12,7 @@ struct RelayConfigView: View { @State var relays: [RelayDescriptor] @State private var showActionButtons = false @State var show_add_relay: Bool = false + @SceneStorage("RelayConfigView.show_recommended") var show_recommended : Bool = true @Environment(\.dismiss) var dismiss @@ -43,8 +44,41 @@ struct RelayConfigView: View { VStack { Divider() - if recommended.count > 0 { + if showActionButtons && !show_recommended { VStack { + Button(action: { + withAnimation(.easeOut(duration: 0.2)) { + show_recommended.toggle() + } + }) { + Text("Show recommended relays", comment: "Button to show recommended relays.") + .foregroundStyle(DamusLightGradient.gradient) + .padding(10) + .background { + RoundedRectangle(cornerRadius: 15) + .stroke(DamusLightGradient.gradient) + } + } + .padding(.top, 10) + } + } + + if recommended.count > 0 && show_recommended { + VStack { + HStack(alignment: .top) { + Spacer() + Button(action: { + withAnimation(.easeOut(duration: 0.2)) { + show_recommended.toggle() + } + }) { + Image(systemName: "xmark.circle") + .font(.system(size: 18)) + .foregroundStyle(DamusLightGradient.gradient) + } + .padding([.top, .trailing], 8) + } + Text("Recommended relays", comment: "Title for view of recommended relays.") .foregroundStyle(DamusLightGradient.gradient) .padding(10) @@ -52,7 +86,6 @@ struct RelayConfigView: View { RoundedRectangle(cornerRadius: 15) .stroke(DamusLightGradient.gradient) } - .padding(.vertical) HStack(spacing: 20) { ForEach(recommended, id: \.url) { r in diff --git a/damus/Views/SearchHomeView.swift b/damus/Views/SearchHomeView.swift index ac0649c7f9..9308539b55 100644 --- a/damus/Views/SearchHomeView.swift +++ b/damus/Views/SearchHomeView.swift @@ -74,6 +74,19 @@ struct SearchHomeView: View { } return preferredLanguages.contains(note_lang) + }, + content: { + AnyView(VStack { + SuggestedHashtagsView(damus_state: damus_state, max_items: 5, events: model.events) + HStack { + Image(systemName: "bubble.fill") + Text(NSLocalizedString("All recent notes", comment: "A label indicating that the notes being displayed below it are all recent notes")) + Spacer() + } + .foregroundColor(.secondary) + .padding(.top, 20) + .padding(.horizontal) + }) } ) .refreshable { diff --git a/damus/Views/SelectWalletView.swift b/damus/Views/SelectWalletView.swift index 6243390413..310e63bec7 100644 --- a/damus/Views/SelectWalletView.swift +++ b/damus/Views/SelectWalletView.swift @@ -38,7 +38,8 @@ struct SelectWalletView: View { Section(NSLocalizedString("Select a Lightning wallet", comment: "Title of section for selecting a Lightning wallet to pay a Lightning invoice.")) { List{ Button() { - open_with_wallet(wallet: default_wallet.model, invoice: invoice) + // TODO: Handle cases where wallet cannot be opened by the system + try? open_with_wallet(wallet: default_wallet.model, invoice: invoice) } label: { HStack { Text("Default Wallet", comment: "Button to pay a Lightning invoice with the user's default Lightning wallet.").font(.body).foregroundColor(.blue) @@ -47,7 +48,8 @@ struct SelectWalletView: View { List($allWalletModels) { $wallet in if wallet.index >= 0 { Button() { - open_with_wallet(wallet: wallet, invoice: invoice) + // TODO: Handle cases where wallet cannot be opened by the system + try? open_with_wallet(wallet: wallet, invoice: invoice) } label: { HStack { Image(wallet.image).resizable().frame(width: 32.0, height: 32.0,alignment: .center).cornerRadius(5) diff --git a/damus/Views/Settings/AppearanceSettingsView.swift b/damus/Views/Settings/AppearanceSettingsView.swift index 0ee6311b37..e1add45c15 100644 --- a/damus/Views/Settings/AppearanceSettingsView.swift +++ b/damus/Views/Settings/AppearanceSettingsView.swift @@ -100,6 +100,15 @@ struct AppearanceSettingsView: View { Toggle(NSLocalizedString("Hide notes with #nsfw tags", comment: "Setting to hide notes with the #nsfw (not safe for work) tags"), isOn: $settings.hide_nsfw_tagged_content) .toggleStyle(.switch) } + + // MARK: - Profiles + Section( + header: Text(NSLocalizedString("Profiles", comment: "Section title for profile view configuration.")), + footer: Text(NSLocalizedString("Profile action sheets allow you to follow, zap, or DM profiles more quickly without having to view their full profile", comment: "Section footer clarifying what the profile action sheet feature does")) + ) { + Toggle(NSLocalizedString("Show profile action sheets", comment: "Setting to show profile action sheets when clicking on a user's profile picture"), isOn: $settings.show_profile_action_sheet_on_pfp_click) + .toggleStyle(.switch) + } } diff --git a/damus/Views/SuggestedHashtagsView.swift b/damus/Views/SuggestedHashtagsView.swift new file mode 100644 index 0000000000..1cddfa5a9e --- /dev/null +++ b/damus/Views/SuggestedHashtagsView.swift @@ -0,0 +1,135 @@ +// +// SuggestedHashtagsView.swift +// damus +// +// Created by Daniel D’Aquino on 2023-10-09. +// + +import SwiftUI + +// Currently we have a hardcoded list of possible hashtags that might be nice to suggest, +// and we suggest the top-N ones most active in the past day. +// This might be simple and effective until we find a more sophisticated way to let the user discover new hashtags +let DEFAULT_SUGGESTED_HASHTAGS: [String] = [ + "grownostr", "damus", "zapathon", "introductions", "plebchain", "bitcoin", "food", + "coffeechain", "nostr", "asknostr", "bounty", "freedom", "freedomtech", "foodstr", + "memestr", "memes", "music", "musicstr", "art", "artstr" +] + +struct SuggestedHashtagsView: View { + struct HashtagWithUserCount: Hashable { + var hashtag: String + var count: Int + } + + let damus_state: DamusState + @StateObject var events: EventHolder + var item_limit: Int? + let suggested_hashtags: [String] + var hashtags_with_count_to_display: [HashtagWithUserCount] { + get { + let all_items = self.suggested_hashtags + .map({ hashtag in + return HashtagWithUserCount( + hashtag: hashtag, + count: self.users_talking_about(hashtag: Hashtag(hashtag: hashtag)) + ) + }) + .sorted(by: { a, b in + a.count > b.count + }) + guard let item_limit else { + return all_items + } + return Array(all_items.prefix(item_limit)) + } + } + + init(damus_state: DamusState, suggested_hashtags: [String]? = nil, max_items item_limit: Int? = nil, events: EventHolder) { + self.damus_state = damus_state + self.suggested_hashtags = suggested_hashtags ?? DEFAULT_SUGGESTED_HASHTAGS + self.item_limit = item_limit + _events = StateObject.init(wrappedValue: events) + } + + var body: some View { + VStack { + HStack { + Image(systemName: "sparkles") + Text(NSLocalizedString("Suggested hashtags", comment: "A label indicating that the items below it are suggested hashtags")) + Spacer() + } + .foregroundColor(.secondary) + .padding(.bottom, 10) + + ForEach(hashtags_with_count_to_display, + id: \.self) { hashtag_with_count in + SuggestedHashtagView(damus_state: damus_state, hashtag: hashtag_with_count.hashtag, count: hashtag_with_count.count) + } + } + .padding() + } + + private struct SuggestedHashtagView: View { // Purposefully private to SuggestedHashtagsView because it assumes the same 24h window + let damus_state: DamusState + let hashtag: String + let count: Int + + init(damus_state: DamusState, hashtag: String, count: Int) { + self.damus_state = damus_state + self.hashtag = hashtag + self.count = count + } + + var body: some View { + HStack { + SingleCharacterAvatar(character: "#") + + VStack(alignment: .leading, spacing: 10) { + Text("#\(hashtag)") + .bold() + + Text(self.count != 1 ? String( + format: NSLocalizedString("%d users talking about it", comment: "A label indicating how many users have been talking about a hashtag"), + self.count + ) : NSLocalizedString("1 user talking about it", comment: "A label indicating 1 user has been talking about a hashtag")) + .foregroundStyle(.secondary) + } + + Spacer() + } + .onTapGesture { + let search_model = SearchModel(state: damus_state, search: NostrFilter.init(hashtag: [hashtag])) + damus_state.nav.push(route: Route.Search(search: search_model)) + } + } + } + + func users_talking_about(hashtag: Hashtag) -> Int { + return self.events.all_events + .filter({ $0.referenced_hashtags.contains(hashtag)}) + .reduce(Set([]), { authors, note in + return authors.union([note.pubkey]) + }) + .count + } +} + +struct SuggestedHashtagsView_Previews: PreviewProvider { + static var previews: some View { + let time_window: TimeInterval = 24 * 60 * 60 // 1 day + let search_model = SearchModel( + state: test_damus_state, + search: NostrFilter.init( + since: UInt32(Date.now.timeIntervalSince1970 - time_window), + hashtag: ["nostr", "bitcoin", "zapathon"] + ) + ) + + SuggestedHashtagsView( + damus_state: test_damus_state, + events: search_model.events + ) + } +} + diff --git a/damus/Views/TextViewWrapper.swift b/damus/Views/TextViewWrapper.swift index 9ebd140dab..35434d898c 100644 --- a/damus/Views/TextViewWrapper.swift +++ b/damus/Views/TextViewWrapper.swift @@ -11,6 +11,7 @@ struct TextViewWrapper: UIViewRepresentable { @Binding var attributedText: NSMutableAttributedString @EnvironmentObject var tagModel: TagModel @Binding var textHeight: CGFloat? + let initialTextSuffix: String? let cursorIndex: Int? var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil @@ -74,25 +75,41 @@ struct TextViewWrapper: UIViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition) + Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition, initialTextSuffix: initialTextSuffix) } class Coordinator: NSObject, UITextViewDelegate { @Binding var attributedText: NSMutableAttributedString var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil let updateCursorPosition: ((Int) -> Void) + let initialTextSuffix: String? + var initialTextSuffixWasAdded: Bool = false init(attributedText: Binding, getFocusWordForMention: ((String?, NSRange?) -> Void)?, - updateCursorPosition: @escaping ((Int) -> Void) + updateCursorPosition: @escaping ((Int) -> Void), + initialTextSuffix: String? ) { _attributedText = attributedText self.getFocusWordForMention = getFocusWordForMention self.updateCursorPosition = updateCursorPosition + self.initialTextSuffix = initialTextSuffix } func textViewDidChange(_ textView: UITextView) { - attributedText = NSMutableAttributedString(attributedString: textView.attributedText) + if let initialTextSuffix, !self.initialTextSuffixWasAdded { + self.initialTextSuffixWasAdded = true + var mutable = NSMutableAttributedString(attributedString: textView.attributedText) + let originalRange = textView.selectedRange + addUnattributedText(initialTextSuffix, to: &mutable, inRange: originalRange) + attributedText = mutable + DispatchQueue.main.async { + self.updateCursorPosition(originalRange.location) + } + } + else { + attributedText = NSMutableAttributedString(attributedString: textView.attributedText) + } processFocusedWordForMention(textView: textView) } diff --git a/damus/Views/Zaps/CustomizeZapView.swift b/damus/Views/Zaps/CustomizeZapView.swift index fb4cef3999..e7cce83c2b 100644 --- a/damus/Views/Zaps/CustomizeZapView.swift +++ b/damus/Views/Zaps/CustomizeZapView.swift @@ -194,16 +194,7 @@ struct CustomizeZapView: View { switch zap_ev.type { case .failed(let err): - switch err { - case .fetching_invoice: - model.error = NSLocalizedString("Error fetching lightning invoice", comment: "Message to display when there was an error fetching a lightning invoice while attempting to zap.") - case .bad_lnurl: - model.error = NSLocalizedString("Invalid lightning address", comment: "Message to display when there was an error attempting to zap due to an invalid lightning address.") - case .canceled: - model.error = NSLocalizedString("Zap attempt from connected wallet was canceled.", comment: "Message to display when a zap from the user's connected wallet was canceled.") - case .send_failed: - model.error = NSLocalizedString("Zap attempt from connected wallet failed.", comment: "Message to display when sending a zap from the user's connected wallet failed.") - } + model.error = err.humanReadableMessage() break case .got_zap_invoice(let inv): if state.settings.show_wallet_selector { @@ -212,8 +203,13 @@ struct CustomizeZapView: View { } else { end_editing() let wallet = state.settings.default_wallet.model - open_with_wallet(wallet: wallet, invoice: inv) - dismiss() + do { + try open_with_wallet(wallet: wallet, invoice: inv) + dismiss() + } + catch { + present_sheet(.select_wallet(invoice: inv)) + } } case .sent_from_nwc: dismiss() diff --git a/damus/Views/Zaps/ProfileZapLinkView.swift b/damus/Views/Zaps/ProfileZapLinkView.swift new file mode 100644 index 0000000000..89e480ee4a --- /dev/null +++ b/damus/Views/Zaps/ProfileZapLinkView.swift @@ -0,0 +1,92 @@ +// +// ProfileZapLinkView.swift +// damus +// +// Created by Daniel D’Aquino on 2023-10-20. +// + +import SwiftUI + +struct ProfileZapLinkView: View { + typealias ContentViewFunction = (_ reactions_enabled: Bool, _ lud16: String?, _ lnurl: String?) -> Content + typealias ActionFunction = () -> Void + + let pubkey: Pubkey + @ViewBuilder let label: ContentViewFunction + let action: ActionFunction? + + let reactions_enabled: Bool + let lud16: String? + let lnurl: String? + + init(pubkey: Pubkey, reactions_enabled: Bool, lud16: String?, lnurl: String?, action: ActionFunction? = nil, @ViewBuilder label: @escaping ContentViewFunction) { + self.pubkey = pubkey + self.label = label + self.action = action + self.reactions_enabled = reactions_enabled + self.lud16 = lud16 + self.lnurl = lnurl + } + + init(damus_state: DamusState, pubkey: Pubkey, action: ActionFunction? = nil, @ViewBuilder label: @escaping ContentViewFunction) { + self.pubkey = pubkey + self.label = label + self.action = action + + let profile_txn = damus_state.profiles.lookup_with_timestamp(pubkey) + let record = profile_txn.unsafeUnownedValue + self.reactions_enabled = record?.profile?.reactions ?? true + self.lud16 = record?.profile?.lud06 + self.lnurl = record?.lnurl + } + + init(unownedProfileRecord: ProfileRecord?, profileModel: ProfileModel, action: ActionFunction? = nil, @ViewBuilder label: @escaping ContentViewFunction) { + self.pubkey = profileModel.pubkey + self.label = label + self.action = action + + self.reactions_enabled = unownedProfileRecord?.profile?.reactions ?? true + self.lud16 = unownedProfileRecord?.profile?.lud16 + self.lnurl = unownedProfileRecord?.lnurl + } + + var body: some View { + Button( + action: { + if let lnurl { + present_sheet(.zap(target: .profile(self.pubkey), lnurl: lnurl)) + } + action?() + }, + label: { + self.label(self.reactions_enabled, self.lud16, self.lnurl) + } + ) + .contextMenu { + if self.reactions_enabled == false { + Text("OnlyZaps Enabled", comment: "Non-tappable text in context menu that shows up when the zap button on profile is long pressed to indicate that the user has enabled OnlyZaps, meaning that they would like to be only zapped and not accept reactions to their notes.") + } + + if let lud16 { + Button { + UIPasteboard.general.string = lud16 + } label: { + Label(lud16, image: "copy2") + } + } else { + Button { + UIPasteboard.general.string = lnurl + } label: { + Label(NSLocalizedString("Copy LNURL", comment: "Context menu option for copying a user's Lightning URL."), image: "copy") + } + } + } + .disabled(lnurl == nil) + } +} + +#Preview { + ProfileZapLinkView(pubkey: test_pubkey, reactions_enabled: true, lud16: make_test_profile().lud16, lnurl: "test@sendzaps.lol", label: { reactions_enabled, lud16, lnurl in + Image("zap.fill") + }) +} diff --git a/damus/damusApp.swift b/damus/damusApp.swift index 7300b1b0e3..a629200a93 100644 --- a/damus/damusApp.swift +++ b/damus/damusApp.swift @@ -32,6 +32,9 @@ struct MainView: View { .onReceive(handle_notify(.login)) { notif in needs_setup = false keypair = get_saved_keypair() + if keypair == nil, let tempkeypair = notif.to_full()?.to_keypair() { + keypair = tempkeypair + } } } } diff --git a/damus/de.lproj/Localizable.strings b/damus/de.lproj/Localizable.strings index 9e7ea82781..a88061b982 100644 Binary files a/damus/de.lproj/Localizable.strings and b/damus/de.lproj/Localizable.strings differ diff --git a/damus/es-419.lproj/InfoPlist.strings b/damus/es-419.lproj/InfoPlist.strings index b19dba1875..9c36f20b4e 100644 Binary files a/damus/es-419.lproj/InfoPlist.strings and b/damus/es-419.lproj/InfoPlist.strings differ diff --git a/damus/es-419.lproj/Localizable.strings b/damus/es-419.lproj/Localizable.strings index e3413ce92f..bfe8e99cdf 100644 Binary files a/damus/es-419.lproj/Localizable.strings and b/damus/es-419.lproj/Localizable.strings differ diff --git a/damus/es-419.lproj/Localizable.stringsdict b/damus/es-419.lproj/Localizable.stringsdict index cce3a7aa35..5d3b9303c6 100644 --- a/damus/es-419.lproj/Localizable.stringsdict +++ b/damus/es-419.lproj/Localizable.stringsdict @@ -290,6 +290,24 @@ %2$@ sats + word_count + + NSStringLocalizedFormatKey + %#@WORDS@ + WORDS + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d Palabra + many + %d Palabras + other + %d Palabras + + zap_notification_no_message NSStringLocalizedFormatKey diff --git a/damus/lv-LV.lproj/InfoPlist.strings b/damus/lv-LV.lproj/InfoPlist.strings index 625249165b..4901b3a795 100644 Binary files a/damus/lv-LV.lproj/InfoPlist.strings and b/damus/lv-LV.lproj/InfoPlist.strings differ diff --git a/damus/lv-LV.lproj/Localizable.strings b/damus/lv-LV.lproj/Localizable.strings index 57db50a3e1..a817dd206c 100644 Binary files a/damus/lv-LV.lproj/Localizable.strings and b/damus/lv-LV.lproj/Localizable.strings differ diff --git a/damus/lv-LV.lproj/Localizable.stringsdict b/damus/lv-LV.lproj/Localizable.stringsdict index 1c7fd44c7a..91fa6a42b3 100644 --- a/damus/lv-LV.lproj/Localizable.stringsdict +++ b/damus/lv-LV.lproj/Localizable.stringsdict @@ -2,6 +2,24 @@ + followed_by_three_and_others + + NSStringLocalizedFormatKey + %#@OTHERS@ + OTHERS + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + Sekots pēc %2$@, %3$@, %4$@ & %1$d citiem + one + Sekots pēc %2$@, %3$@, %4$@ & %1$d citiem + other + Sekots pēc %2$@, %3$@, %4$@ & %1$d citiem + + followers_count NSStringLocalizedFormatKey @@ -38,6 +56,24 @@ Sekojat + imports_count + + NSStringLocalizedFormatKey + %#@IMPORTS@ + IMPORTS + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + Importi + one + Importi + other + Importi + + reacted_tagged_in_3 NSStringLocalizedFormatKey @@ -49,14 +85,14 @@ NSStringFormatValueTypeKey d zero - %2$@ un %1$d citi reaģēja uz ziņu, kurā esat atzīmēts + %2$@ un %1$d citi reaģēja uz ierakstu, kurā bijāt atzīmēts one - %2$@ un %1$d cits reaģēja uz ziņu, kurā esat atzīmēts + %2$@ un %1$d citi reaģēja uz ierakstu, kurā bijāt atzīmēts other - %2$@ un %1$d citi reaģēja uz ziņu, kurā esat atzīmēts + %2$@ un %1$d citi reaģēja uz ierakstu, kurā bijāt atzīmēts - reacted_your_post_3 + reacted_your_note_3 NSStringLocalizedFormatKey %#@REACTED@ @@ -67,11 +103,11 @@ NSStringFormatValueTypeKey d zero - %2$@ un %1$d citi reaģēja tavai ziņai + %2$@ un %1$d citi reaģēja uz jūsu ierakstu one - %2$@ un %1$d cits reaģēja tavai ziņai + %2$@ un %1$d citi reaģēja uz jūsu ierakstu other - %2$@ un %1$d citi reaģēja tavai ziņai + %2$@ un %1$d citi reaģēja uz jūsu ierakstu reacted_your_profile_3 @@ -157,14 +193,14 @@ NSStringFormatValueTypeKey d zero - %2$@ un %1$d citi atkārtoti publicēja ziņu, kurā bijāt atzīmēts + %2$@ un %1$d citi atkārtoti pārpublicēja ierakstu, kurā bijāt atzīmēts one - %2$@ un %1$d cits atkārtoti publicēja ziņu, kurā bijāt atzīmēts + %2$@ un %1$d citi atkārtoti pārpublicēja ierakstu, kurā bijāt atzīmēts other - %2$@ un %1$d citi atkārtoti publicēja ziņu, kurā bijāt atzīmēts + %2$@ un %1$d citi atkārtoti pārpublicēja ierakstu, kurā bijāt atzīmēts - reposted_your_post_3 + reposted_your_note_3 NSStringLocalizedFormatKey %#@REPOSTED@ @@ -175,11 +211,11 @@ NSStringFormatValueTypeKey d zero - %2$@ un %1$d citi atkārtoti publicēja tavu ziņu + %2$@ un %1$d citi pārpublicēja jūsu ierakstu one - %2$@ un %1$d cits atkārtoti publicēja tavu ziņu + %2$@ un %1$d citi pārpublicēja jūsu ierakstu other - %2$@ un %1$d citi atkārtoti publicēja tavu ziņu + %2$@ un %1$d citi pārpublicēja jūsu ierakstu reposted_your_profile_3 @@ -254,6 +290,24 @@ %2$@ sati + word_count + + NSStringLocalizedFormatKey + %#@WORDS@ + WORDS + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + %d Vārdi + one + %d Vārdi + other + %d Vārdi + + zap_notification_no_message NSStringLocalizedFormatKey @@ -283,11 +337,11 @@ NSStringFormatValueTypeKey @ zero - Jūs saņēmāt %2$@ satus no %3$@: "%4$@" + Jūs saņēmāt %2$@ satus no %3$@: "%4$@" one - Jūs saņēmāt %2$@ satu no %3$@: "%4$@" + Jūs saņēmāt %2$@ satus no %3$@: "%4$@" other - Jūs saņēmāt %2$@ satus no %3$@: "%4$@" + Jūs saņēmāt %2$@ satus no %3$@: "%4$@" zapped_tagged_in_3 @@ -301,14 +355,14 @@ NSStringFormatValueTypeKey d zero - %2$@ un %1$d citi sazapoja ziņu, kurā bijāt atzīmēts + %2$@ un %1$d citi zapoja ierakstam, kurā bijāt atzīmēts one - %2$@ un %1$d cits sazapoja ziņu, kurā bijāt atzīmēts + %2$@ un %1$d citi zapoja ierakstam, kurā bijāt atzīmēts other %2$@ un %1$d citi zapoja ierakstam, kurā bijāt atzīmēts - zapped_your_post_3 + zapped_your_note_3 NSStringLocalizedFormatKey %#@ZAPPED@ @@ -319,11 +373,11 @@ NSStringFormatValueTypeKey d zero - %2$@ un %1$d citi sazapoja tavai ziņai + %2$@ un %1$d citi zapoja jūsu ierakstam one - %2$@ un %1$d cits sazapoja tavai ziņai + %2$@ un %1$d citi zapoja jūsu ierakstam other - %2$@ un %1$d citi sazapoja tavam ierakstam + %2$@ un %1$d citi zapoja jūsu ierakstam zapped_your_profile_3 @@ -337,11 +391,11 @@ NSStringFormatValueTypeKey d zero - %2$@ un %1$d citi sazapoja tavam profilam + %2$@ un %1$d citi zapoja jums one - %2$@ un %1$d cits sazapoja tavam profilam + %2$@ un %1$d citi zapoja jums other - %2$@ un %1$d citi zapoja tavam profilam + %2$@ un %1$d citi zapoja jums zaps_count diff --git a/damus/pl-PL.lproj/Localizable.stringsdict b/damus/pl-PL.lproj/Localizable.stringsdict index 41ce42fcbe..65e57ba4bb 100644 --- a/damus/pl-PL.lproj/Localizable.stringsdict +++ b/damus/pl-PL.lproj/Localizable.stringsdict @@ -322,6 +322,26 @@ %2$@ satsa + word_count + + NSStringLocalizedFormatKey + %#@WORDS@ + WORDS + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d słowo + few + %d słowa + many + %d słów + other + %d słowa + + zap_notification_no_message NSStringLocalizedFormatKey @@ -353,13 +373,13 @@ NSStringFormatValueTypeKey @ one - Otrzymano %2$@ satoshi od %3$@: "%4$@" + Otrzymano %2$@ satoshi od %3$@: "%4$@" few - Otrzymano %2$@ satoshi od %3$@: "%4$@" + Otrzymano %2$@ satoshi od %3$@: "%4$@" many - Otrzymano %2$@ satoshi od %3$@: "%4$@" + Otrzymano %2$@ satoshi od %3$@: "%4$@" other - Otrzymano %2$@ satoshi od %3$@: "%4$@" + Otrzymano %2$@ satoshi od %3$@: "%4$@" zapped_tagged_in_3 diff --git a/damus/pt-PT.lproj/InfoPlist.strings b/damus/pt-PT.lproj/InfoPlist.strings index 47ff370a81..e8ec1c2882 100644 Binary files a/damus/pt-PT.lproj/InfoPlist.strings and b/damus/pt-PT.lproj/InfoPlist.strings differ diff --git a/damus/pt-PT.lproj/Localizable.strings b/damus/pt-PT.lproj/Localizable.strings index 16cb4f3718..2cb6b62565 100644 Binary files a/damus/pt-PT.lproj/Localizable.strings and b/damus/pt-PT.lproj/Localizable.strings differ diff --git a/damus/sw.lproj/InfoPlist.strings b/damus/sw.lproj/InfoPlist.strings index 11ee46998f..5ce92090dc 100644 Binary files a/damus/sw.lproj/InfoPlist.strings and b/damus/sw.lproj/InfoPlist.strings differ diff --git a/damus/zh-CN.lproj/InfoPlist.strings b/damus/zh-CN.lproj/InfoPlist.strings index f90b15c979..170d40288c 100644 Binary files a/damus/zh-CN.lproj/InfoPlist.strings and b/damus/zh-CN.lproj/InfoPlist.strings differ diff --git a/damus/zh-CN.lproj/Localizable.strings b/damus/zh-CN.lproj/Localizable.strings index 132d554c45..da6a22341f 100644 Binary files a/damus/zh-CN.lproj/Localizable.strings and b/damus/zh-CN.lproj/Localizable.strings differ diff --git a/damus/zh-HK.lproj/InfoPlist.strings b/damus/zh-HK.lproj/InfoPlist.strings index a6ef607222..411114ad95 100644 Binary files a/damus/zh-HK.lproj/InfoPlist.strings and b/damus/zh-HK.lproj/InfoPlist.strings differ diff --git a/damus/zh-TW.lproj/InfoPlist.strings b/damus/zh-TW.lproj/InfoPlist.strings index a6ef607222..411114ad95 100644 Binary files a/damus/zh-TW.lproj/InfoPlist.strings and b/damus/zh-TW.lproj/InfoPlist.strings differ diff --git a/damus/zh-TW.lproj/Localizable.strings b/damus/zh-TW.lproj/Localizable.strings index 12a658af03..5df1872824 100644 Binary files a/damus/zh-TW.lproj/Localizable.strings and b/damus/zh-TW.lproj/Localizable.strings differ diff --git a/damusTests/EventViewTests.swift b/damusTests/EventViewTests.swift new file mode 100644 index 0000000000..ccd3c356ef --- /dev/null +++ b/damusTests/EventViewTests.swift @@ -0,0 +1,46 @@ +// +// EventViewTests.swift +// damusTests +// +// Created by Daniel D’Aquino on 2023-10-13. +// + +import Foundation +import XCTest +import SnapshotTesting +import SwiftUI +@testable import damus + +final class EventViewTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testBasicEventViewLayout() { + let test_mock_damus_state = generate_test_damus_state( + mock_profile_info: [ + // Manually mock some profile info so that we have a more realistic-looking note + jack_keypair.pubkey: Profile( + name: "jack", + display_name: "Jack Dorsey" + ) + ] + ) + let test_note = NostrEvent( + content: "Nostr is the super app. Because it’s actually an ecosystem of apps, all of which make each other better. People haven’t grasped that yet. They will when it’s more accessible and onboarding is more straightforward and intuitive.", + keypair: jack_keypair, + createdAt: UInt32(Date.init(timeIntervalSinceNow: -60).timeIntervalSince1970) + )! + + let eventViewTest = EventView(damus: test_mock_damus_state, event: test_note).padding() + let hostView = UIHostingController(rootView: eventViewTest) + + // Run snapshot check + assertSnapshot(matching: hostView, as: .image(on: .iPhone13(.portrait))) + } +} diff --git a/damusTests/Mocking/MockDamusState.swift b/damusTests/Mocking/MockDamusState.swift new file mode 100644 index 0000000000..9d49c9ab18 --- /dev/null +++ b/damusTests/Mocking/MockDamusState.swift @@ -0,0 +1,66 @@ +// +// MockDamusState.swift +// damusTests +// +// Created by Daniel D’Aquino on 2023-10-13. +// + +import Foundation +@testable import damus + +// Generates a test damus state with configurable mock parameters +func generate_test_damus_state( + mock_profile_info: [Pubkey: Profile]? +) -> DamusState { + // Create a unique temporary directory + var tempDir: String! + do { + let fileManager = FileManager.default + let temp = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fileManager.createDirectory(at: temp, withIntermediateDirectories: true, attributes: nil) + tempDir = temp.absoluteString + } catch { + tempDir = "." + } + + print("opening \(tempDir!)") + let ndb = Ndb(path: tempDir)! + let our_pubkey = test_pubkey + let pool = RelayPool(ndb: ndb) + let settings = UserSettingsStore() + + let profiles: Profiles = { + guard let mock_profile_info, let profiles: Profiles = MockProfiles(mocked_profiles: mock_profile_info, ndb: ndb) else { + return Profiles.init(ndb: ndb) + } + return profiles + }() + + let damus = DamusState(pool: pool, + keypair: test_keypair, + likes: .init(our_pubkey: our_pubkey), + boosts: .init(our_pubkey: our_pubkey), + contacts: .init(our_pubkey: our_pubkey), + profiles: profiles, + dms: .init(our_pubkey: our_pubkey), + previews: .init(), + zaps: .init(our_pubkey: our_pubkey), + lnurls: .init(), + settings: settings, + relay_filters: .init(our_pubkey: our_pubkey), + relay_model_cache: .init(), + drafts: .init(), + events: .init(ndb: ndb), + bookmarks: .init(pubkey: our_pubkey), + postbox: .init(pool: pool), + bootstrap_relays: .init(), + replies: .init(our_pubkey: our_pubkey), + muted_threads: .init(keypair: test_keypair), + wallet: .init(settings: settings), + nav: .init(), + music: .init(onChange: {_ in }), + video: .init(), + ndb: ndb) + + return damus +} diff --git a/damusTests/Mocking/MockProfiles.swift b/damusTests/Mocking/MockProfiles.swift new file mode 100644 index 0000000000..591ba2f6ef --- /dev/null +++ b/damusTests/Mocking/MockProfiles.swift @@ -0,0 +1,28 @@ +// +// MockProfiles.swift +// damusTests +// +// Created by Daniel D’Aquino on 2023-10-13. +// + +import Foundation +@testable import damus + +// A Mockable `Profiles` class that can be used for testing. +// Note: Not all methods are mocked. You might need to implement a method depending on the test you are writing. +class MockProfiles: Profiles { + var mocked_profiles: [Pubkey: Profile] = [:] + var ndb: Ndb + + init?(mocked_profiles: [Pubkey : Profile], ndb: Ndb) { + self.mocked_profiles = mocked_profiles + self.ndb = ndb + super.init(ndb: ndb) + } + + override func lookup(id: Pubkey) -> NdbTxn { + return NdbTxn(ndb: self.ndb) { txn in + return self.mocked_profiles[id] + } + } +} diff --git a/damusTests/PostViewTests.swift b/damusTests/PostViewTests.swift index bab02ac364..963cb98e07 100644 --- a/damusTests/PostViewTests.swift +++ b/damusTests/PostViewTests.swift @@ -6,6 +6,8 @@ // import Foundation import XCTest +import SnapshotTesting +import SwiftUI @testable import damus import SwiftUI @@ -19,6 +21,30 @@ final class PostViewTests: XCTestCase { // Put teardown code here. This method is called after the invocation of each test method in the class. } + func testTextWrapperViewWillWrapText() { + // Setup test variables to be passed into the TextViewWrapper + let tagModel: TagModel = TagModel() + var textHeight: CGFloat? = nil + let textHeightBinding: Binding = Binding(get: { + return textHeight + }, set: { newValue in + textHeight = newValue + }) + + // Setup the test view + let textEditorView = TextViewWrapper( + attributedText: .constant(NSMutableAttributedString(string: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")), + textHeight: textHeightBinding, + initialTextSuffix: nil, + cursorIndex: 9, + updateCursorPosition: { _ in return } + ).environmentObject(tagModel) + let hostView = UIHostingController(rootView: textEditorView) + + // Run snapshot check + assertSnapshot(matching: hostView, as: .image(on: .iPhoneSe(.portrait))) + } + /// Based on https://github.com/damus-io/damus/issues/1375 /// Tests whether the editor properly handles mention links after they have been added, to avoid manual editing of attributed links func testMentionLinkEditorHandling() throws { @@ -132,7 +158,7 @@ func checkMentionLinkEditorHandling( if let expectedNewCursorIndex { XCTAssertEqual(newCursorIndex, expectedNewCursorIndex) } - }) + }, initialTextSuffix: nil) let textView = UITextView() textView.attributedText = content diff --git a/damusTests/__Snapshots__/EventViewTests/testBasicEventViewLayout.1.png b/damusTests/__Snapshots__/EventViewTests/testBasicEventViewLayout.1.png new file mode 100644 index 0000000000..51454c1c24 Binary files /dev/null and b/damusTests/__Snapshots__/EventViewTests/testBasicEventViewLayout.1.png differ diff --git a/nostrdb/Ndb.swift b/nostrdb/Ndb.swift index e55c543ecc..150b63faca 100644 --- a/nostrdb/Ndb.swift +++ b/nostrdb/Ndb.swift @@ -182,6 +182,25 @@ class Ndb { } } + func write_profile_last_fetched(pubkey: Pubkey, fetched_at: UInt64) { + let _ = pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> () in + guard let p = ptr.baseAddress else { return } + ndb_write_last_profile_fetch(ndb.ndb, p, fetched_at) + } + } + + func read_profile_last_fetched(txn: NdbTxn, pubkey: Pubkey) -> UInt64? { + return pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> UInt64? in + guard let p = ptr.baseAddress else { return nil } + let res = ndb_read_last_profile_fetch(&txn.txn, p) + if res == 0 { + return nil + } + + return res + } + } + func process_event(_ str: String) -> Bool { return str.withCString { cstr in return ndb_process_event(ndb.ndb, cstr, Int32(str.utf8.count)) != 0 diff --git a/nostrdb/bindings/c/meta_builder.h b/nostrdb/bindings/c/meta_builder.h index 1ae48f3413..ad850bd53f 100644 --- a/nostrdb/bindings/c/meta_builder.h +++ b/nostrdb/bindings/c/meta_builder.h @@ -20,19 +20,31 @@ static const flatbuffers_voffset_t __NdbEventMeta_required[] = { 0 }; typedef flatbuffers_ref_t NdbEventMeta_ref_t; static NdbEventMeta_ref_t NdbEventMeta_clone(flatbuffers_builder_t *B, NdbEventMeta_table_t t); -__flatbuffers_build_table(flatbuffers_, NdbEventMeta, 1) +__flatbuffers_build_table(flatbuffers_, NdbEventMeta, 6) -#define __NdbEventMeta_formal_args , int32_t v0 -#define __NdbEventMeta_call_args , v0 +#define __NdbEventMeta_formal_args ,\ + int32_t v0, int32_t v1, int32_t v2, int32_t v3, int32_t v4, int64_t v5 +#define __NdbEventMeta_call_args ,\ + v0, v1, v2, v3, v4, v5 static inline NdbEventMeta_ref_t NdbEventMeta_create(flatbuffers_builder_t *B __NdbEventMeta_formal_args); __flatbuffers_build_table_prolog(flatbuffers_, NdbEventMeta, NdbEventMeta_file_identifier, NdbEventMeta_type_identifier) __flatbuffers_build_scalar_field(0, flatbuffers_, NdbEventMeta_received_at, flatbuffers_int32, int32_t, 4, 4, INT32_C(0), NdbEventMeta) +__flatbuffers_build_scalar_field(1, flatbuffers_, NdbEventMeta_reactions, flatbuffers_int32, int32_t, 4, 4, INT32_C(0), NdbEventMeta) +__flatbuffers_build_scalar_field(2, flatbuffers_, NdbEventMeta_quotes, flatbuffers_int32, int32_t, 4, 4, INT32_C(0), NdbEventMeta) +__flatbuffers_build_scalar_field(3, flatbuffers_, NdbEventMeta_reposts, flatbuffers_int32, int32_t, 4, 4, INT32_C(0), NdbEventMeta) +__flatbuffers_build_scalar_field(4, flatbuffers_, NdbEventMeta_zaps, flatbuffers_int32, int32_t, 4, 4, INT32_C(0), NdbEventMeta) +__flatbuffers_build_scalar_field(5, flatbuffers_, NdbEventMeta_zap_total, flatbuffers_int64, int64_t, 8, 8, INT64_C(0), NdbEventMeta) static inline NdbEventMeta_ref_t NdbEventMeta_create(flatbuffers_builder_t *B __NdbEventMeta_formal_args) { if (NdbEventMeta_start(B) - || NdbEventMeta_received_at_add(B, v0)) { + || NdbEventMeta_zap_total_add(B, v5) + || NdbEventMeta_received_at_add(B, v0) + || NdbEventMeta_reactions_add(B, v1) + || NdbEventMeta_quotes_add(B, v2) + || NdbEventMeta_reposts_add(B, v3) + || NdbEventMeta_zaps_add(B, v4)) { return 0; } return NdbEventMeta_end(B); @@ -42,7 +54,12 @@ static NdbEventMeta_ref_t NdbEventMeta_clone(flatbuffers_builder_t *B, NdbEventM { __flatbuffers_memoize_begin(B, t); if (NdbEventMeta_start(B) - || NdbEventMeta_received_at_pick(B, t)) { + || NdbEventMeta_zap_total_pick(B, t) + || NdbEventMeta_received_at_pick(B, t) + || NdbEventMeta_reactions_pick(B, t) + || NdbEventMeta_quotes_pick(B, t) + || NdbEventMeta_reposts_pick(B, t) + || NdbEventMeta_zaps_pick(B, t)) { return 0; } __flatbuffers_memoize_end(B, t, NdbEventMeta_end(B)); diff --git a/nostrdb/bindings/c/meta_json_parser.h b/nostrdb/bindings/c/meta_json_parser.h index b008106284..94fcebd093 100644 --- a/nostrdb/bindings/c/meta_json_parser.h +++ b/nostrdb/bindings/c/meta_json_parser.h @@ -33,16 +33,14 @@ static const char *NdbEventMeta_parse_json_table(flatcc_json_parser_t *ctx, cons uint64_t w; *result = 0; - if (flatcc_builder_start_table(ctx->ctx, 1)) goto failed; + if (flatcc_builder_start_table(ctx->ctx, 6)) goto failed; buf = flatcc_json_parser_object_start(ctx, buf, end, &more); while (more) { buf = flatcc_json_parser_symbol_start(ctx, buf, end); w = flatcc_json_parser_symbol_part(buf, end); - if (w == 0x7265636569766564) { /* descend "received" */ - buf += 8; - w = flatcc_json_parser_symbol_part(buf, end); - if ((w & 0xffffff0000000000) == 0x5f61740000000000) { /* "_at" */ - buf = flatcc_json_parser_match_symbol(ctx, (mark = buf), end, 3); + if (w < 0x7265636569766564) { /* branch "received" */ + if ((w & 0xffffffffffff0000) == 0x71756f7465730000) { /* "quotes" */ + buf = flatcc_json_parser_match_symbol(ctx, (mark = buf), end, 6); if (mark != buf) { int32_t val = 0; static flatcc_json_parser_integral_symbol_f *symbolic_parsers[] = { @@ -54,18 +52,154 @@ static const char *NdbEventMeta_parse_json_table(flatcc_json_parser_t *ctx, cons if (buf == mark || buf == end) goto failed; } if (val != INT32_C(0) || (ctx->flags & flatcc_json_parser_f_force_add)) { - if (!(pval = flatcc_builder_table_add(ctx->ctx, 0, 4, 4))) goto failed; + if (!(pval = flatcc_builder_table_add(ctx->ctx, 2, 4, 4))) goto failed; flatbuffers_int32_write_to_pe(pval, val); } } else { - buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); + goto pfguard1; } - } else { /* "_at" */ + } else { /* "quotes" */ + goto pfguard1; + } /* "quotes" */ + goto endpfguard1; +pfguard1: + if (w == 0x7265616374696f6e) { /* descend "reaction" */ + buf += 8; + w = flatcc_json_parser_symbol_part(buf, end); + if ((w & 0xff00000000000000) == 0x7300000000000000) { /* "s" */ + buf = flatcc_json_parser_match_symbol(ctx, (mark = buf), end, 1); + if (mark != buf) { + int32_t val = 0; + static flatcc_json_parser_integral_symbol_f *symbolic_parsers[] = { + meta_local_json_parser_enum, + meta_global_json_parser_enum, 0 }; + buf = flatcc_json_parser_int32(ctx, (mark = buf), end, &val); + if (mark == buf) { + buf = flatcc_json_parser_symbolic_int32(ctx, (mark = buf), end, symbolic_parsers, &val); + if (buf == mark || buf == end) goto failed; + } + if (val != INT32_C(0) || (ctx->flags & flatcc_json_parser_f_force_add)) { + if (!(pval = flatcc_builder_table_add(ctx->ctx, 1, 4, 4))) goto failed; + flatbuffers_int32_write_to_pe(pval, val); + } + } else { + buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); + } + } else { /* "s" */ + buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); + } /* "s" */ + } else { /* descend "reaction" */ buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); - } /* "_at" */ - } else { /* descend "received" */ - buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); - } /* descend "received" */ + } /* descend "reaction" */ +endpfguard1: + (void)0; + } else { /* branch "received" */ + if (w < 0x7265706f73747300) { /* branch "reposts" */ + if (w == 0x7265636569766564) { /* descend "received" */ + buf += 8; + w = flatcc_json_parser_symbol_part(buf, end); + if ((w & 0xffffff0000000000) == 0x5f61740000000000) { /* "_at" */ + buf = flatcc_json_parser_match_symbol(ctx, (mark = buf), end, 3); + if (mark != buf) { + int32_t val = 0; + static flatcc_json_parser_integral_symbol_f *symbolic_parsers[] = { + meta_local_json_parser_enum, + meta_global_json_parser_enum, 0 }; + buf = flatcc_json_parser_int32(ctx, (mark = buf), end, &val); + if (mark == buf) { + buf = flatcc_json_parser_symbolic_int32(ctx, (mark = buf), end, symbolic_parsers, &val); + if (buf == mark || buf == end) goto failed; + } + if (val != INT32_C(0) || (ctx->flags & flatcc_json_parser_f_force_add)) { + if (!(pval = flatcc_builder_table_add(ctx->ctx, 0, 4, 4))) goto failed; + flatbuffers_int32_write_to_pe(pval, val); + } + } else { + buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); + } + } else { /* "_at" */ + buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); + } /* "_at" */ + } else { /* descend "received" */ + buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); + } /* descend "received" */ + } else { /* branch "reposts" */ + if (w < 0x7a61705f746f7461) { /* branch "zap_tota" */ + if ((w & 0xffffffffffffff00) == 0x7265706f73747300) { /* "reposts" */ + buf = flatcc_json_parser_match_symbol(ctx, (mark = buf), end, 7); + if (mark != buf) { + int32_t val = 0; + static flatcc_json_parser_integral_symbol_f *symbolic_parsers[] = { + meta_local_json_parser_enum, + meta_global_json_parser_enum, 0 }; + buf = flatcc_json_parser_int32(ctx, (mark = buf), end, &val); + if (mark == buf) { + buf = flatcc_json_parser_symbolic_int32(ctx, (mark = buf), end, symbolic_parsers, &val); + if (buf == mark || buf == end) goto failed; + } + if (val != INT32_C(0) || (ctx->flags & flatcc_json_parser_f_force_add)) { + if (!(pval = flatcc_builder_table_add(ctx->ctx, 3, 4, 4))) goto failed; + flatbuffers_int32_write_to_pe(pval, val); + } + } else { + buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); + } + } else { /* "reposts" */ + buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); + } /* "reposts" */ + } else { /* branch "zap_tota" */ + if (w == 0x7a61705f746f7461) { /* descend "zap_tota" */ + buf += 8; + w = flatcc_json_parser_symbol_part(buf, end); + if ((w & 0xff00000000000000) == 0x6c00000000000000) { /* "l" */ + buf = flatcc_json_parser_match_symbol(ctx, (mark = buf), end, 1); + if (mark != buf) { + int64_t val = 0; + static flatcc_json_parser_integral_symbol_f *symbolic_parsers[] = { + meta_local_json_parser_enum, + meta_global_json_parser_enum, 0 }; + buf = flatcc_json_parser_int64(ctx, (mark = buf), end, &val); + if (mark == buf) { + buf = flatcc_json_parser_symbolic_int64(ctx, (mark = buf), end, symbolic_parsers, &val); + if (buf == mark || buf == end) goto failed; + } + if (val != INT64_C(0) || (ctx->flags & flatcc_json_parser_f_force_add)) { + if (!(pval = flatcc_builder_table_add(ctx->ctx, 5, 8, 8))) goto failed; + flatbuffers_int64_write_to_pe(pval, val); + } + } else { + buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); + } + } else { /* "l" */ + buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); + } /* "l" */ + } else { /* descend "zap_tota" */ + if ((w & 0xffffffff00000000) == 0x7a61707300000000) { /* "zaps" */ + buf = flatcc_json_parser_match_symbol(ctx, (mark = buf), end, 4); + if (mark != buf) { + int32_t val = 0; + static flatcc_json_parser_integral_symbol_f *symbolic_parsers[] = { + meta_local_json_parser_enum, + meta_global_json_parser_enum, 0 }; + buf = flatcc_json_parser_int32(ctx, (mark = buf), end, &val); + if (mark == buf) { + buf = flatcc_json_parser_symbolic_int32(ctx, (mark = buf), end, symbolic_parsers, &val); + if (buf == mark || buf == end) goto failed; + } + if (val != INT32_C(0) || (ctx->flags & flatcc_json_parser_f_force_add)) { + if (!(pval = flatcc_builder_table_add(ctx->ctx, 4, 4, 4))) goto failed; + flatbuffers_int32_write_to_pe(pval, val); + } + } else { + buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); + } + } else { /* "zaps" */ + buf = flatcc_json_parser_unmatched_symbol(ctx, buf, end); + } /* "zaps" */ + } /* descend "zap_tota" */ + } /* branch "zap_tota" */ + } /* branch "reposts" */ + } /* branch "received" */ buf = flatcc_json_parser_object_end(ctx, buf, end, &more); } if (ctx->error) goto failed; diff --git a/nostrdb/bindings/c/meta_reader.h b/nostrdb/bindings/c/meta_reader.h index e72746d0d2..90f2223eed 100644 --- a/nostrdb/bindings/c/meta_reader.h +++ b/nostrdb/bindings/c/meta_reader.h @@ -47,6 +47,11 @@ __flatbuffers_offset_vec_at(NdbEventMeta_table_t, vec, i, 0) __flatbuffers_table_as_root(NdbEventMeta) __flatbuffers_define_scalar_field(0, NdbEventMeta, received_at, flatbuffers_int32, int32_t, INT32_C(0)) +__flatbuffers_define_scalar_field(1, NdbEventMeta, reactions, flatbuffers_int32, int32_t, INT32_C(0)) +__flatbuffers_define_scalar_field(2, NdbEventMeta, quotes, flatbuffers_int32, int32_t, INT32_C(0)) +__flatbuffers_define_scalar_field(3, NdbEventMeta, reposts, flatbuffers_int32, int32_t, INT32_C(0)) +__flatbuffers_define_scalar_field(4, NdbEventMeta, zaps, flatbuffers_int32, int32_t, INT32_C(0)) +__flatbuffers_define_scalar_field(5, NdbEventMeta, zap_total, flatbuffers_int64, int64_t, INT64_C(0)) #include "flatcc_epilogue.h" diff --git a/nostrdb/bindings/c/meta_verifier.h b/nostrdb/bindings/c/meta_verifier.h index d2b2df7224..db801cf6f2 100644 --- a/nostrdb/bindings/c/meta_verifier.h +++ b/nostrdb/bindings/c/meta_verifier.h @@ -15,6 +15,11 @@ static int NdbEventMeta_verify_table(flatcc_table_verifier_descriptor_t *td) { int ret; if ((ret = flatcc_verify_field(td, 0, 4, 4) /* received_at */)) return ret; + if ((ret = flatcc_verify_field(td, 1, 4, 4) /* reactions */)) return ret; + if ((ret = flatcc_verify_field(td, 2, 4, 4) /* quotes */)) return ret; + if ((ret = flatcc_verify_field(td, 3, 4, 4) /* reposts */)) return ret; + if ((ret = flatcc_verify_field(td, 4, 4, 4) /* zaps */)) return ret; + if ((ret = flatcc_verify_field(td, 5, 8, 8) /* zap_total */)) return ret; return flatcc_verify_ok; } diff --git a/nostrdb/bindings/swift/NdbMeta.swift b/nostrdb/bindings/swift/NdbMeta.swift new file mode 100644 index 0000000000..f452ee6d33 --- /dev/null +++ b/nostrdb/bindings/swift/NdbMeta.swift @@ -0,0 +1,71 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// swiftlint:disable all +// swiftformat:disable all + +import FlatBuffers + +public struct NdbEventMeta: FlatBufferObject, Verifiable { + + static func validateVersion() { FlatBuffersVersion_23_5_26() } + public var __buffer: ByteBuffer! { return _accessor.bb } + private var _accessor: Table + + private init(_ t: Table) { _accessor = t } + public init(_ bb: ByteBuffer, o: Int32) { _accessor = Table(bb: bb, position: o) } + + private enum VTOFFSET: VOffset { + case receivedAt = 4 + case reactions = 6 + case quotes = 8 + case reposts = 10 + case zaps = 12 + case zapTotal = 14 + var v: Int32 { Int32(self.rawValue) } + var p: VOffset { self.rawValue } + } + + public var receivedAt: Int32 { let o = _accessor.offset(VTOFFSET.receivedAt.v); return o == 0 ? 0 : _accessor.readBuffer(of: Int32.self, at: o) } + public var reactions: Int32 { let o = _accessor.offset(VTOFFSET.reactions.v); return o == 0 ? 0 : _accessor.readBuffer(of: Int32.self, at: o) } + public var quotes: Int32 { let o = _accessor.offset(VTOFFSET.quotes.v); return o == 0 ? 0 : _accessor.readBuffer(of: Int32.self, at: o) } + public var reposts: Int32 { let o = _accessor.offset(VTOFFSET.reposts.v); return o == 0 ? 0 : _accessor.readBuffer(of: Int32.self, at: o) } + public var zaps: Int32 { let o = _accessor.offset(VTOFFSET.zaps.v); return o == 0 ? 0 : _accessor.readBuffer(of: Int32.self, at: o) } + public var zapTotal: Int64 { let o = _accessor.offset(VTOFFSET.zapTotal.v); return o == 0 ? 0 : _accessor.readBuffer(of: Int64.self, at: o) } + public static func startNdbEventMeta(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 6) } + public static func add(receivedAt: Int32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: receivedAt, def: 0, at: VTOFFSET.receivedAt.p) } + public static func add(reactions: Int32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: reactions, def: 0, at: VTOFFSET.reactions.p) } + public static func add(quotes: Int32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: quotes, def: 0, at: VTOFFSET.quotes.p) } + public static func add(reposts: Int32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: reposts, def: 0, at: VTOFFSET.reposts.p) } + public static func add(zaps: Int32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: zaps, def: 0, at: VTOFFSET.zaps.p) } + public static func add(zapTotal: Int64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: zapTotal, def: 0, at: VTOFFSET.zapTotal.p) } + public static func endNdbEventMeta(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } + public static func createNdbEventMeta( + _ fbb: inout FlatBufferBuilder, + receivedAt: Int32 = 0, + reactions: Int32 = 0, + quotes: Int32 = 0, + reposts: Int32 = 0, + zaps: Int32 = 0, + zapTotal: Int64 = 0 + ) -> Offset { + let __start = NdbEventMeta.startNdbEventMeta(&fbb) + NdbEventMeta.add(receivedAt: receivedAt, &fbb) + NdbEventMeta.add(reactions: reactions, &fbb) + NdbEventMeta.add(quotes: quotes, &fbb) + NdbEventMeta.add(reposts: reposts, &fbb) + NdbEventMeta.add(zaps: zaps, &fbb) + NdbEventMeta.add(zapTotal: zapTotal, &fbb) + return NdbEventMeta.endNdbEventMeta(&fbb, start: __start) + } + + public static func verify(_ verifier: inout Verifier, at position: Int, of type: T.Type) throws where T: Verifiable { + var _v = try verifier.visitTable(at: position) + try _v.visit(field: VTOFFSET.receivedAt.p, fieldName: "receivedAt", required: false, type: Int32.self) + try _v.visit(field: VTOFFSET.reactions.p, fieldName: "reactions", required: false, type: Int32.self) + try _v.visit(field: VTOFFSET.quotes.p, fieldName: "quotes", required: false, type: Int32.self) + try _v.visit(field: VTOFFSET.reposts.p, fieldName: "reposts", required: false, type: Int32.self) + try _v.visit(field: VTOFFSET.zaps.p, fieldName: "zaps", required: false, type: Int32.self) + try _v.visit(field: VTOFFSET.zapTotal.p, fieldName: "zapTotal", required: false, type: Int64.self) + _v.finish() + } +} + diff --git a/nostrdb/nostrdb.c b/nostrdb/nostrdb.c index d805393de5..97a11809e0 100644 --- a/nostrdb/nostrdb.c +++ b/nostrdb/nostrdb.c @@ -16,6 +16,8 @@ #include "bindings/c/profile_json_parser.h" #include "bindings/c/profile_builder.h" +#include "bindings/c/meta_builder.h" +#include "bindings/c/meta_reader.h" #include "bindings/c/profile_verifier.h" #include "secp256k1.h" #include "secp256k1_ecdh.h" @@ -155,8 +157,7 @@ static void ndb_make_search_key(struct ndb_search_key *key, unsigned char *id, key->search[sizeof(key->search) - 1] = '\0'; } -static int ndb_write_profile_search_index(struct ndb_lmdb *lmdb, - MDB_txn *txn, +static int ndb_write_profile_search_index(struct ndb_txn *txn, struct ndb_search_key *index_key, uint64_t profile_key) { @@ -168,7 +169,9 @@ static int ndb_write_profile_search_index(struct ndb_lmdb *lmdb, val.mv_data = &profile_key; val.mv_size = sizeof(profile_key); - if ((rc = mdb_put(txn, lmdb->dbs[NDB_DB_PROFILE_SEARCH], &key, &val, 0))) { + if ((rc = mdb_put(txn->mdb_txn, txn->lmdb->dbs[NDB_DB_PROFILE_SEARCH], + &key, &val, 0))) + { ndb_debug("ndb_write_profile_search_index failed: %s\n", mdb_strerror(rc)); return 0; @@ -179,8 +182,7 @@ static int ndb_write_profile_search_index(struct ndb_lmdb *lmdb, // map usernames and display names to profile keys for user searching -static int ndb_write_profile_search_indices(struct ndb_lmdb *lmdb, - MDB_txn *txn, +static int ndb_write_profile_search_indices(struct ndb_txn *txn, struct ndb_note *note, uint64_t profile_key, void *profile_root) @@ -199,8 +201,7 @@ static int ndb_write_profile_search_indices(struct ndb_lmdb *lmdb, if (name) { ndb_make_search_key(&index, note->pubkey, note->created_at, name); - if (!ndb_write_profile_search_index(lmdb, txn, &index, - profile_key)) + if (!ndb_write_profile_search_index(txn, &index, profile_key)) return 0; } @@ -211,19 +212,30 @@ static int ndb_write_profile_search_indices(struct ndb_lmdb *lmdb, } ndb_make_search_key(&index, note->pubkey, note->created_at, display_name); - if (!ndb_write_profile_search_index(lmdb, txn, &index, - profile_key)) + if (!ndb_write_profile_search_index(txn, &index, profile_key)) return 0; } return 1; } -int ndb_begin_query(struct ndb *ndb, struct ndb_txn *txn) + +static int _ndb_begin_query(struct ndb *ndb, struct ndb_txn *txn, int flags) { - txn->ndb = ndb; + txn->lmdb = &ndb->lmdb; MDB_txn **mdb_txn = (MDB_txn **)&txn->mdb_txn; - return mdb_txn_begin(ndb->lmdb.env, NULL, 0, mdb_txn) == 0; + return mdb_txn_begin(txn->lmdb->env, NULL, flags, mdb_txn) == 0; +} + +int ndb_begin_query(struct ndb *ndb, struct ndb_txn *txn) +{ + return _ndb_begin_query(ndb, txn, MDB_RDONLY); +} + +// this should only be used in migrations, etc +static int ndb_begin_rw_query(struct ndb *ndb, struct ndb_txn *txn) +{ + return _ndb_begin_query(ndb, txn, 0); } @@ -243,8 +255,8 @@ static int ndb_migrate_user_search_indices(struct ndb *ndb) size_t len; int count; - if (!ndb_begin_query(ndb, &txn)) { - fprintf(stderr, "ndb_migrate_user_search_indices: ndb_begin_query failed\n"); + if (!ndb_begin_rw_query(ndb, &txn)) { + fprintf(stderr, "ndb_migrate_user_search_indices: ndb_begin_rw_query failed\n"); return 0; } @@ -268,8 +280,7 @@ static int ndb_migrate_user_search_indices(struct ndb *ndb) return 0; } - if (!ndb_write_profile_search_indices(&ndb->lmdb, txn.mdb_txn, - note, profile_key, + if (!ndb_write_profile_search_indices(&txn, note, profile_key, profile_root)) { fprintf(stderr, "ndb_migrate_user_search_indices: ndb_write_profile_search_indices failed\n"); @@ -282,7 +293,8 @@ static int ndb_migrate_user_search_indices(struct ndb *ndb) fprintf(stderr, "migrated %d profiles to include search indices\n", count); mdb_cursor_close(cur); - mdb_txn_commit(txn.mdb_txn); + + ndb_end_query(&txn); return 1; } @@ -442,11 +454,15 @@ struct ndb_writer_ndb_meta { uint64_t version; }; +// Used in the writer thread when writing ndb_profile_fetch_record's +// kv = pubkey: recor struct ndb_writer_last_fetch { unsigned char pubkey[32]; uint64_t fetched_at; }; +// The different types of messages that the writer thread can write to the +// database struct ndb_writer_msg { enum ndb_writer_msgtype type; union { @@ -457,9 +473,10 @@ struct ndb_writer_msg { }; }; -void ndb_end_query(struct ndb_txn *txn) +int ndb_end_query(struct ndb_txn *txn) { - mdb_txn_abort(txn->mdb_txn); + // this works on read or write queries. + return mdb_txn_commit(txn->mdb_txn) == 0; } int ndb_note_verify(void *ctx, unsigned char pubkey[32], unsigned char id[32], @@ -504,18 +521,21 @@ static int ndb_writer_queue_note(struct ndb_writer *writer, return prot_queue_push(&writer->inbox, &msg); } -static void ndb_writer_last_profile_fetch(struct ndb_lmdb *lmdb, MDB_txn *txn, - struct ndb_writer_last_fetch *w) +static void ndb_writer_last_profile_fetch(struct ndb_txn *txn, + const unsigned char *pubkey, + uint64_t fetched_at) { int rc; MDB_val key, val; - key.mv_data = (unsigned char*)&w->pubkey; - key.mv_size = sizeof(w->pubkey); - val.mv_data = &w->fetched_at; - val.mv_size = sizeof(w->fetched_at); + key.mv_data = (unsigned char*)pubkey; + key.mv_size = 32; + val.mv_data = &fetched_at; + val.mv_size = sizeof(fetched_at); - if ((rc = mdb_put(txn, lmdb->dbs[NDB_DB_PROFILE_LAST_FETCH], &key, &val, 0))) { + if ((rc = mdb_put(txn->mdb_txn, txn->lmdb->dbs[NDB_DB_PROFILE_LAST_FETCH], + &key, &val, 0))) + { ndb_debug("write version to ndb_meta failed: %s\n", mdb_strerror(rc)); return; @@ -524,6 +544,46 @@ static void ndb_writer_last_profile_fetch(struct ndb_lmdb *lmdb, MDB_txn *txn, //fprintf(stderr, "writing version %" PRIu64 "\n", version); } + +// We just received a profile that we haven't processed yet, but it could +// be an older one! Make sure we only write last fetched profile if it's a new +// one +// +// To do this, we first check the latest profile in the database. If the +// created_date for this profile note is newer, then we write a +// last_profile_fetch record, otherwise we do not. +// +// WARNING: This function is only valid when called from the writer thread +static int ndb_maybe_write_last_profile_fetch(struct ndb_txn *txn, + struct ndb_note *note) +{ + size_t len; + uint64_t profile_key, note_key; + void *root; + struct ndb_note *last_profile; + NdbProfileRecord_table_t record; + + if ((root = ndb_get_profile_by_pubkey(txn, note->pubkey, &len, &profile_key))) { + record = NdbProfileRecord_as_root(root); + note_key = NdbProfileRecord_note_key(record); + last_profile = ndb_get_note_by_key(txn, note_key, &len); + if (last_profile == NULL) { + return 0; + } + + // found profile, let's see if it's newer than ours + if (note->created_at > last_profile->created_at) { + // this is a new profile note, record last fetched time + ndb_writer_last_profile_fetch(txn, note->pubkey, time(NULL)); + } + } else { + // couldn't fetch profile. record last fetched time + ndb_writer_last_profile_fetch(txn, note->pubkey, time(NULL)); + } + + return 1; +} + int ndb_write_last_profile_fetch(struct ndb *ndb, const unsigned char *pubkey, uint64_t fetched_at) { @@ -536,8 +596,8 @@ int ndb_write_last_profile_fetch(struct ndb *ndb, const unsigned char *pubkey, } // get some value based on a clustered id key -int ndb_get_tsid(MDB_txn *txn, struct ndb_lmdb *lmdb, enum ndb_dbs db, - const unsigned char *id, MDB_val *val) +int ndb_get_tsid(struct ndb_txn *txn, enum ndb_dbs db, const unsigned char *id, + MDB_val *val) { MDB_val k, v; MDB_cursor *cur; @@ -550,7 +610,7 @@ int ndb_get_tsid(MDB_txn *txn, struct ndb_lmdb *lmdb, enum ndb_dbs db, k.mv_data = &tsid; k.mv_size = sizeof(tsid); - mdb_cursor_open(txn, lmdb->dbs[db], &cur); + mdb_cursor_open(txn->mdb_txn, txn->lmdb->dbs[db], &cur); // Position cursor at the next key greater than or equal to the specified key if (mdb_cursor_get(cur, &k, &v, MDB_SET_RANGE)) { @@ -582,7 +642,7 @@ static void *ndb_lookup_by_key(struct ndb_txn *txn, uint64_t key, k.mv_data = &key; k.mv_size = sizeof(key); - if (mdb_get(txn->mdb_txn, txn->ndb->lmdb.dbs[store], &k, &v)) { + if (mdb_get(txn->mdb_txn, txn->lmdb->dbs[store], &k, &v)) { ndb_debug("ndb_get_profile_by_pubkey: mdb_get note failed\n"); return NULL; } @@ -602,7 +662,7 @@ static void *ndb_lookup_tsid(struct ndb_txn *txn, enum ndb_dbs ind, if (len) *len = 0; - if (!ndb_get_tsid(txn->mdb_txn, &txn->ndb->lmdb, ind, pk, &k)) { + if (!ndb_get_tsid(txn, ind, pk, &k)) { //ndb_debug("ndb_get_profile_by_pubkey: ndb_get_tsid failed\n"); return 0; } @@ -610,7 +670,7 @@ static void *ndb_lookup_tsid(struct ndb_txn *txn, enum ndb_dbs ind, if (primkey) *primkey = *(uint64_t*)k.mv_data; - if (mdb_get(txn->mdb_txn, txn->ndb->lmdb.dbs[store], &k, &v)) { + if (mdb_get(txn->mdb_txn, txn->lmdb->dbs[store], &k, &v)) { ndb_debug("ndb_get_profile_by_pubkey: mdb_get note failed\n"); return 0; } @@ -638,7 +698,7 @@ static inline uint64_t ndb_get_indexkey_by_id(struct ndb_txn *txn, { MDB_val k; - if (!ndb_get_tsid(txn->mdb_txn, &txn->ndb->lmdb, db, id, &k)) + if (!ndb_get_tsid(txn, db, id, &k)) return 0; return *(uint32_t*)k.mv_data; @@ -664,37 +724,52 @@ void *ndb_get_profile_by_key(struct ndb_txn *txn, uint64_t key, size_t *len) return ndb_lookup_by_key(txn, key, NDB_DB_PROFILE, len); } -uint64_t ndb_read_last_profile_fetch(struct ndb_txn *txn, uint64_t profile_key) +uint64_t +ndb_read_last_profile_fetch(struct ndb_txn *txn, const unsigned char *pubkey) { - size_t len; - void *ret = ndb_lookup_by_key(txn, profile_key, NDB_DB_PROFILE_LAST_FETCH, &len); - if (ret == NULL) + MDB_val k, v; + + k.mv_data = (unsigned char*)pubkey; + k.mv_size = 32; + + if (mdb_get(txn->mdb_txn, txn->lmdb->dbs[NDB_DB_PROFILE_LAST_FETCH], &k, &v)) { + //ndb_debug("ndb_read_last_profile_fetch: mdb_get note failed\n"); return 0; - assert(len == sizeof(uint64_t)); - return *((uint64_t*)ret); + } + + return *((uint64_t*)v.mv_data); } -static int ndb_has_note(MDB_txn *txn, struct ndb_lmdb *lmdb, const unsigned char *id) +static int ndb_has_note(struct ndb_txn *txn, const unsigned char *id) { MDB_val val; - if (!ndb_get_tsid(txn, lmdb, NDB_DB_NOTE_ID, id, &val)) + if (!ndb_get_tsid(txn, NDB_DB_NOTE_ID, id, &val)) return 0; return 1; } +static void ndb_txn_from_mdb(struct ndb_txn *txn, struct ndb_lmdb *lmdb, + MDB_txn *mdb_txn) +{ + txn->lmdb = lmdb; + txn->mdb_txn = mdb_txn; +} + static enum ndb_idres ndb_ingester_json_controller(void *data, const char *hexid) { unsigned char id[32]; struct ndb_ingest_controller *c = data; + struct ndb_txn txn; hex_decode(hexid, 64, id, sizeof(id)); // let's see if we already have it - if (!ndb_has_note(c->read_txn, c->lmdb, id)) + ndb_txn_from_mdb(&txn, c->lmdb, c->read_txn); + if (!ndb_has_note(&txn, id)) return NDB_IDRES_CONT; return NDB_IDRES_STOP; @@ -785,8 +860,12 @@ static int ndb_ingester_process_note(secp256k1_context *ctx, size_t note_size, struct ndb_writer_msg *out) { + //printf("ndb_ingester_process_note "); + //print_hex(note->id, 32); + //printf("\n"); + // Verify! If it's an invalid note we don't need to - // bothter writing it to the database + // bother writing it to the database if (!ndb_note_verify(ctx, note->pubkey, note->id, note->sig)) { ndb_debug("signature verification failed\n"); return 0; @@ -957,8 +1036,8 @@ int ndb_search_profile(struct ndb_txn *txn, struct ndb_search *search, const cha k.mv_size = sizeof(s); if ((rc = mdb_cursor_open(txn->mdb_txn, - txn->ndb->lmdb.dbs[NDB_DB_PROFILE_SEARCH], - cursor))) { + txn->lmdb->dbs[NDB_DB_PROFILE_SEARCH], + cursor))) { printf("search_profile: cursor opened failed: %s\n", mdb_strerror(rc)); return 0; @@ -1040,7 +1119,7 @@ static int ndb_search_key_cmp(const MDB_val *a, const MDB_val *b) return 0; } -static int ndb_write_profile(struct ndb_lmdb *lmdb, MDB_txn *txn, +static int ndb_write_profile(struct ndb_txn *txn, struct ndb_writer_profile *profile, uint64_t note_key) { @@ -1071,11 +1150,11 @@ static int ndb_write_profile(struct ndb_lmdb *lmdb, MDB_txn *txn, //assert(NdbProfileRecord_verify_as_root(flatbuf, flatbuf_len) == 0); // get dbs - profile_db = lmdb->dbs[NDB_DB_PROFILE]; - pk_db = lmdb->dbs[NDB_DB_PROFILE_PK]; + profile_db = txn->lmdb->dbs[NDB_DB_PROFILE]; + pk_db = txn->lmdb->dbs[NDB_DB_PROFILE_PK]; // get new key - profile_key = ndb_get_last_key(txn, profile_db) + 1; + profile_key = ndb_get_last_key(txn->mdb_txn, profile_db) + 1; // write profile to profile store key.mv_data = &profile_key; @@ -1084,7 +1163,7 @@ static int ndb_write_profile(struct ndb_lmdb *lmdb, MDB_txn *txn, val.mv_size = flatbuf_len; //ndb_debug("profile_len %ld\n", profile->profile_len); - if ((rc = mdb_put(txn, profile_db, &key, &val, 0))) { + if ((rc = mdb_put(txn->mdb_txn, profile_db, &key, &val, 0))) { ndb_debug("write profile to db failed: %s\n", mdb_strerror(rc)); return 0; } @@ -1097,14 +1176,20 @@ static int ndb_write_profile(struct ndb_lmdb *lmdb, MDB_txn *txn, val.mv_data = &profile_key; val.mv_size = sizeof(profile_key); - if ((rc = mdb_put(txn, pk_db, &key, &val, 0))) { + // write last fetched record + if (!ndb_maybe_write_last_profile_fetch(txn, note)) { + ndb_debug("failed to write last profile fetched record\n"); + return 0; + } + + if ((rc = mdb_put(txn->mdb_txn, pk_db, &key, &val, 0))) { ndb_debug("write profile_pk(%" PRIu64 ") to db failed: %s\n", profile_key, mdb_strerror(rc)); return 0; } // write name, display_name profile search indices - if (!ndb_write_profile_search_indices(lmdb, txn, note, profile_key, + if (!ndb_write_profile_search_indices(txn, note, profile_key, flatbuf)) { ndb_debug("failed to write profile search indices\n"); return 0; @@ -1113,7 +1198,127 @@ static int ndb_write_profile(struct ndb_lmdb *lmdb, MDB_txn *txn, return 1; } -static uint64_t ndb_write_note(struct ndb_lmdb *lmdb, MDB_txn *txn, +// find the last id tag in a note (e, p, etc) +static unsigned char *ndb_note_last_id_tag(struct ndb_note *note, char type) +{ + unsigned char *last = NULL; + struct ndb_iterator iter; + struct ndb_str str; + + // get the liked event id (last id) + ndb_tags_iterate_start(note, &iter); + + while (ndb_tags_iterate_next(&iter)) { + if (iter.tag->count < 2) + continue; + + str = ndb_note_str(note, &iter.tag->strs[0]); + + // assign liked to the last e tag + if (str.flag == NDB_PACKED_STR && str.str[0] == type) { + str = ndb_note_str(note, &iter.tag->strs[1]); + if (str.flag == NDB_PACKED_ID) + last = str.id; + } + } + + return last; +} + +void *ndb_get_note_meta(struct ndb_txn *txn, const unsigned char *id, size_t *len) +{ + MDB_val k, v; + + k.mv_data = (unsigned char*)id; + k.mv_size = 32; + + if (mdb_get(txn->mdb_txn, txn->lmdb->dbs[NDB_DB_META], &k, &v)) { + ndb_debug("ndb_get_note_meta: mdb_get note failed\n"); + return NULL; + } + + if (len) + *len = v.mv_size; + + return v.mv_data; +} + +// When receiving a reaction note, look for the liked id and increase the +// reaction counter in the note metadata database +// +// TODO: I found some bugs when implementing this feature. If the same note id +// is processed multiple times in the same ingestion block, then it will count +// the like twice. This is because it hasn't been written to the DB yet and the +// ingestor doesn't know about notes that are being processed at the same time. +// One fix for this is to maintain a hashtable in the ingestor and make sure +// the same note is not processed twice. +// +// I'm not sure how common this would be, so I'm not going to worry about it +// for now, but it's something to keep in mind. +static int ndb_write_reaction_stats(struct ndb_txn *txn, struct ndb_note *note) +{ + size_t len; + void *root; + int reactions, rc; + MDB_val key, val; + NdbEventMeta_table_t meta; + unsigned char *liked = ndb_note_last_id_tag(note, 'e'); + + if (liked == NULL) + return 0; + + root = ndb_get_note_meta(txn, liked, &len); + + flatcc_builder_t builder; + flatcc_builder_init(&builder); + NdbEventMeta_start_as_root(&builder); + + // no meta record, let's make one + if (root == NULL) { + NdbEventMeta_reactions_add(&builder, 1); + } else { + // clone existing and add to it + meta = NdbEventMeta_as_root(root); + + reactions = NdbEventMeta_reactions_get(meta); + NdbEventMeta_clone(&builder, meta); + NdbEventMeta_reactions_add(&builder, reactions + 1); + } + + NdbProfileRecord_end_as_root(&builder); + root = flatcc_builder_finalize_aligned_buffer(&builder, &len); + assert(((uint64_t)root % 8) == 0); + + if (root == NULL) { + ndb_debug("failed to create note metadata record\n"); + return 0; + } + + // metadata is keyed on id because we want to collect stats regardless + // if we have the note yet or not + key.mv_data = liked; + key.mv_size = 32; + + val.mv_data = root; + val.mv_size = len; + + // write the new meta record + //ndb_debug("writing stats record for "); + //print_hex(liked, 32); + //ndb_debug("\n"); + + if ((rc = mdb_put(txn->mdb_txn, txn->lmdb->dbs[NDB_DB_META], &key, &val, 0))) { + ndb_debug("write reaction stats to db failed: %s\n", mdb_strerror(rc)); + return 0; + } + + free(root); + + return 1; +} + + +static uint64_t ndb_write_note(struct ndb_txn *txn, struct ndb_writer_note *note) { int rc; @@ -1123,11 +1328,11 @@ static uint64_t ndb_write_note(struct ndb_lmdb *lmdb, MDB_txn *txn, MDB_val key, val; // get dbs - note_db = lmdb->dbs[NDB_DB_NOTE]; - id_db = lmdb->dbs[NDB_DB_NOTE_ID]; + note_db = txn->lmdb->dbs[NDB_DB_NOTE]; + id_db = txn->lmdb->dbs[NDB_DB_NOTE_ID]; // get new key - note_key = ndb_get_last_key(txn, note_db) + 1; + note_key = ndb_get_last_key(txn->mdb_txn, note_db) + 1; // write note to event store key.mv_data = ¬e_key; @@ -1135,7 +1340,7 @@ static uint64_t ndb_write_note(struct ndb_lmdb *lmdb, MDB_txn *txn, val.mv_data = note->note; val.mv_size = note->note_len; - if ((rc = mdb_put(txn, note_db, &key, &val, 0))) { + if ((rc = mdb_put(txn->mdb_txn, note_db, &key, &val, 0))) { ndb_debug("write note to db failed: %s\n", mdb_strerror(rc)); return 0; } @@ -1148,17 +1353,21 @@ static uint64_t ndb_write_note(struct ndb_lmdb *lmdb, MDB_txn *txn, val.mv_data = ¬e_key; val.mv_size = sizeof(note_key); - if ((rc = mdb_put(txn, id_db, &key, &val, 0))) { + if ((rc = mdb_put(txn->mdb_txn, id_db, &key, &val, 0))) { ndb_debug("write note id index to db failed: %s\n", mdb_strerror(rc)); return 0; } + if (note->note->kind == 7) { + ndb_write_reaction_stats(txn, note->note); + } + return note_key; } // only to be called from the writer thread -static void ndb_write_version(struct ndb_lmdb *lmdb, MDB_txn *txn, uint64_t version) +static void ndb_write_version(struct ndb_txn *txn, uint64_t version) { int rc; MDB_val key, val; @@ -1171,7 +1380,7 @@ static void ndb_write_version(struct ndb_lmdb *lmdb, MDB_txn *txn, uint64_t vers val.mv_data = &version; val.mv_size = sizeof(version); - if ((rc = mdb_put(txn, lmdb->dbs[NDB_DB_NDB_META], &key, &val, 0))) { + if ((rc = mdb_put(txn->mdb_txn, txn->lmdb->dbs[NDB_DB_NDB_META], &key, &val, 0))) { ndb_debug("write version to ndb_meta failed: %s\n", mdb_strerror(rc)); return; @@ -1186,11 +1395,13 @@ static void *ndb_writer_thread(void *data) struct ndb_writer_msg msgs[THREAD_QUEUE_BATCH], *msg; int i, popped, done, any_note; uint64_t note_nkey; - MDB_txn *txn; + MDB_txn *mdb_txn = NULL; + struct ndb_txn txn; + ndb_txn_from_mdb(&txn, writer->lmdb, mdb_txn); done = 0; while (!done) { - txn = NULL; + txn.mdb_txn = NULL; popped = prot_queue_pop_all(&writer->inbox, msgs, THREAD_QUEUE_BATCH); //ndb_debug("writer popped %d items\n", popped); @@ -1206,7 +1417,7 @@ static void *ndb_writer_thread(void *data) } } - if (any_note && mdb_txn_begin(writer->lmdb->env, NULL, 0, &txn)) + if (any_note && mdb_txn_begin(txn.lmdb->env, NULL, 0, (MDB_txn **)&txn.mdb_txn)) { fprintf(stderr, "writer thread txn_begin failed"); // should definitely not happen unless DB is full @@ -1224,33 +1435,37 @@ static void *ndb_writer_thread(void *data) continue; case NDB_WRITER_PROFILE: note_nkey = - ndb_write_note(writer->lmdb, txn, &msg->note); + ndb_write_note(&txn, &msg->note); if (msg->profile.record.builder) { // only write if parsing didn't fail - ndb_write_profile(writer->lmdb, txn, - &msg->profile, + ndb_write_profile(&txn, &msg->profile, note_nkey); } break; case NDB_WRITER_NOTE: - ndb_write_note(writer->lmdb, txn, &msg->note); + ndb_write_note(&txn, &msg->note); + //printf("wrote note "); + //print_hex(msg->note.note->id, 32); + //printf("\n"); break; case NDB_WRITER_DBMETA: - ndb_write_version(writer->lmdb, txn, msg->ndb_meta.version); + ndb_write_version(&txn, msg->ndb_meta.version); break; case NDB_WRITER_PROFILE_LAST_FETCH: - ndb_writer_last_profile_fetch(writer->lmdb, txn, &msg->last_fetch); + ndb_writer_last_profile_fetch(&txn, + msg->last_fetch.pubkey, + msg->last_fetch.fetched_at + ); break; } } // commit writes - if (any_note && mdb_txn_commit(txn)) { + if (any_note && !ndb_end_query(&txn)) { fprintf(stderr, "writer thread txn commit failed"); assert(false); } - // free notes for (i = 0; i < popped; i++) { msg = &msgs[i]; @@ -1459,7 +1674,7 @@ static int ndb_init_lmdb(const char *filename, struct ndb_lmdb *lmdb, size_t map } // note metadata db - if ((rc = mdb_dbi_open(txn, "meta", MDB_CREATE | MDB_INTEGERKEY, &lmdb->dbs[NDB_DB_META]))) { + if ((rc = mdb_dbi_open(txn, "meta", MDB_CREATE, &lmdb->dbs[NDB_DB_META]))) { fprintf(stderr, "mdb_dbi_open meta failed, error %d\n", rc); return 0; } diff --git a/nostrdb/nostrdb.h b/nostrdb/nostrdb.h index 3c1ad3d399..62006864d4 100644 --- a/nostrdb/nostrdb.h +++ b/nostrdb/nostrdb.h @@ -40,7 +40,7 @@ struct ndb_search { // required to keep a read struct ndb_txn { - struct ndb *ndb; + struct ndb_lmdb *lmdb; void *mdb_txn; }; @@ -196,15 +196,16 @@ int ndb_begin_query(struct ndb *, struct ndb_txn *); int ndb_search_profile(struct ndb_txn *txn, struct ndb_search *search, const char *query); int ndb_search_profile_next(struct ndb_search *search); void ndb_search_profile_end(struct ndb_search *search); -void ndb_end_query(struct ndb_txn *); +int ndb_end_query(struct ndb_txn *); int ndb_write_last_profile_fetch(struct ndb *ndb, const unsigned char *pubkey, uint64_t fetched_at); -uint64_t ndb_read_last_profile_fetch(struct ndb_txn *txn, uint64_t profile_key); +uint64_t ndb_read_last_profile_fetch(struct ndb_txn *txn, const unsigned char *pubkey); void *ndb_get_profile_by_pubkey(struct ndb_txn *txn, const unsigned char *pubkey, size_t *len, uint64_t *primkey); void *ndb_get_profile_by_key(struct ndb_txn *txn, uint64_t key, size_t *len); uint64_t ndb_get_notekey_by_id(struct ndb_txn *txn, const unsigned char *id); uint64_t ndb_get_profilekey_by_pubkey(struct ndb_txn *txn, const unsigned char *id); struct ndb_note *ndb_get_note_by_id(struct ndb_txn *txn, const unsigned char *id, size_t *len, uint64_t *primkey); struct ndb_note *ndb_get_note_by_key(struct ndb_txn *txn, uint64_t key, size_t *len); +void *ndb_get_note_meta(struct ndb_txn *txn, const unsigned char *id, size_t *len); void ndb_destroy(struct ndb *); // BUILDER