From 95d38fa802d8d6510553c4a5d78e2ffea3b1aa1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Mon, 12 Jan 2026 12:47:06 -0800 Subject: [PATCH] Implement initial negentropy base functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This implements some useful functions to use negentropy from RelayPool, but does not integrate them with the rest of the app. No changelog for the negentropy support right now as it is not hooked up to any user-facing feature Changelog-Fixed: Fixed a race condition in the networking logic that could cause notes to get missed in certain rare scenarios Signed-off-by: Daniel D’Aquino --- damus.xcodeproj/project.pbxproj | 106 ++++- .../xcshareddata/swiftpm/Package.resolved | 20 +- damus/Core/Nostr/NostrEvent.swift | 8 +- damus/Core/Nostr/NostrRequest.swift | 12 + damus/Core/Nostr/NostrResponse.swift | 95 +++- damus/Core/Nostr/Relay.swift | 5 + damus/Core/Nostr/RelayConnection.swift | 166 ++++++- damus/Core/Nostr/RelayPool.swift | 133 ++++++ .../Onboarding/Views/SaveKeysView.swift | 4 + .../Utilities/AsyncStreamUtilities.swift | 42 ++ .../Utilities/NegentropyUtilities.swift | 14 + damusTests/LargeEventTests.swift | 4 +- damusTests/NegentropySupportTests.swift | 445 ++++++++++++++++++ nostrscript/NostrScript.swift | 4 + 14 files changed, 1034 insertions(+), 24 deletions(-) create mode 100644 damus/Shared/Utilities/AsyncStreamUtilities.swift create mode 100644 damus/Shared/Utilities/NegentropyUtilities.swift create mode 100644 damusTests/NegentropySupportTests.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 133ef7b7..228f8348 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -42,7 +42,6 @@ 3A92C1002DE16E9800CEEBAC /* FaviconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C0FD2DE16E9800CEEBAC /* FaviconCache.swift */; }; 3A92C1022DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C1012DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift */; }; 3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */; }; - D2585C7839C411EB3E0D79D6 /* RepostNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0A2B0F048CC8D494945E6 /* RepostNotificationTests.swift */; }; 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FE297E3D900090C62D /* RepostsView.swift */; }; 3AA24802297E3DC20090C62D /* RepostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA24801297E3DC20090C62D /* RepostView.swift */; }; 3AA2F4E82DF1467A00B18606 /* TrustedNetworkButtonTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA2F4E72DF1467A00B18606 /* TrustedNetworkButtonTip.swift */; }; @@ -67,6 +66,7 @@ 3ACF94482DAA006500971A4E /* NIP05DomainEventsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACF94452DAA006500971A4E /* NIP05DomainEventsModel.swift */; }; 3AE45AF6297BB2E700C1D842 /* LibreTranslateServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */; }; 3CCD1E6A2A874C4E0099A953 /* Nip98HTTPAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCD1E692A874C4E0099A953 /* Nip98HTTPAuth.swift */; }; + 3RRUR7Z6M0UHAOFZTGU9GRU0 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; }; 4C011B5E2BD0A56A002F2F9B /* ChatEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C011B5C2BD0A56A002F2F9B /* ChatEventView.swift */; }; 4C011B5F2BD0A56A002F2F9B /* ChatroomThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C011B5D2BD0A56A002F2F9B /* ChatroomThreadView.swift */; }; 4C011B612BD0B25C002F2F9B /* ReplyQuoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C011B602BD0B25C002F2F9B /* ReplyQuoteView.swift */; }; @@ -320,8 +320,6 @@ 4CA5588329F33F5B00DC6A45 /* StringCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA5588229F33F5B00DC6A45 /* StringCodable.swift */; }; 4CA9275D2A28FF630098A105 /* LongformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9275C2A28FF630098A105 /* LongformView.swift */; }; 4CA9275F2A2902B20098A105 /* LongformPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9275E2A2902B20098A105 /* LongformPreview.swift */; }; - 5C78A7912E30358100CF177D /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */; }; - 5C78A7952E30359100CF177D /* LongformMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */; }; 4CA927612A290E340098A105 /* EventShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA927602A290E340098A105 /* EventShell.swift */; }; 4CA927632A290EB10098A105 /* EventTop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA927622A290EB10098A105 /* EventTop.swift */; }; 4CA927652A290F1A0098A105 /* TimeDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA927642A290F1A0098A105 /* TimeDot.swift */; }; @@ -530,6 +528,12 @@ 5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */; }; 5C6E1DAF2A194075008FC15A /* PinkGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAE2A194075008FC15A /* PinkGradient.swift */; }; 5C7389B12B6EFA7100781E0A /* ProxyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B02B6EFA7100781E0A /* ProxyView.swift */; }; + 5C78A7912E30358100CF177D /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */; }; + 5C78A7922E30358200CF177D /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */; }; + 5C78A7932E30358300CF177D /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */; }; + 5C78A7952E30359100CF177D /* LongformMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */; }; + 5C78A7962E30359200CF177D /* LongformMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */; }; + 5C78A7972E30359300CF177D /* LongformMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */; }; 5C8498022D5D150000F74FEB /* ZapExplainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */; }; 5C8498032D5D150000F74FEB /* ZapExplainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */; }; 5C8498042D5D150000F74FEB /* ZapExplainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */; }; @@ -630,7 +634,6 @@ 7C60CAEF298471A1009C80D6 /* CoreSVG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */; }; 7C902AE32981D55B002AB16E /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */; }; 7C95CAEE299DCEF1009DCB67 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; }; - K9G5YXAZ4Y19GSLH8TWS8CO1 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; }; 7CFF6317299FEFE5005D382A /* SelectableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFF6316299FEFE5005D382A /* SelectableText.swift */; }; 82D6FA9A2CD9820500C925F4 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D6FA992CD9820500C925F4 /* ShareViewController.swift */; }; 82D6FAA12CD9820500C925F4 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 82D6FA972CD9820500C925F4 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -749,7 +752,6 @@ 82D6FB2F2CD99F7900C925F4 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */; }; 82D6FB302CD99F7900C925F4 /* PostBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F329D779B5005914DB /* PostBox.swift */; }; 82D6FB312CD99F7900C925F4 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; }; - FQ9UEENWE218BBQDVQXU3RA9 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; }; 82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; }; 82D6FB332CD99F7900C925F4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; }; 82D6FB342CD99F7900C925F4 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; }; @@ -1007,8 +1009,6 @@ 82D6FC3A2CD99F7900C925F4 /* WideEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6C29CD022E008DB934 /* WideEventView.swift */; }; 82D6FC3B2CD99F7900C925F4 /* LongformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9275C2A28FF630098A105 /* LongformView.swift */; }; 82D6FC3C2CD99F7900C925F4 /* LongformPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9275E2A2902B20098A105 /* LongformPreview.swift */; }; - 5C78A7922E30358200CF177D /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */; }; - 5C78A7962E30359200CF177D /* LongformMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */; }; 82D6FC3D2CD99F7900C925F4 /* EventShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA927602A290E340098A105 /* EventShell.swift */; }; 82D6FC3E2CD99F7900C925F4 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; }; 82D6FC3F2CD99F7900C925F4 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; }; @@ -1104,6 +1104,7 @@ BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; }; BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; }; D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; }; + D2585C7839C411EB3E0D79D6 /* RepostNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0A2B0F048CC8D494945E6 /* RepostNotificationTests.swift */; }; D5C1AFBF2E5DF7E60092F72F /* ContactCardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */; }; D5C1AFC02E5DF7E60092F72F /* ContactCardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */; }; D5C1AFC12E5DF7E60092F72F /* ContactCardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */; }; @@ -1367,7 +1368,6 @@ D73E5E632C6A97F4007EB227 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */; }; D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F329D779B5005914DB /* PostBox.swift */; }; D73E5E652C6A97F4007EB227 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; }; - 3RRUR7Z6M0UHAOFZTGU9GRU0 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; }; D73E5E662C6A97F4007EB227 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; }; D73E5E672C6A97F4007EB227 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; }; D73E5E682C6A97F4007EB227 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; }; @@ -1555,8 +1555,6 @@ D73E5F352C6A97F4007EB227 /* WideEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6C29CD022E008DB934 /* WideEventView.swift */; }; D73E5F362C6A97F4007EB227 /* LongformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9275C2A28FF630098A105 /* LongformView.swift */; }; D73E5F372C6A97F4007EB227 /* LongformPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9275E2A2902B20098A105 /* LongformPreview.swift */; }; - 5C78A7932E30358300CF177D /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */; }; - 5C78A7972E30359300CF177D /* LongformMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */; }; D73E5F382C6A97F4007EB227 /* EventShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA927602A290E340098A105 /* EventShell.swift */; }; D73E5F392C6A97F4007EB227 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; }; D73E5F3A2C6A97F4007EB227 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; }; @@ -1641,6 +1639,8 @@ D73E5F9D2C6AA8E3007EB227 /* SwipeActions in Frameworks */ = {isa = PBXBuildFile; productRef = D73E5F9C2C6AA8E3007EB227 /* SwipeActions */; }; D73E5F9E2C6AA9F7007EB227 /* nostrscript.c in Sources */ = {isa = PBXBuildFile; fileRef = 4C4F14A92A2A71AB0045A0B9 /* nostrscript.c */; }; D73FA9E12DDC12AA00C706E1 /* OnboardingContentSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73FA9E02DDC129E00C706E1 /* OnboardingContentSettings.swift */; }; + D74723EC2F15B0C3002DA12A /* NostrSDK in Frameworks */ = {isa = PBXBuildFile; productRef = D74723EB2F15B0C3002DA12A /* NostrSDK */; }; + D74723EE2F15B0DF002DA12A /* NegentropySupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74723ED2F15B0D6002DA12A /* NegentropySupportTests.swift */; }; D74AAFC22B153395006CF0F4 /* HeadlessDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */; }; D74AAFC32B153395006CF0F4 /* HeadlessDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */; }; D74AAFC52B1538DF006CF0F4 /* NotificationExtensionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */; }; @@ -1701,6 +1701,12 @@ D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; }; D773BC602C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; }; D77BFA0B2AE3051200621634 /* ProfileActionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */; }; + D77DA2C42F19CA48000B7093 /* AsyncStreamUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */; }; + D77DA2C52F19CA48000B7093 /* AsyncStreamUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */; }; + D77DA2C62F19CA48000B7093 /* AsyncStreamUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */; }; + D77DA2C82F19D480000B7093 /* NegentropyUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DA2C72F19D452000B7093 /* NegentropyUtilities.swift */; }; + D77DA2C92F19D480000B7093 /* NegentropyUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DA2C72F19D452000B7093 /* NegentropyUtilities.swift */; }; + D77DA2CA2F19D480000B7093 /* NegentropyUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DA2C72F19D452000B7093 /* NegentropyUtilities.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 */; }; @@ -1868,6 +1874,10 @@ D7E5B2D32EA0188200CF47AC /* StreamPipelineDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E5B2D22EA0187B00CF47AC /* StreamPipelineDiagnostics.swift */; }; D7E5B2D42EA0188200CF47AC /* StreamPipelineDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E5B2D22EA0187B00CF47AC /* StreamPipelineDiagnostics.swift */; }; D7E5B2D52EA0188200CF47AC /* StreamPipelineDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E5B2D22EA0187B00CF47AC /* StreamPipelineDiagnostics.swift */; }; + D7E5B2DB2EA080BE00CF47AC /* Negentropy in Frameworks */ = {isa = PBXBuildFile; productRef = D7E5B2DA2EA080BE00CF47AC /* Negentropy */; }; + D7E5B2DF2EA0A68600CF47AC /* Negentropy in Frameworks */ = {isa = PBXBuildFile; productRef = D7E5B2DE2EA0A68600CF47AC /* Negentropy */; }; + D7E5B2E12EA0A69200CF47AC /* Negentropy in Frameworks */ = {isa = PBXBuildFile; productRef = D7E5B2E02EA0A69200CF47AC /* Negentropy */; }; + D7E5B2E32EA0A69900CF47AC /* Negentropy in Frameworks */ = {isa = PBXBuildFile; productRef = D7E5B2E22EA0A69900CF47AC /* Negentropy */; }; D7EB00B02CD59C8D00660C07 /* PresentFullScreenItemNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */; }; D7EB00B12CD59C8D00660C07 /* PresentFullScreenItemNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */; }; D7EBF8BB2E59022A004EAE29 /* NostrNetworkManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EBF8BA2E5901F7004EAE29 /* NostrNetworkManagerTests.swift */; }; @@ -1940,6 +1950,8 @@ F7F0BA25297892BD009531F3 /* SwipeToDismiss.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7F0BA24297892BD009531F3 /* SwipeToDismiss.swift */; }; F7F0BA272978E54D009531F3 /* ParticipantsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7F0BA262978E54D009531F3 /* ParticipantsView.swift */; }; F944F56E29EA9CCC0067B3BF /* DamusParseContentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F944F56D29EA9CCC0067B3BF /* DamusParseContentTests.swift */; }; + FQ9UEENWE218BBQDVQXU3RA9 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; }; + K9G5YXAZ4Y19GSLH8TWS8CO1 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -2069,7 +2081,6 @@ 3A96D41B298DA94500388A2A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 3A96D41C298DA94500388A2A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = nl; path = nl.lproj/Localizable.stringsdict; sourceTree = ""; }; 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostedTests.swift; sourceTree = ""; }; - 64D0A2B0F048CC8D494945E6 /* RepostNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostNotificationTests.swift; sourceTree = ""; }; 3A994C4C2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = th; path = th.lproj/Localizable.stringsdict; sourceTree = ""; }; 3A994C4D2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = ""; }; 3A994C4E2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = ""; }; @@ -2424,8 +2435,6 @@ 4CA5588229F33F5B00DC6A45 /* StringCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringCodable.swift; sourceTree = ""; }; 4CA9275C2A28FF630098A105 /* LongformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformView.swift; sourceTree = ""; }; 4CA9275E2A2902B20098A105 /* LongformPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformPreview.swift; sourceTree = ""; }; - 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressBar.swift; sourceTree = ""; }; - 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformMarkdownView.swift; sourceTree = ""; }; 4CA927602A290E340098A105 /* EventShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventShell.swift; sourceTree = ""; }; 4CA927622A290EB10098A105 /* EventTop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTop.swift; sourceTree = ""; }; 4CA927642A290F1A0098A105 /* TimeDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeDot.swift; sourceTree = ""; }; @@ -2651,6 +2660,8 @@ 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientButtonStyle.swift; sourceTree = ""; }; 5C6E1DAE2A194075008FC15A /* PinkGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinkGradient.swift; sourceTree = ""; }; 5C7389B02B6EFA7100781E0A /* ProxyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyView.swift; sourceTree = ""; }; + 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressBar.swift; sourceTree = ""; }; + 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformMarkdownView.swift; sourceTree = ""; }; 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapExplainer.swift; sourceTree = ""; }; 5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingTimelineView.swift; sourceTree = ""; }; 5C8F97092EB45E85009399B1 /* LiveChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveChatModel.swift; sourceTree = ""; }; @@ -2691,13 +2702,13 @@ 6439E013296790CF0020672B /* ProfilePicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePicImageView.swift; sourceTree = ""; }; 643EA5C7296B764E005081BB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = ""; }; 647D9A8C2968520300A295DE /* SideMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuView.swift; sourceTree = ""; }; + 64D0A2B0F048CC8D494945E6 /* RepostNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostNotificationTests.swift; sourceTree = ""; }; 64FBD06E296255C400D9D3B2 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 7527271D2A93FF0100214108 /* Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Block.swift; sourceTree = ""; }; 75AD872A2AA23A460085EF2C /* Block+Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Block+Tests.swift"; sourceTree = ""; }; 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreSVG.swift; sourceTree = ""; }; 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = ""; }; 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KFOptionSetter+.swift"; sourceTree = ""; }; - BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherImageProvider.swift; sourceTree = ""; }; 7CFF6316299FEFE5005D382A /* SelectableText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableText.swift; sourceTree = ""; }; 82D6FA972CD9820500C925F4 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 82D6FA992CD9820500C925F4 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; @@ -2726,6 +2737,7 @@ BA3759962ABCCF360018D73B /* CameraPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = ""; }; BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = ""; }; BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = ""; }; + BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherImageProvider.swift; sourceTree = ""; }; D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManager.swift; sourceTree = ""; }; D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManagerMock.swift; sourceTree = ""; }; @@ -2782,6 +2794,7 @@ D73E5F7E2C6AA066007EB227 /* DamusAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAliases.swift; sourceTree = ""; }; D73E5F802C6AA07A007EB227 /* HighlighterExtensionAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlighterExtensionAliases.swift; sourceTree = ""; }; D73FA9E02DDC129E00C706E1 /* OnboardingContentSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingContentSettings.swift; sourceTree = ""; }; + D74723ED2F15B0D6002DA12A /* NegentropySupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NegentropySupportTests.swift; sourceTree = ""; }; D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessDamusState.swift; sourceTree = ""; }; D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationExtensionState.swift; sourceTree = ""; }; D74AAFCB2B155D07006CF0F4 /* MakeZapRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeZapRequest.swift; sourceTree = ""; }; @@ -2806,6 +2819,8 @@ D77135D22E7B766300E7639F /* DataExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = ""; }; D773BC5E2C6D538500349F0A /* CommentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentItem.swift; sourceTree = ""; }; D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = ""; }; + D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncStreamUtilities.swift; sourceTree = ""; }; + D77DA2C72F19D452000B7093 /* NegentropyUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NegentropyUtilities.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 = ""; }; @@ -2909,6 +2924,7 @@ D78DB8592C1CE9CA00F0AB12 /* SwipeActions in Frameworks */, 4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */, 4C27C9322A64766F007DBC75 /* MarkdownUI in Frameworks */, + D7E5B2DB2EA080BE00CF47AC /* Negentropy in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2918,6 +2934,7 @@ files = ( D7A343EE2AD0D77C00CED48B /* InlineSnapshotTesting in Frameworks */, D7A343F02AD0D77C00CED48B /* SnapshotTesting in Frameworks */, + D74723EC2F15B0C3002DA12A /* NostrSDK in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2941,6 +2958,7 @@ 82D6FC882CD9A4DE00C925F4 /* EmojiPicker in Frameworks */, 82D6FC842CD9A48500C925F4 /* Kingfisher in Frameworks */, 82D6FC812CD99FC500C925F4 /* secp256k1 in Frameworks */, + D7E5B2E32EA0A69900CF47AC /* Negentropy in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2954,6 +2972,7 @@ D7DB1FE82D5A9F5300CF06DA /* CryptoSwift in Frameworks */, D73E5F762C6A997E007EB227 /* EmojiPicker in Frameworks */, D703D7192C66E47100A400EA /* UniformTypeIdentifiers.framework in Frameworks */, + D7E5B2E12EA0A69200CF47AC /* Negentropy in Frameworks */, D7C48C0F2D12E35600A3BACF /* SwiftyCrop in Frameworks */, D703D7492C6709B100A400EA /* secp256k1 in Frameworks */, D70D909C2CDED7B200CD0534 /* CodeScanner in Frameworks */, @@ -2969,6 +2988,7 @@ D789D1202AFEFBF20083A7AB /* secp256k1 in Frameworks */, D7EDED312B1290B80018B19C /* MarkdownUI in Frameworks */, D7DB1FEA2D5A9F5A00CF06DA /* CryptoSwift in Frameworks */, + D7E5B2DF2EA0A68600CF47AC /* Negentropy in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3844,6 +3864,7 @@ 4CE6DEF627F7A08200C66700 /* damusTests */ = { isa = PBXGroup; children = ( + D74723ED2F15B0D6002DA12A /* NegentropySupportTests.swift */, D72734292F089EE600F90677 /* NdbMigrationTests.swift */, D72734272F08912F00F90677 /* DatabaseSnapshotManagerTests.swift */, D7100CB52EEA3E20008D94B7 /* AutoSaveViewModelTests.swift */, @@ -4847,6 +4868,8 @@ 5C78A7B82E3047DE00CF177D /* Utilities */ = { isa = PBXGroup; children = ( + D77DA2C72F19D452000B7093 /* NegentropyUtilities.swift */, + D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */, D7E5B2D22EA0187B00CF47AC /* StreamPipelineDiagnostics.swift */, D77135D22E7B766300E7639F /* DataExtensions.swift */, 4CF0ABEA29844B2F00D66079 /* AnyCodable */, @@ -5397,6 +5420,7 @@ D7C48C0A2D12DE0C00A3BACF /* SwiftyCrop */, D7DB1FE32D5A9AC900CF06DA /* CryptoSwift */, 3ACF94372DA9A52F00971A4E /* FaviconFinder */, + D7E5B2DA2EA080BE00CF47AC /* Negentropy */, ); productName = damus; productReference = 4CE6DEE327F7A08100C66700 /* damus.app */; @@ -5419,6 +5443,7 @@ packageProductDependencies = ( D7A343ED2AD0D77C00CED48B /* InlineSnapshotTesting */, D7A343EF2AD0D77C00CED48B /* SnapshotTesting */, + D74723EB2F15B0C3002DA12A /* NostrSDK */, ); productName = damusTests; productReference = 4CE6DEF327F7A08200C66700 /* damusTests.xctest */; @@ -5465,6 +5490,7 @@ D7C48C0C2D12E34900A3BACF /* SwiftyCrop */, D7DB1FEB2D5A9F6500CF06DA /* CryptoSwift */, 3ACF943F2DA9B11200971A4E /* FaviconFinder */, + D7E5B2E22EA0A69900CF47AC /* Negentropy */, ); productName = "share extension"; productReference = 82D6FA972CD9820500C925F4 /* ShareExtension.appex */; @@ -5495,6 +5521,7 @@ D7C48C0E2D12E35600A3BACF /* SwiftyCrop */, D7DB1FE72D5A9F5300CF06DA /* CryptoSwift */, 3ACF943D2DA9B10800971A4E /* FaviconFinder */, + D7E5B2E02EA0A69200CF47AC /* Negentropy */, ); productName = "highlighter action extension"; productReference = D703D7172C66E47100A400EA /* HighlighterActionExtension.appex */; @@ -5519,6 +5546,7 @@ D7EDED302B1290B80018B19C /* MarkdownUI */, D7DB1FE92D5A9F5A00CF06DA /* CryptoSwift */, 4C5726B92D72C6FA00E7FF82 /* Kingfisher */, + D7E5B2DE2EA0A68600CF47AC /* Negentropy */, ); productName = DamusNotificationService; productReference = D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */; @@ -5608,6 +5636,8 @@ D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */, D7DB1FE22D5A9AC900CF06DA /* XCRemoteSwiftPackageReference "CryptoSwift" */, 3ACF94362DA9A52F00971A4E /* XCRemoteSwiftPackageReference "FaviconFinder" */, + D7E5B2D92EA080BE00CF47AC /* XCRemoteSwiftPackageReference "negentropy-swift" */, + D74723EA2F15B0C3002DA12A /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */, ); productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */; projectDirPath = ""; @@ -6129,6 +6159,7 @@ 4CDA128C29EB19C40006FA5A /* LocalNotification.swift in Sources */, 4C3BEFD6281D995700B3DE84 /* ActionBarModel.swift in Sources */, 4C7D09762A0AF19E00943473 /* FillAndStroke.swift in Sources */, + D77DA2C92F19D480000B7093 /* NegentropyUtilities.swift in Sources */, D7E5B2D42EA0188200CF47AC /* StreamPipelineDiagnostics.swift in Sources */, 4CA927612A290E340098A105 /* EventShell.swift in Sources */, D74EC8502E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */, @@ -6224,6 +6255,7 @@ 5CB017232D2D985E00A9ED05 /* CoinosButton.swift in Sources */, 5C8F973B2EB4616D009399B1 /* LiveStreamTimeline.swift in Sources */, F7908E92298B0F0700AB113A /* RelayDetailView.swift in Sources */, + D77DA2C62F19CA48000B7093 /* AsyncStreamUtilities.swift in Sources */, 4CE879552996BAB900F758CC /* RelayPaidDetail.swift in Sources */, 5CB0172F2D42C76A00A9ED05 /* BalanceView.swift in Sources */, 5C8F971D2EB4607B009399B1 /* LiveEvent.swift in Sources */, @@ -6323,6 +6355,7 @@ 4C7D097E2A0C58B900943473 /* WalletConnectTests.swift in Sources */, 4CB883AA297612FF00DC99E7 /* ZapTests.swift in Sources */, D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */, + D74723EE2F15B0DF002DA12A /* NegentropySupportTests.swift in Sources */, 4CB8839A297322D200DC99E7 /* DMTests.swift in Sources */, D5C1AFC82E5E00690092F72F /* ContactCardManagerTests.swift in Sources */, D72734282F08914C00F90677 /* DatabaseSnapshotManagerTests.swift in Sources */, @@ -6429,6 +6462,7 @@ D5C1AFC52E5DFF700092F72F /* ContactCardManagerMock.swift in Sources */, 82D6FADE2CD99F7900C925F4 /* ThreadReply.swift in Sources */, 82D6FADF2CD99F7900C925F4 /* AttachedWalletNotify.swift in Sources */, + D77DA2C52F19CA48000B7093 /* AsyncStreamUtilities.swift in Sources */, 82D6FAE02CD99F7900C925F4 /* DisplayTabBarNotify.swift in Sources */, 82D6FAE12CD99F7900C925F4 /* BroadcastNotify.swift in Sources */, 82D6FAE22CD99F7900C925F4 /* ComposeNotify.swift in Sources */, @@ -6570,6 +6604,7 @@ 82D6FB522CD99F7900C925F4 /* Translator.swift in Sources */, 82D6FB532CD99F7900C925F4 /* Debouncer.swift in Sources */, 82D6FB542CD99F7900C925F4 /* EventHolder.swift in Sources */, + D77DA2C82F19D480000B7093 /* NegentropyUtilities.swift in Sources */, 82D6FB552CD99F7900C925F4 /* LocalizationUtil.swift in Sources */, 82D6FB562CD99F7900C925F4 /* EventCache.swift in Sources */, 82D6FB572CD99F7900C925F4 /* DisplayName.swift in Sources */, @@ -6988,6 +7023,7 @@ D73E5E522C6A97F4007EB227 /* UserView.swift in Sources */, D73E5E532C6A97F4007EB227 /* ZoomableScrollView.swift in Sources */, D73E5E542C6A97F4007EB227 /* NoteZapButton.swift in Sources */, + D77DA2CA2F19D480000B7093 /* NegentropyUtilities.swift in Sources */, D73E5E552C6A97F4007EB227 /* TranslateView.swift in Sources */, D73E5E562C6A97F4007EB227 /* SelectableText.swift in Sources */, D73E5E572C6A97F4007EB227 /* DamusColors.swift in Sources */, @@ -7120,6 +7156,7 @@ D73E5EB12C6A97F4007EB227 /* DamusCacheManager.swift in Sources */, D73E5EB22C6A97F4007EB227 /* NotificationsManager.swift in Sources */, D73E5EB32C6A97F4007EB227 /* Contacts+.swift in Sources */, + D77DA2C42F19CA48000B7093 /* AsyncStreamUtilities.swift in Sources */, D73E5EB42C6A97F4007EB227 /* NoteContent.swift in Sources */, D73E5EB52C6A97F4007EB227 /* LongformEvent.swift in Sources */, D73E5EB62C6A97F4007EB227 /* PushNotificationClient.swift in Sources */, @@ -8430,6 +8467,14 @@ revision = 9fa582f4b36c69c2a55bff5fb3377eb170ae273c; }; }; + D74723EA2F15B0C3002DA12A /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/rust-nostr/nostr-sdk-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.44.0; + }; + }; D78DB8572C1CE9CA00F0AB12 /* XCRemoteSwiftPackageReference "SwipeActions" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/damus-io/SwipeActions.git"; @@ -8462,6 +8507,14 @@ revision = e74bbbfbef939224b242ae7c342a90e60b88b5ce; }; }; + D7E5B2D92EA080BE00CF47AC /* XCRemoteSwiftPackageReference "negentropy-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/damus-io/negentropy-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -8575,6 +8628,11 @@ package = D78DB8572C1CE9CA00F0AB12 /* XCRemoteSwiftPackageReference "SwipeActions" */; productName = SwipeActions; }; + D74723EB2F15B0C3002DA12A /* NostrSDK */ = { + isa = XCSwiftPackageProductDependency; + package = D74723EA2F15B0C3002DA12A /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */; + productName = NostrSDK; + }; D789D11F2AFEFBF20083A7AB /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; @@ -8630,6 +8688,26 @@ package = D7DB1FE22D5A9AC900CF06DA /* XCRemoteSwiftPackageReference "CryptoSwift" */; productName = CryptoSwift; }; + D7E5B2DA2EA080BE00CF47AC /* Negentropy */ = { + isa = XCSwiftPackageProductDependency; + package = D7E5B2D92EA080BE00CF47AC /* XCRemoteSwiftPackageReference "negentropy-swift" */; + productName = Negentropy; + }; + D7E5B2DE2EA0A68600CF47AC /* Negentropy */ = { + isa = XCSwiftPackageProductDependency; + package = D7E5B2D92EA080BE00CF47AC /* XCRemoteSwiftPackageReference "negentropy-swift" */; + productName = Negentropy; + }; + D7E5B2E02EA0A69200CF47AC /* Negentropy */ = { + isa = XCSwiftPackageProductDependency; + package = D7E5B2D92EA080BE00CF47AC /* XCRemoteSwiftPackageReference "negentropy-swift" */; + productName = Negentropy; + }; + D7E5B2E22EA0A69900CF47AC /* Negentropy */ = { + isa = XCSwiftPackageProductDependency; + package = D7E5B2D92EA080BE00CF47AC /* XCRemoteSwiftPackageReference "negentropy-swift" */; + productName = Negentropy; + }; D7EDED242B117F7C0018B19C /* MarkdownUI */ = { isa = XCSwiftPackageProductDependency; package = 4C27C9302A64766F007DBC75 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; diff --git a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6de7b688..cc319010 100644 --- a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc", + "originHash" : "c718c1e7dcc1a07671694b2d7d7311e11804fbbaf22f4b81e49523a3df816ad6", "pins" : [ { "identity" : "codescanner", @@ -62,6 +62,24 @@ "version" : "8.3.1" } }, + { + "identity" : "negentropy-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/damus-io/negentropy-swift", + "state" : { + "revision" : "181789fb0842f5666020db87ffea0d120cc5aa5d", + "version" : "0.1.0" + } + }, + { + "identity" : "nostr-sdk-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rust-nostr/nostr-sdk-swift", + "state" : { + "revision" : "42fe7d379b326583ae8282a5fd7232745f195906", + "version" : "0.44.0" + } + }, { "identity" : "secp256k1.swift", "kind" : "remoteSourceControl", diff --git a/damus/Core/Nostr/NostrEvent.swift b/damus/Core/Nostr/NostrEvent.swift index ba68744f..9c6d04a9 100644 --- a/damus/Core/Nostr/NostrEvent.swift +++ b/damus/Core/Nostr/NostrEvent.swift @@ -331,11 +331,11 @@ func sign_id(privkey: String, id: String) -> String { } func decode_nostr_event(txt: String) -> NostrResponse? { - return NostrResponse.owned_from_json(json: txt) + return NostrResponse.decode(from: txt) } func decode_and_verify_nostr_response(txt: String) -> NostrResponse? { - guard let response = NostrResponse.owned_from_json(json: txt) else { return nil } + guard let response = NostrResponse.decode(from: txt) else { return nil } guard verify_nostr_response(response: response) == true else { return nil } return response } @@ -352,6 +352,10 @@ func verify_nostr_response(response: borrowing NostrResponse) -> Bool { return true case .auth(_): return true + case .negentropyError(subscriptionId: let subscriptionId, reasonCodeString: let reasonCodeString): + return true + case .negentropyMessage(subscriptionId: let subscriptionId, hexEncodedData: let hexEncodedData): + return true } } diff --git a/damus/Core/Nostr/NostrRequest.swift b/damus/Core/Nostr/NostrRequest.swift index d5554ad8..b8f790b5 100644 --- a/damus/Core/Nostr/NostrRequest.swift +++ b/damus/Core/Nostr/NostrRequest.swift @@ -48,6 +48,12 @@ enum NostrRequest { case event(NostrEvent) /// Authenticate with the relay case auth(NostrEvent) + /// Negentropy open + case negentropyOpen(subscriptionId: String, filter: NostrFilter, initialMessage: [UInt8]) + /// Negentropy message + case negentropyMessage(subscriptionId: String, message: [UInt8]) + /// Close negentropy communication + case negentropyClose(subscriptionId: String) /// Whether this request is meant to write data to a relay var is_write: Bool { @@ -60,6 +66,12 @@ enum NostrRequest { return true case .auth: return false + case .negentropyOpen: + return false + case .negentropyMessage: + return false + case .negentropyClose: + return false } } diff --git a/damus/Core/Nostr/NostrResponse.swift b/damus/Core/Nostr/NostrResponse.swift index ec5b3342..ca7f1625 100644 --- a/damus/Core/Nostr/NostrResponse.swift +++ b/damus/Core/Nostr/NostrResponse.swift @@ -18,6 +18,23 @@ enum MaybeResponse { case ok(NostrResponse) } +enum NegentropyResponse { + /// Negentropy error + case error(subscriptionId: String, reasonCodeString: String) + /// Negentropy message + case message(subscriptionId: String, data: [UInt8]) + /// Invalid negentropy message + case invalidResponse(subscriptionId: String) + + var subscriptionId: String { + switch self { + case .error(subscriptionId: let subscriptionId, reasonCodeString: let reasonCodeString): subscriptionId + case .message(subscriptionId: let subscriptionId, data: let data): subscriptionId + case .invalidResponse(subscriptionId: let subscriptionId): subscriptionId + } + } +} + enum NostrResponse { case event(String, NostrEvent) case notice(String) @@ -27,6 +44,10 @@ enum NostrResponse { /// /// The associated type of this case is the challenge string sent by the server. case auth(String) + /// Negentropy error + case negentropyError(subscriptionId: String, reasonCodeString: String) + /// Negentropy message + case negentropyMessage(subscriptionId: String, hexEncodedData: String) var subid: String? { switch self { @@ -36,14 +57,84 @@ enum NostrResponse { return sub_id case .eose(let sub_id): return sub_id - case .notice: + case .notice(_): return nil case .auth(let challenge_string): return challenge_string + case .negentropyError(subscriptionId: let subscriptionId, reasonCodeString: _): + return subscriptionId + case .negentropyMessage(subscriptionId: let subscriptionId, hexEncodedData: _): + return subscriptionId + } + } + + var negentropyResponse: NegentropyResponse? { + switch self { + case .event(_, _): return nil + case .notice(_): return nil + case .eose(_): return nil + case .ok(_): return nil + case .auth(_): return nil + case .negentropyError(subscriptionId: let subscriptionId, reasonCodeString: let reasonCodeString): + return .error(subscriptionId: subscriptionId, reasonCodeString: reasonCodeString) + case .negentropyMessage(subscriptionId: let subscriptionId, hexEncodedData: let hexData): + if let bytes = hex_decode(hexData) { + return .message(subscriptionId: subscriptionId, data: bytes) + } + return .invalidResponse(subscriptionId: subscriptionId) + } + } + + /// Decode a Nostr response from JSON using idiomatic Swift parsing + /// Supports NEG-MSG and NEG-ERR formats, falling back to C parsing for other message types + static func decode(from json: String) -> NostrResponse? { + // Try Swift-based parsing first for negentropy messages + if let response = try? decodeNegentropyMessage(from: json) { + return response + } + + // Fall back to C-based parsing for standard Nostr messages + return owned_from_json(json: json) + } + + /// Decode negentropy messages using idiomatic Swift + private static func decodeNegentropyMessage(from json: String) throws -> NostrResponse? { + guard let jsonData = json.data(using: .utf8) else { + return nil + } + + guard let jsonArray = try JSONSerialization.jsonObject(with: jsonData) as? [Any], + jsonArray.count >= 2, + let messageType = jsonArray[0] as? String else { + return nil + } + + switch messageType { + case "NEG-MSG": + // Format: ["NEG-MSG", "subscription-id", "hex-encoded-data"] + guard jsonArray.count == 3, + let subscriptionId = jsonArray[1] as? String, + let hexData = jsonArray[2] as? String else { + return nil + } + return .negentropyMessage(subscriptionId: subscriptionId, hexEncodedData: hexData) + + case "NEG-ERR": + // Format: ["NEG-ERR", "subscription-id", "reason-code"] + guard jsonArray.count == 3, + let subscriptionId = jsonArray[1] as? String, + let reasonCode = jsonArray[2] as? String else { + return nil + } + return .negentropyError(subscriptionId: subscriptionId, reasonCodeString: reasonCode) + + default: + // Not a negentropy message + return nil } } - static func owned_from_json(json: String) -> NostrResponse? { + private static func owned_from_json(json: String) -> NostrResponse? { return json.withCString{ cstr in let bufsize: Int = max(Int(Double(json.utf8.count) * 8.0), Int(getpagesize())) let data = malloc(bufsize) diff --git a/damus/Core/Nostr/Relay.swift b/damus/Core/Nostr/Relay.swift index f33d9c5e..8073d297 100644 --- a/damus/Core/Nostr/Relay.swift +++ b/damus/Core/Nostr/Relay.swift @@ -139,6 +139,11 @@ struct RelayMetadata: Codable { var is_paid: Bool { return limitation?.payment_required ?? false } + + var supports_negentropy: Bool? { + // Supports negentropy if NIP-77 is in the list of supported NIPs + supported_nips?.contains(where: { $0 == 77 }) + } } extension RelayPool { diff --git a/damus/Core/Nostr/RelayConnection.swift b/damus/Core/Nostr/RelayConnection.swift index 1581b018..94e1bb33 100644 --- a/damus/Core/Nostr/RelayConnection.swift +++ b/damus/Core/Nostr/RelayConnection.swift @@ -7,6 +7,7 @@ import Combine import Foundation +import Negentropy enum NostrConnectionEvent { /// Other non-message websocket events @@ -61,6 +62,16 @@ final class RelayConnection: ObservableObject { private var processEvent: (WebSocketEvent) -> () private let relay_url: RelayURL var log: RelayLog? + + /// The queue of WebSocket events to be processed + /// We need this queue to ensure events are processed and sent to RelayPool in the exact order in which they arrive. + /// See `processEventsTask()` for more information + var wsEventQueue: QueueableNotify + /// The task which will process WebSocket events in the order in which we receive them from the wire + var wsEventProcessTask: Task? + + @RelayPoolActor // Isolate this to a specific actor to avoid thread-satefy issues. + var negentropyStreams: [String: AsyncStream.Continuation] = [:] init(url: RelayURL, handleEvent: @escaping (NostrConnectionEvent) async -> (), @@ -69,6 +80,33 @@ final class RelayConnection: ObservableObject { self.relay_url = url self.handleEvent = handleEvent self.processEvent = processUnverifiedWSEvent + self.wsEventQueue = .init(maxQueueItems: 1000) + self.wsEventProcessTask = nil + self.wsEventProcessTask = Task { + try await self.processEventsTask() + } + } + + deinit { + self.wsEventProcessTask?.cancel() + } + + /// The task that will stream the queue of WebSocket events to be processed + /// We need this in order to ensure events are processed and sent to RelayPool in the exact order in which they arrive. + /// + /// We need this (or some equivalent syncing mechanism) because without it, two WebSocket events can be processed concurrently, + /// and sometimes sent in the wrong order due to difference in processing timing. + /// + /// For example, streaming a filter that yields 1 event can cause the EOSE signal to arrive in RelayPool before the event, simply because the event + /// takes longer to process compared to the EOSE signal. + /// + /// To prevent this, we send raw WebSocket events to this queue BEFORE any processing (to ensure equal timing), + /// and then process the queue in the order in which they appear + func processEventsTask() async throws { + for await item in await self.wsEventQueue.stream { + try Task.checkCancellation() + await self.receive(event: item) + } } func ping() { @@ -104,12 +142,12 @@ final class RelayConnection: ObservableObject { .sink { [weak self] completion in switch completion { case .failure(let error): - Task { await self?.receive(event: .error(error)) } + Task { await self?.wsEventQueue.add(item: .error(error)) } case .finished: - Task { await self?.receive(event: .disconnected(.normalClosure, nil)) } + Task { await self?.wsEventQueue.add(item: .disconnected(.normalClosure, nil)) } } } receiveValue: { [weak self] event in - Task { await self?.receive(event: event) } + Task { await self?.wsEventQueue.add(item: event) } } socket.connect() @@ -227,6 +265,9 @@ final class RelayConnection: ObservableObject { // we will not need to verify nostr events at this point. if let ev = decode_and_verify_nostr_response(txt: messageString) { await self.handleEvent(.nostr_event(ev)) + if let negentropyResponse = ev.negentropyResponse { + await self.negentropyStreams[negentropyResponse.subscriptionId]?.yield(negentropyResponse) + } return } print("failed to decode event \(messageString)") @@ -238,6 +279,94 @@ final class RelayConnection: ObservableObject { print("An unexpected URLSessionWebSocketTask.Message was received.") } } + + // MARK: - Negentropy logic + + /// Retrieves the IDs of events missing locally compared to the relay using negentropy protocol. + /// + /// - Parameters: + /// - filter: The Nostr filter to scope the sync + /// - negentropyVector: The local storage vector for comparison + /// - timeout: Optional timeout for the operation + /// - Returns: Array of IDs that the relay has but we don't + /// - Throws: NegentropySyncError on failure + @RelayPoolActor + func getMissingIds(filter: NostrFilter, negentropyVector: NegentropyStorageVector, timeout: Duration?) async throws -> [Id] { + if let relayMetadata = try? await fetch_relay_metadata(relay_id: self.relay_url), + let supportsNegentropy = relayMetadata.supports_negentropy { + if !supportsNegentropy { + // Throw an error if the relay specifically advertises that there is no support for negentropy + throw NegentropySyncError.notSupported + } + } + let timeout = timeout ?? .seconds(5) + let frameSizeLimit = 60_000 // Copied from rust-nostr project: Default frame limit is 128k. Halve that (hex encoding) and subtract a bit (JSON msg overhead) + try? negentropyVector.seal() // Error handling note: We do not care if it throws an `alreadySealed` error. As long as it is sealed in the end it is fine + let negentropyClient = try Negentropy(storage: negentropyVector, frameSizeLimit: frameSizeLimit) + let initialMessage = try negentropyClient.initiate() + let subscriptionId = UUID().uuidString + var allNeedIds: [Id] = [] + for await response in negentropyStream(subscriptionId: subscriptionId, filter: filter, initialMessage: initialMessage, timeoutDuration: timeout) { + switch response { + case .error(subscriptionId: _, reasonCodeString: let reasonCodeString): + throw NegentropySyncError.genericError(reasonCodeString) + case .message(subscriptionId: _, data: let data): + var haveIds: [Id] = [] + var needIds: [Id] = [] + let nextMessage = try negentropyClient.reconcile(data, haveIds: &haveIds, needIds: &needIds) + allNeedIds.append(contentsOf: needIds) + if let nextMessage { + self.send(.typical(.negentropyMessage(subscriptionId: subscriptionId, message: nextMessage))) + } + else { + // Reconciliation is complete + return allNeedIds + } + case .invalidResponse(subscriptionId: _): + throw NegentropySyncError.relayError + } + } + // If the stream completes without a response, throw a timeout/relay error + throw NegentropySyncError.relayError + } + + enum NegentropySyncError: Error { + /// Fallback generic error + case genericError(String) + /// Negentropy is not supported by the relay + case notSupported + /// Something went wrong with the relay communication during negentropy sync + case relayError + } + + @RelayPoolActor + private func negentropyStream(subscriptionId: String, filter: NostrFilter, initialMessage: [UInt8], timeoutDuration: Duration? = nil) -> AsyncStream { + return AsyncStream { continuation in + self.negentropyStreams[subscriptionId] = continuation + let nostrRequest: NostrRequest = .negentropyOpen(subscriptionId: subscriptionId, filter: filter, initialMessage: initialMessage) + self.send(.typical(nostrRequest)) + let timeoutTask = Task { + if let timeoutDuration { + try Task.checkCancellation() + try await Task.sleep(for: timeoutDuration) + try Task.checkCancellation() + continuation.finish() + } + } + continuation.onTermination = { @Sendable _ in + Task { + await self.removeNegentropyStream(id: subscriptionId) + self.send(.typical(.negentropyClose(subscriptionId: subscriptionId))) + } + timeoutTask.cancel() + } + } + } + + @RelayPoolActor + private func removeNegentropyStream(id: String) { + self.negentropyStreams[id] = nil + } } func make_nostr_req(_ req: NostrRequest) -> String? { @@ -250,6 +379,12 @@ func make_nostr_req(_ req: NostrRequest) -> String? { return make_nostr_push_event(ev: ev) case .auth(let ev): return make_nostr_auth_event(ev: ev) + case .negentropyOpen(subscriptionId: let subscriptionId, filter: let filter, initialMessage: let initialMessage): + return make_nostr_negentropy_open_req(subscriptionId: subscriptionId, filter: filter, initialMessage: initialMessage) + case .negentropyMessage(subscriptionId: let subscriptionId, message: let message): + return make_nostr_negentropy_message_req(subscriptionId: subscriptionId, message: message) + case .negentropyClose(subscriptionId: let subscriptionId): + return make_nostr_negentropy_close_req(subscriptionId: subscriptionId) } } @@ -289,3 +424,28 @@ func make_nostr_subscription_req(_ filters: [NostrFilter], sub_id: String) -> St req += "]" return req } + +func make_nostr_negentropy_open_req(subscriptionId: String, filter: NostrFilter, initialMessage: [UInt8]) -> String? { + let encoder = JSONEncoder() + let messageData = Data(initialMessage) + let messageHex = hex_encode(messageData) + var req = "[\"NEG-OPEN\",\"\(subscriptionId)\"," + guard let filter_json = try? encoder.encode(filter) else { + return nil + } + let filter_json_str = String(decoding: filter_json, as: UTF8.self) + req += filter_json_str + req += ",\"\(messageHex)\"" + req += "]" + return req +} + +func make_nostr_negentropy_message_req(subscriptionId: String, message: [UInt8]) -> String? { + let messageData = Data(message) + let messageHex = hex_encode(messageData) + return "[\"NEG-MSG\",\"\(subscriptionId)\",\"\(messageHex)\"]" +} + +func make_nostr_negentropy_close_req(subscriptionId: String) -> String? { + return "[\"NEG-CLOSE\",\"\(subscriptionId)\"]" +} diff --git a/damus/Core/Nostr/RelayPool.swift b/damus/Core/Nostr/RelayPool.swift index be8e3c80..0ea5f690 100644 --- a/damus/Core/Nostr/RelayPool.swift +++ b/damus/Core/Nostr/RelayPool.swift @@ -7,6 +7,7 @@ import Foundation import Network +import Negentropy struct RelayHandler { let sub_id: String @@ -269,6 +270,12 @@ class RelayPool { return true case .auth(_): return true + case .negentropyOpen(subscriptionId: _, filter: _, initialMessage: _): + return false // Do not persist negentropy requests across sessions + case .negentropyMessage(subscriptionId: _, message: _): + return false // Do not persist negentropy requests across sessions + case .negentropyClose(subscriptionId: _): + return false // Do not persist negentropy requests across sessions } } } @@ -339,6 +346,8 @@ class RelayPool { } case .ok(_): break // No need to handle this, we are not sending an event to the relay case .auth(_): break // Handled in a separate function in RelayPool + case .negentropyError(subscriptionId: _, reasonCodeString: _): break // Not handled in regular subscriptions + case .negentropyMessage(subscriptionId: _, hexEncodedData: _): break // Not handled in regular subscriptions } } } @@ -366,6 +375,21 @@ class RelayPool { } } + /// This streams events that are pre-existing on the relay, and stops streaming as soon as it receives the EOSE signal. + func subscribeExistingItems(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil, id: UUID? = nil) -> AsyncStream { + return AsyncStream.with(task: { continuation in + outerLoop: for await item in await self.subscribe(filters: filters, to: desiredRelays, eoseTimeout: eoseTimeout, id: id) { + if Task.isCancelled { return } + switch item { + case .event(let event): + continuation.yield(event) + case .eose: + break outerLoop + } + } + }) + } + enum StreamItem { /// A Nostr event case event(NostrEvent) @@ -551,6 +575,115 @@ class RelayPool { handler.handler.yield((relay_id, event)) } } + + // MARK: - Negentropy + + /// This streams items in the following fashion: + /// 1. Performs a negentropy sync, sending missing notes to the stream + /// 2. Send EOSE to signal end of syncing + /// 3. Stream new notes + func negentropySubscribe( + filters: [NostrFilter], + to desiredRelayURLs: [RelayURL]? = nil, + negentropyVector: NegentropyStorageVector, + eoseTimeout: Duration? = nil, + id: UUID? = nil, + ignoreUnsupportedRelays: Bool + ) async throws -> AsyncThrowingStream { + return AsyncThrowingStream.with(task: { continuation in + // 1. Mark the time when we begin negentropy syncing + let negentropyStartTimestamp = UInt32(Date().timeIntervalSince1970) + // 2. Negentropy sync missing notes and send the missing notes over + for try await event in try await self.negentropySync(filters: filters, to: desiredRelayURLs, negentropyVector: negentropyVector, ignoreUnsupportedRelays: ignoreUnsupportedRelays) { + continuation.yield(.event(event)) + } + // 3. When syncing is done, send the EOSE signal + continuation.yield(.eose) + // 3. Stream new notes that match the filter + let updatedFilters = filters.map({ filter in + var newFilter = filter + newFilter.since = negentropyStartTimestamp + return newFilter + }) + for await item in await self.subscribe(filters: updatedFilters, to: desiredRelayURLs, eoseTimeout: eoseTimeout, id: id) { + try Task.checkCancellation() + switch item { + case .event(let nostrEvent): + continuation.yield(.event(nostrEvent)) + case .eose: + continue // We already sent the EOSE signal after negentropy sync, ignore this redundant EOSE + } + } + }) + } + + /// This performs a negentropy syncing with various relays and various filters and sends missing notes over an async stream + func negentropySync( + filters: [NostrFilter], + to desiredRelayURLs: [RelayURL]? = nil, + negentropyVector: NegentropyStorageVector, + eoseTimeout: Duration? = nil, + ignoreUnsupportedRelays: Bool + ) async throws -> AsyncThrowingStream { + return AsyncThrowingStream.with(task: { continuation in + for filter in filters { + try Task.checkCancellation() + for try await event in try await self.negentropySync(filter: filter, to: desiredRelayURLs, negentropyVector: negentropyVector, eoseTimeout: eoseTimeout, ignoreUnsupportedRelays: ignoreUnsupportedRelays) { + try Task.checkCancellation() + continuation.yield(event) + // Note: Negentropy vector already updated by the underlying stream, since it is a reference type + try Task.checkCancellation() + } + } + }) + } + + /// This performs a negentropy syncing with various relays and sends missing notes over an async stream + func negentropySync( + filter: NostrFilter, + to desiredRelayURLs: [RelayURL]? = nil, + negentropyVector: NegentropyStorageVector, + eoseTimeout: Duration? = nil, + ignoreUnsupportedRelays: Bool + ) async throws -> AsyncThrowingStream { + return AsyncThrowingStream.with(task: { continuation in + let desiredRelays = await self.getRelays(targetRelays: desiredRelayURLs) + for desiredRelay in desiredRelays { + try Task.checkCancellation() + do { + for try await event in try await self.negentropySync(filter: filter, to: desiredRelay, negentropyVector: negentropyVector, eoseTimeout: eoseTimeout) { + try Task.checkCancellation() + continuation.yield(event) + // Add to our negentropy vector so that we don't need to receive it from the next relay! + negentropyVector.unseal() + try negentropyVector.insert(nostrEvent: event) + try Task.checkCancellation() + } + } + catch { + if let negentropyError = error as? RelayConnection.NegentropySyncError, + case .notSupported = negentropyError, + ignoreUnsupportedRelays { + // Do not throw error, ignore the relays that do not support negentropy + } + else { + throw error + } + } + } + }) + } + + /// This performs a negentropy syncing with one relay and sends missing notes over an async stream + func negentropySync(filter: NostrFilter, to desiredRelay: Relay, negentropyVector: NegentropyStorageVector, eoseTimeout: Duration? = nil) async throws -> AsyncThrowingStream { + return AsyncThrowingStream.with(task: { streamContinuation in + let missingIds = try await desiredRelay.connection.getMissingIds(filter: filter, negentropyVector: negentropyVector, timeout: eoseTimeout) + let missingIdsFilter = NostrFilter(ids: missingIds.map { NoteId($0.toData()) }) + for await event in self.subscribeExistingItems(filters: [missingIdsFilter], to: [desiredRelay.descriptor.url], eoseTimeout: eoseTimeout) { + streamContinuation.yield(event) + } + }) + } } func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) async { diff --git a/damus/Features/Onboarding/Views/SaveKeysView.swift b/damus/Features/Onboarding/Views/SaveKeysView.swift index 468b4c16..8aeb6ee0 100644 --- a/damus/Features/Onboarding/Views/SaveKeysView.swift +++ b/damus/Features/Onboarding/Views/SaveKeysView.swift @@ -217,6 +217,10 @@ struct SaveKeysView: View { break case .auth: break + case .negentropyError: + break // Not relevant during signup + case .negentropyMessage: + break // Not relevant during signup } } } diff --git a/damus/Shared/Utilities/AsyncStreamUtilities.swift b/damus/Shared/Utilities/AsyncStreamUtilities.swift new file mode 100644 index 00000000..11fbebff --- /dev/null +++ b/damus/Shared/Utilities/AsyncStreamUtilities.swift @@ -0,0 +1,42 @@ +// +// AsyncStreamUtilities.swift +// damus +// +// Created by Daniel D'Aquino on 2026-01-15. +// + + +extension AsyncThrowingStream where Failure == any Error { + /// Convenience initializer for an async throwing stream that uses an async task to stream items + static func with(task: @escaping (_ continuation: AsyncThrowingStream.Continuation) async throws -> Void) -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + let streamTask = Task { + do { + try await task(continuation) + } + catch { + continuation.finish(throwing: error) + } + continuation.finish() + } + continuation.onTermination = { @Sendable _ in + streamTask.cancel() + } + } + } +} + +extension AsyncStream { + /// Convenience initializer for an async stream that uses an async task to stream items + static func with(task: @escaping (_ continuation: AsyncStream.Continuation) async -> Void) -> AsyncStream { + return AsyncStream { continuation in + let streamTask = Task { + await task(continuation) + continuation.finish() + } + continuation.onTermination = { @Sendable _ in + streamTask.cancel() + } + } + } +} diff --git a/damus/Shared/Utilities/NegentropyUtilities.swift b/damus/Shared/Utilities/NegentropyUtilities.swift new file mode 100644 index 00000000..2a4c7b35 --- /dev/null +++ b/damus/Shared/Utilities/NegentropyUtilities.swift @@ -0,0 +1,14 @@ +// +// NegentropyUtilities.swift +// damus +// +// Created by Daniel D'Aquino on 2026-01-15. +// + +import Negentropy + +extension NegentropyStorageVector { + func insert(nostrEvent: NostrEvent) throws { + try self.insert(timestamp: UInt64(nostrEvent.created_at), id: Id(data: nostrEvent.id.id)) + } +} diff --git a/damusTests/LargeEventTests.swift b/damusTests/LargeEventTests.swift index 16ab63e1..c3172d0e 100644 --- a/damusTests/LargeEventTests.swift +++ b/damusTests/LargeEventTests.swift @@ -12,7 +12,7 @@ final class LargeEventTests: XCTestCase { func testLongPost() async throws { let json = "[\"EVENT\",\"subid\",\(test_failing_nostr_report)]" - let resp = NostrResponse.owned_from_json(json: json) + let resp = NostrResponse.decode(from: json) XCTAssertNotNil(resp) guard let resp, @@ -32,7 +32,7 @@ final class LargeEventTests: XCTestCase { func testIsHellthread() throws { let json = "[\"EVENT\",\"subid\",\(test_failing_nostr_report)]" - let resp = NostrResponse.owned_from_json(json: json) + let resp = NostrResponse.decode(from: json) XCTAssertNotNil(resp) guard let resp, diff --git a/damusTests/NegentropySupportTests.swift b/damusTests/NegentropySupportTests.swift new file mode 100644 index 00000000..14ab8b79 --- /dev/null +++ b/damusTests/NegentropySupportTests.swift @@ -0,0 +1,445 @@ +// +// NegentropySupportTests.swift +// damus +// +// Created by Daniel D’Aquino on 2026-01-12. +// + +import XCTest +import NostrSDK +import Negentropy +@testable import damus + +final class NegentropySupportTests: XCTestCase { + + // MARK: - Helper Functions + + /// Creates and runs a local relay on the specified port. + /// - Parameter port: The port number to run the relay on + /// - Returns: The running LocalRelay instance + private func setupRelay(port: UInt16) async throws -> LocalRelay { + let builder = RelayBuilder().port(port: port) + let relay = LocalRelay(builder: builder) + try await relay.run() + print("Relay url: \(await relay.url())") + return relay + } + + /// Connects to a relay and waits for the connection to be established. + /// - Parameters: + /// - url: The relay URL to connect to + /// - label: Optional label for logging (e.g., "Relay1", "Relay2") + /// - Returns: The connected RelayConnection instance + private func connectToRelay(url: RelayURL, label: String = "") async -> RelayConnection { + var connectionContinuation: CheckedContinuation? + + let relayConnection = RelayConnection(url: url, handleEvent: { _ in }, processUnverifiedWSEvent: { wsEvent in + let prefix = label.isEmpty ? "" : "(\(label)) " + switch wsEvent { + case .connected: + connectionContinuation?.resume() + case .message(let message): + print("NEGENTROPY_SUPPORT_TEST \(prefix): Received: \(message)") + case .disconnected(let closeCode, let string): + print("NEGENTROPY_SUPPORT_TEST \(prefix): Disconnected: \(closeCode); \(String(describing: string))") + case .error(let error): + print("NEGENTROPY_SUPPORT_TEST \(prefix): Received error: \(error)") + } + }) + relayConnection.connect() + + // Wait for connection to be established + await withCheckedContinuation { continuation in + connectionContinuation = continuation + } + + return relayConnection + } + + /// Sends events to a relay connection. + /// - Parameters: + /// - events: Array of NostrEvent to send + /// - connection: The RelayConnection to send events through + private func sendEvents(_ events: [NostrEvent], to connection: RelayConnection) { + for event in events { + connection.send(.typical(.event(event))) + } + } + + /// Sets up a relay pool with the specified relay URLs. + /// - Parameter urls: Array of RelayURL to add to the pool + /// - Returns: Configured and connected RelayPool + private func setupRelayPool(with urls: [RelayURL]) async throws -> RelayPool { + let relayPool = RelayPool(ndb: await test_damus_state.ndb) + + for url in urls { + try await relayPool.add_relay(.init(url: url, info: .readWrite)) + } + + await relayPool.connect() + // Wait for relay pool to be ready. + // It's generally not a good idea to hard code delays but RelayPool does not seem to provide any way to await for the connection to fully go through, + // or that mechanism is not well documented. + try await Task.sleep(for: .seconds(2)) + + return relayPool + } + + /// Runs a negentropy subscribe operation and fulfills expectations based on received events. + /// - Parameters: + /// - relayPool: The relay pool to subscribe through + /// - filters: The NostrFilters to apply + /// - vector: The NegentropyStorageVector representing local state + /// - eventExpectations: Dictionary mapping event IDs to their expectations + /// - ignoreUnsupportedRelays: Whether to ignore relays that don't support negentropy + private func runNegentropySubscribe( + relayPool: RelayPool, + filters: [NostrFilter], + vector: NegentropyStorageVector, + eventExpectations: [NoteId: XCTestExpectation], + ignoreUnsupportedRelays: Bool = false + ) { + Task { + do { + for try await item in try await relayPool.negentropySubscribe( + filters: filters, + negentropyVector: vector, + ignoreUnsupportedRelays: ignoreUnsupportedRelays + ) { + switch item { + case .event(let event): + if let expectation = eventExpectations[event.id] { + expectation.fulfill() + } + case .eose: + return + } + } + } + catch { + XCTFail("Stream Error: \(error)") + } + } + } + + // MARK: - Test Cases + + func testBasic() async throws { + // Given: A relay with noteA and noteB, and local storage has noteA + let relay = try await setupRelay(port: 8080) // Do not discard the result to avoid relay from being garbage collected and shutdown + let relayUrl = RelayURL(await relay.url().description)! + + let noteA = NostrEvent(content: "A", keypair: test_keypair)! + let noteB = NostrEvent(content: "B", keypair: test_keypair)! + + let relayConnection = await connectToRelay(url: relayUrl) + sendEvents([noteA, noteB], to: relayConnection) + + let relayPool = try await setupRelayPool(with: [relayUrl]) + + let negentropyVector = NegentropyStorageVector() + try negentropyVector.insert(nostrEvent: noteA) + + let getsNoteB = XCTestExpectation(description: "Gets note B") + let doesNotGetNoteA = XCTestExpectation(description: "Does not get note A") + doesNotGetNoteA.isInverted = true + + // When: Performing negentropy subscribe + runNegentropySubscribe( + relayPool: relayPool, + filters: [NostrFilter(kinds: [.text])], + vector: negentropyVector, + eventExpectations: [noteA.id: doesNotGetNoteA, noteB.id: getsNoteB] + ) + + // Then: Should receive only noteB (noteA is already synced) + await fulfillment(of: [getsNoteB, doesNotGetNoteA], timeout: 5.0) + } + + func testEmptyLocalStorage() async throws { + // Given: A relay with noteA and noteB, and empty local storage + let relay = try await setupRelay(port: 8081) + let relayUrl = RelayURL(await relay.url().description)! + + let noteA = NostrEvent(content: "A", keypair: test_keypair)! + let noteB = NostrEvent(content: "B", keypair: test_keypair)! + + let relayConnection = await connectToRelay(url: relayUrl) + sendEvents([noteA, noteB], to: relayConnection) + + let relayPool = try await setupRelayPool(with: [relayUrl]) + + // Empty negentropy vector - should receive all events + let negentropyVector = NegentropyStorageVector() + + let getsNoteA = XCTestExpectation(description: "Gets note A") + let getsNoteB = XCTestExpectation(description: "Gets note B") + + // When: Performing negentropy subscribe with empty local storage + runNegentropySubscribe( + relayPool: relayPool, + filters: [NostrFilter(kinds: [.text])], + vector: negentropyVector, + eventExpectations: [noteA.id: getsNoteA, noteB.id: getsNoteB] + ) + + // Then: Should receive all events (noteA and noteB) + await fulfillment(of: [getsNoteA, getsNoteB], timeout: 5.0) + } + + /// Test negentropy sync with two relays having overlapping events. + /// Relay1 has noteA+noteB, Relay2 has noteB+noteC, local has noteB. + /// Should get noteA from Relay1 and noteC from Relay2 (deduplicating noteB). + func testTwoRelaysWithOverlap() async throws { + // Given: Two relays with overlapping events and local storage has noteB + let relay1 = try await setupRelay(port: 8082) + let relay2 = try await setupRelay(port: 8083) + + let relayUrl1 = RelayURL(await relay1.url().description)! + let relayUrl2 = RelayURL(await relay2.url().description)! + + let noteA = NostrEvent(content: "A", keypair: test_keypair)! + let noteB = NostrEvent(content: "B", keypair: test_keypair)! + let noteC = NostrEvent(content: "C", keypair: test_keypair)! + + // Connect to relay1 and send noteA + noteB + let relayConnection1 = await connectToRelay(url: relayUrl1, label: "Relay1") + sendEvents([noteA, noteB], to: relayConnection1) + + // Connect to relay2 and send noteB + noteC + let relayConnection2 = await connectToRelay(url: relayUrl2, label: "Relay2") + sendEvents([noteB, noteC], to: relayConnection2) + + let relayPool = try await setupRelayPool(with: [relayUrl1, relayUrl2]) + + // Local vector has noteB already + let negentropyVector = NegentropyStorageVector() + try negentropyVector.insert(nostrEvent: noteB) + + let getsNoteA = XCTestExpectation(description: "Gets note A") + let getsNoteC = XCTestExpectation(description: "Gets note C") + let doesNotGetNoteB = XCTestExpectation(description: "Does not get note B") + doesNotGetNoteB.isInverted = true + + // When: Performing negentropy subscribe across two relays + runNegentropySubscribe( + relayPool: relayPool, + filters: [NostrFilter(kinds: [.text])], + vector: negentropyVector, + eventExpectations: [noteA.id: getsNoteA, noteB.id: doesNotGetNoteB, noteC.id: getsNoteC] + ) + + // Then: Should receive noteA and noteC, but not noteB (already synced) + await fulfillment(of: [getsNoteA, getsNoteC, doesNotGetNoteB], timeout: 5.0) + } + + /// Test negentropy sync when all events are already synced locally. + /// Local has noteA+noteB, relay has noteA+noteB. + /// Should receive EOSE only without any events. + func testAllEventsSynced() async throws { + // Given: A relay with noteA and noteB, and local storage has both events + let relay = try await setupRelay(port: 8084) + let relayUrl = RelayURL(await relay.url().description)! + + let noteA = NostrEvent(content: "A", keypair: test_keypair)! + let noteB = NostrEvent(content: "B", keypair: test_keypair)! + + let relayConnection = await connectToRelay(url: relayUrl) + sendEvents([noteA, noteB], to: relayConnection) + + let relayPool = try await setupRelayPool(with: [relayUrl]) + + // Local vector has both events already + let negentropyVector = NegentropyStorageVector() + try negentropyVector.insert(nostrEvent: noteA) + try negentropyVector.insert(nostrEvent: noteB) + + let doesNotGetNoteA = XCTestExpectation(description: "Does not get note A") + let doesNotGetNoteB = XCTestExpectation(description: "Does not get note B") + doesNotGetNoteA.isInverted = true + doesNotGetNoteB.isInverted = true + + // When: Performing negentropy subscribe with all events already synced + runNegentropySubscribe( + relayPool: relayPool, + filters: [NostrFilter(kinds: [.text])], + vector: negentropyVector, + eventExpectations: [noteA.id: doesNotGetNoteA, noteB.id: doesNotGetNoteB] + ) + + // Then: Should not receive any events (all already synced) + await fulfillment(of: [doesNotGetNoteA, doesNotGetNoteB], timeout: 5.0) + } + + /// Test negentropy sync when local storage is a superset of relay events. + /// Local has noteA+noteB+noteC, relay has noteA+noteB. + /// Should receive no new events. + func testRelaySubset() async throws { + // Given: A relay with noteA and noteB, and local storage has noteA, noteB, and noteC + let relay = try await setupRelay(port: 8085) + let relayUrl = RelayURL(await relay.url().description)! + + let noteA = NostrEvent(content: "A", keypair: test_keypair)! + let noteB = NostrEvent(content: "B", keypair: test_keypair)! + let noteC = NostrEvent(content: "C", keypair: test_keypair)! + + let relayConnection = await connectToRelay(url: relayUrl) + sendEvents([noteA, noteB], to: relayConnection) + + let relayPool = try await setupRelayPool(with: [relayUrl]) + + // Local vector has all relay events plus one more + let negentropyVector = NegentropyStorageVector() + try negentropyVector.insert(nostrEvent: noteA) + try negentropyVector.insert(nostrEvent: noteB) + try negentropyVector.insert(nostrEvent: noteC) + + let doesNotGetNoteA = XCTestExpectation(description: "Does not get note A") + let doesNotGetNoteB = XCTestExpectation(description: "Does not get note B") + let doesNotGetNoteC = XCTestExpectation(description: "Does not get note C") + doesNotGetNoteA.isInverted = true + doesNotGetNoteB.isInverted = true + doesNotGetNoteC.isInverted = true + + // When: Performing negentropy subscribe where local is a superset of relay + runNegentropySubscribe( + relayPool: relayPool, + filters: [NostrFilter(kinds: [.text])], + vector: negentropyVector, + eventExpectations: [noteA.id: doesNotGetNoteA, noteB.id: doesNotGetNoteB, noteC.id: doesNotGetNoteC] + ) + + // Then: Should not receive any events (local has all relay events and more) + await fulfillment(of: [doesNotGetNoteA, doesNotGetNoteB, doesNotGetNoteC], timeout: 5.0) + } + + /// Test negentropy sync with three relays having overlapping events and partial local sync. + /// Relay1 has A+B, Relay2 has B+C, Relay3 has C+D, local has A+C. + /// Should only receive B and D. + func testThreeRelaysPartialSync() async throws { + // Given: Three relays with overlapping events and local storage has noteA and noteC + let relay1 = try await setupRelay(port: 8086) + let relay2 = try await setupRelay(port: 8087) + let relay3 = try await setupRelay(port: 8088) + + let relayUrl1 = RelayURL(await relay1.url().description)! + let relayUrl2 = RelayURL(await relay2.url().description)! + let relayUrl3 = RelayURL(await relay3.url().description)! + + let noteA = NostrEvent(content: "A", keypair: test_keypair)! + let noteB = NostrEvent(content: "B", keypair: test_keypair)! + let noteC = NostrEvent(content: "C", keypair: test_keypair)! + let noteD = NostrEvent(content: "D", keypair: test_keypair)! + + // Connect to relay1 and send noteA + noteB + let relayConnection1 = await connectToRelay(url: relayUrl1, label: "Relay1") + sendEvents([noteA, noteB], to: relayConnection1) + + // Connect to relay2 and send noteB + noteC + let relayConnection2 = await connectToRelay(url: relayUrl2, label: "Relay2") + sendEvents([noteB, noteC], to: relayConnection2) + + // Connect to relay3 and send noteC + noteD + let relayConnection3 = await connectToRelay(url: relayUrl3, label: "Relay3") + sendEvents([noteC, noteD], to: relayConnection3) + + let relayPool = try await setupRelayPool(with: [relayUrl1, relayUrl2, relayUrl3]) + + // Local vector has noteA and noteC already + let negentropyVector = NegentropyStorageVector() + try negentropyVector.insert(nostrEvent: noteA) + try negentropyVector.insert(nostrEvent: noteC) + + let getsNoteB = XCTestExpectation(description: "Gets note B") + let getsNoteD = XCTestExpectation(description: "Gets note D") + let doesNotGetNoteA = XCTestExpectation(description: "Does not get note A") + let doesNotGetNoteC = XCTestExpectation(description: "Does not get note C") + doesNotGetNoteA.isInverted = true + doesNotGetNoteC.isInverted = true + + // When: Performing negentropy subscribe across three relays with partial overlap + runNegentropySubscribe( + relayPool: relayPool, + filters: [NostrFilter(kinds: [.text])], + vector: negentropyVector, + eventExpectations: [ + noteA.id: doesNotGetNoteA, + noteB.id: getsNoteB, + noteC.id: doesNotGetNoteC, + noteD.id: getsNoteD + ] + ) + + // Then: Should receive only noteB and noteD (noteA and noteC already synced) + await fulfillment(of: [getsNoteB, getsNoteD, doesNotGetNoteA, doesNotGetNoteC], timeout: 5.0) + } + + /// Test negentropy sync with multiple filters for different event kinds across three relays. + /// Relay1 has text notes A+B (kind 1), Relay2 has text B + DM C (kind 4), Relay3 has DMs C+D (kind 4). + /// Local has text note A (kind 1) and DM C (kind 4). + /// Uses two filters: one for kind 1 (text), one for kind 4 (DMs). + /// Should only receive text note B and DM D. + func testMultipleFiltersWithDifferentKinds() async throws { + // Given: Three relays with mixed event kinds and local storage has text note A and DM C + let relay1 = try await setupRelay(port: 8089) + let relay2 = try await setupRelay(port: 8090) + let relay3 = try await setupRelay(port: 8091) + + let relayUrl1 = RelayURL(await relay1.url().description)! + let relayUrl2 = RelayURL(await relay2.url().description)! + let relayUrl3 = RelayURL(await relay3.url().description)! + + // Create events with different kinds + // kind 1 = text notes, kind 4 = encrypted DMs + let noteA = NostrEvent(content: "A", keypair: test_keypair, kind: 1)! // text note + let noteB = NostrEvent(content: "B", keypair: test_keypair, kind: 1)! // text note + let noteC = NostrEvent(content: "C", keypair: test_keypair, kind: 4)! // DM + let noteD = NostrEvent(content: "D", keypair: test_keypair, kind: 4)! // DM + + // Connect to relay1 and send text notes A + B + let relayConnection1 = await connectToRelay(url: relayUrl1, label: "Relay1") + sendEvents([noteA, noteB], to: relayConnection1) + + // Connect to relay2 and send text note B + DM C + let relayConnection2 = await connectToRelay(url: relayUrl2, label: "Relay2") + sendEvents([noteB, noteC], to: relayConnection2) + + // Connect to relay3 and send DMs C + D + let relayConnection3 = await connectToRelay(url: relayUrl3, label: "Relay3") + sendEvents([noteC, noteD], to: relayConnection3) + + let relayPool = try await setupRelayPool(with: [relayUrl1, relayUrl2, relayUrl3]) + + // Local vector has text note A and DM C already + let negentropyVector = NegentropyStorageVector() + try negentropyVector.insert(nostrEvent: noteA) + try negentropyVector.insert(nostrEvent: noteC) + + let getsNoteB = XCTestExpectation(description: "Gets text note B") + let getsNoteD = XCTestExpectation(description: "Gets DM D") + let doesNotGetNoteA = XCTestExpectation(description: "Does not get text note A") + let doesNotGetNoteC = XCTestExpectation(description: "Does not get DM C") + doesNotGetNoteA.isInverted = true + doesNotGetNoteC.isInverted = true + + // When: Performing negentropy subscribe with multiple filters for different kinds + // Use two filters: one for kind 1 (text), one for kind 4 (DMs) + runNegentropySubscribe( + relayPool: relayPool, + filters: [ + NostrFilter(kinds: [.text]), // kind 1 + NostrFilter(kinds: [.dm]) // kind 4 + ], + vector: negentropyVector, + eventExpectations: [ + noteA.id: doesNotGetNoteA, + noteB.id: getsNoteB, + noteC.id: doesNotGetNoteC, + noteD.id: getsNoteD + ] + ) + + // Then: Should receive only text note B and DM D (text note A and DM C already synced) + await fulfillment(of: [getsNoteB, getsNoteD, doesNotGetNoteA, doesNotGetNoteC], timeout: 5.0) + } +} diff --git a/nostrscript/NostrScript.swift b/nostrscript/NostrScript.swift index 917ccb78..357a480b 100644 --- a/nostrscript/NostrScript.swift +++ b/nostrscript/NostrScript.swift @@ -207,6 +207,10 @@ enum NScriptEventType: Int { self = .ok case .auth: self = .auth + case .negentropyError: + self = .notice // Treat negentropy errors as notices for nostrscript + case .negentropyMessage: + self = .notice // Treat negentropy messages as notices for nostrscript } } }