Implement initial negentropy base functions

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 <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2026-01-12 12:47:06 -08:00
parent ac05b83772
commit 95d38fa802
14 changed files with 1034 additions and 24 deletions

View File

@@ -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 = "<group>"; };
3A96D41C298DA94500388A2A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = nl; path = nl.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostedTests.swift; sourceTree = "<group>"; };
64D0A2B0F048CC8D494945E6 /* RepostNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostNotificationTests.swift; sourceTree = "<group>"; };
3A994C4C2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = th; path = th.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
3A994C4D2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = "<group>"; };
3A994C4E2BE5B9370019F632 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -2424,8 +2435,6 @@
4CA5588229F33F5B00DC6A45 /* StringCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringCodable.swift; sourceTree = "<group>"; };
4CA9275C2A28FF630098A105 /* LongformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformView.swift; sourceTree = "<group>"; };
4CA9275E2A2902B20098A105 /* LongformPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformPreview.swift; sourceTree = "<group>"; };
5C78A7902E30358000CF177D /* ReadingProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressBar.swift; sourceTree = "<group>"; };
5C78A7942E30359000CF177D /* LongformMarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformMarkdownView.swift; sourceTree = "<group>"; };
4CA927602A290E340098A105 /* EventShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventShell.swift; sourceTree = "<group>"; };
4CA927622A290EB10098A105 /* EventTop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTop.swift; sourceTree = "<group>"; };
4CA927642A290F1A0098A105 /* TimeDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeDot.swift; sourceTree = "<group>"; };
@@ -2651,6 +2660,8 @@
5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientButtonStyle.swift; sourceTree = "<group>"; };
5C6E1DAE2A194075008FC15A /* PinkGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinkGradient.swift; sourceTree = "<group>"; };
5C7389B02B6EFA7100781E0A /* ProxyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyView.swift; sourceTree = "<group>"; };
5C78A7902E30358000CF177D /* ReadingProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressBar.swift; sourceTree = "<group>"; };
5C78A7942E30359000CF177D /* LongformMarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformMarkdownView.swift; sourceTree = "<group>"; };
5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapExplainer.swift; sourceTree = "<group>"; };
5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingTimelineView.swift; sourceTree = "<group>"; };
5C8F97092EB45E85009399B1 /* LiveChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveChatModel.swift; sourceTree = "<group>"; };
@@ -2691,13 +2702,13 @@
6439E013296790CF0020672B /* ProfilePicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePicImageView.swift; sourceTree = "<group>"; };
643EA5C7296B764E005081BB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = "<group>"; };
647D9A8C2968520300A295DE /* SideMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuView.swift; sourceTree = "<group>"; };
64D0A2B0F048CC8D494945E6 /* RepostNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostNotificationTests.swift; sourceTree = "<group>"; };
64FBD06E296255C400D9D3B2 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
7527271D2A93FF0100214108 /* Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Block.swift; sourceTree = "<group>"; };
75AD872A2AA23A460085EF2C /* Block+Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Block+Tests.swift"; sourceTree = "<group>"; };
7C60CAEE298471A1009C80D6 /* CoreSVG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreSVG.swift; sourceTree = "<group>"; };
7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = "<group>"; };
7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KFOptionSetter+.swift"; sourceTree = "<group>"; };
BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherImageProvider.swift; sourceTree = "<group>"; };
7CFF6316299FEFE5005D382A /* SelectableText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableText.swift; sourceTree = "<group>"; };
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 = "<group>"; };
@@ -2726,6 +2737,7 @@
BA3759962ABCCF360018D73B /* CameraPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = "<group>"; };
BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; };
BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; };
BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherImageProvider.swift; sourceTree = "<group>"; };
D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManager.swift; sourceTree = "<group>"; };
D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManagerMock.swift; sourceTree = "<group>"; };
@@ -2782,6 +2794,7 @@
D73E5F7E2C6AA066007EB227 /* DamusAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAliases.swift; sourceTree = "<group>"; };
D73E5F802C6AA07A007EB227 /* HighlighterExtensionAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlighterExtensionAliases.swift; sourceTree = "<group>"; };
D73FA9E02DDC129E00C706E1 /* OnboardingContentSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingContentSettings.swift; sourceTree = "<group>"; };
D74723ED2F15B0D6002DA12A /* NegentropySupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NegentropySupportTests.swift; sourceTree = "<group>"; };
D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessDamusState.swift; sourceTree = "<group>"; };
D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationExtensionState.swift; sourceTree = "<group>"; };
D74AAFCB2B155D07006CF0F4 /* MakeZapRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeZapRequest.swift; sourceTree = "<group>"; };
@@ -2806,6 +2819,8 @@
D77135D22E7B766300E7639F /* DataExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = "<group>"; };
D773BC5E2C6D538500349F0A /* CommentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentItem.swift; sourceTree = "<group>"; };
D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = "<group>"; };
D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncStreamUtilities.swift; sourceTree = "<group>"; };
D77DA2C72F19D452000B7093 /* NegentropyUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NegentropyUtilities.swift; sourceTree = "<group>"; };
D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = "<group>"; };
D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; };
D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; };
@@ -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" */;

View File

@@ -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",

View File

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

View File

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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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<WebSocketEvent>
/// The task which will process WebSocket events in the order in which we receive them from the wire
var wsEventProcessTask: Task<Void, any Error>?
@RelayPoolActor // Isolate this to a specific actor to avoid thread-satefy issues.
var negentropyStreams: [String: AsyncStream<NegentropyResponse>.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<NegentropyResponse> {
return AsyncStream<NegentropyResponse> { 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)\"]"
}

View File

@@ -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<NostrEvent> {
return AsyncStream<NostrEvent>.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<StreamItem, any Error> {
return AsyncThrowingStream<StreamItem, any Error>.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<NostrEvent, any Error> {
return AsyncThrowingStream<NostrEvent, any Error>.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<NostrEvent, any Error> {
return AsyncThrowingStream<NostrEvent, any Error>.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<NostrEvent, any Error> {
return AsyncThrowingStream<NostrEvent, any Error>.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 {

View File

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

View File

@@ -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<Element, Failure>.Continuation) async throws -> Void) -> AsyncThrowingStream<Element, Failure> {
return AsyncThrowingStream<Element, Failure> { 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<Element>.Continuation) async -> Void) -> AsyncStream<Element> {
return AsyncStream<Element> { continuation in
let streamTask = Task {
await task(continuation)
continuation.finish()
}
continuation.onTermination = { @Sendable _ in
streamTask.cancel()
}
}
}
}

View File

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

View File

@@ -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,

View File

@@ -0,0 +1,445 @@
//
// NegentropySupportTests.swift
// damus
//
// Created by Daniel DAquino 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<Void, Never>?
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)
}
}

View File

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