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 } } }