diff --git a/DamusNotificationService/NotificationExtensionState.swift b/DamusNotificationService/NotificationExtensionState.swift index afe60557f..890e6f6c5 100644 --- a/DamusNotificationService/NotificationExtensionState.swift +++ b/DamusNotificationService/NotificationExtensionState.swift @@ -15,6 +15,7 @@ struct NotificationExtensionState: HeadlessDamusState { let keypair: Keypair let profiles: Profiles let zaps: Zaps + let polls: PollResultsStore let lnurls: LNUrls init?() { @@ -32,6 +33,7 @@ struct NotificationExtensionState: HeadlessDamusState { self.keypair = keypair self.profiles = Profiles(ndb: ndb) self.zaps = Zaps(our_pubkey: keypair.pubkey) + self.polls = MainActor.assumeIsolated { PollResultsStore() } self.lnurls = LNUrls() } diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 682622505..a3727ec81 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -191,8 +191,19 @@ 4C363A9028247A1D006E126D /* NostrLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A8F28247A1D006E126D /* NostrLink.swift */; }; 4C363A922825FCF2006E126D /* ProfileUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A912825FCF2006E126D /* ProfileUpdate.swift */; }; 4C363A94282704FA006E126D /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A93282704FA006E126D /* Post.swift */; }; + 5C78B8042E40000000CF177D /* PollModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8022E40000000CF177D /* PollModels.swift */; }; + 5C78B8082E40000000CF177D /* PollResultsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8032E40000000CF177D /* PollResultsStore.swift */; }; + 5C78B80D2E40000000CF177D /* PollVoteBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B80C2E40000000CF177D /* PollVoteBuilder.swift */; }; + 5C78B8122E40000000CF177D /* PollDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8112E40000000CF177D /* PollDraft.swift */; }; + 5C78B8222E40000000CF177D /* PollComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8212E40000000CF177D /* PollComposerView.swift */; }; + 5C78B8322E40000000CF177D /* PollEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8312E40000000CF177D /* PollEventView.swift */; }; + 5C78B8372E40000000CF177D /* PollResultsStore+AppIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8362E40000000CF177D /* PollResultsStore+AppIntegration.swift */; }; + 5C78B8392E40000000CF177D /* PollEventViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8382E40000000CF177D /* PollEventViewFactory.swift */; }; + 5C78B83B2E40000000CF177D /* PollEventViewFactory+Stub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B83A2E40000000CF177D /* PollEventViewFactory+Stub.swift */; }; + 5C78B83D2E40000000CF177D /* PollEventViewFactory+Stub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B83A2E40000000CF177D /* PollEventViewFactory+Stub.swift */; }; 4C363A9A28283854006E126D /* Reply.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A9928283854006E126D /* Reply.swift */; }; 4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A9D2828A822006E126D /* ReplyTests.swift */; }; + 5C78B8412E40000000CF177D /* PollTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8402E40000000CF177D /* PollTests.swift */; }; 4C363AA02828A8DD006E126D /* LikeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A9F2828A8DD006E126D /* LikeTests.swift */; }; 4C363AA228296A7E006E126D /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363AA128296A7E006E126D /* SearchView.swift */; }; 4C363AA428296DEE006E126D /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363AA328296DEE006E126D /* SearchModel.swift */; }; @@ -754,6 +765,11 @@ 82D6FB7E2CD99F7900C925F4 /* Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7FF7D42823313F009601DB /* Mentions.swift */; }; 82D6FB7F2CD99F7900C925F4 /* ProfileUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A912825FCF2006E126D /* ProfileUpdate.swift */; }; 82D6FB802CD99F7900C925F4 /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A93282704FA006E126D /* Post.swift */; }; + 5C78B8052E40000000CF177D /* PollModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8022E40000000CF177D /* PollModels.swift */; }; + 5C78B8092E40000000CF177D /* PollResultsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8032E40000000CF177D /* PollResultsStore.swift */; }; + 5C78B80E2E40000000CF177D /* PollVoteBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B80C2E40000000CF177D /* PollVoteBuilder.swift */; }; + 5C78B8132E40000000CF177D /* PollDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8112E40000000CF177D /* PollDraft.swift */; }; + 5C78B8232E40000000CF177D /* PollComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8212E40000000CF177D /* PollComposerView.swift */; }; 82D6FB822CD99F7900C925F4 /* Reply.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A9928283854006E126D /* Reply.swift */; }; 82D6FB832CD99F7900C925F4 /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363AA328296DEE006E126D /* SearchModel.swift */; }; 82D6FB842CD99F7900C925F4 /* NostrFilter+Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */; }; @@ -1074,6 +1090,11 @@ D703D76A2C670B2C00A400EA /* Bech32Object.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF0ABEF29857E9200D66079 /* Bech32Object.swift */; }; D703D76B2C670B3100A400EA /* Referenced.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC14FF82A741939007AEB17 /* Referenced.swift */; }; D703D76C2C670B3900A400EA /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A93282704FA006E126D /* Post.swift */; }; + 5C78B8062E40000000CF177D /* PollModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8022E40000000CF177D /* PollModels.swift */; }; + 5C78B80A2E40000000CF177D /* PollResultsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8032E40000000CF177D /* PollResultsStore.swift */; }; + 5C78B80F2E40000000CF177D /* PollVoteBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B80C2E40000000CF177D /* PollVoteBuilder.swift */; }; + 5C78B8142E40000000CF177D /* PollDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8112E40000000CF177D /* PollDraft.swift */; }; + 5C78B8242E40000000CF177D /* PollComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8212E40000000CF177D /* PollComposerView.swift */; }; D703D76D2C670B4500A400EA /* ZapDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */; }; D703D76E2C670B4900A400EA /* NdbTagsIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDD1AE12A6B3074001CD4DF /* NdbTagsIterator.swift */; }; D703D76F2C670B5200A400EA /* NostrResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFB028049D510006080F /* NostrResponse.swift */; }; @@ -1629,6 +1650,11 @@ D798D2232B0859B700234419 /* KeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C7F2A0220E1001AFC1D /* KeychainStorage.swift */; }; D798D2242B0859C900234419 /* LocalizationUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A3040F029A8FF97008A0F29 /* LocalizationUtil.swift */; }; D798D2252B0859D700234419 /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A93282704FA006E126D /* Post.swift */; }; + 5C78B8072E40000000CF177D /* PollModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8022E40000000CF177D /* PollModels.swift */; }; + 5C78B80B2E40000000CF177D /* PollResultsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8032E40000000CF177D /* PollResultsStore.swift */; }; + 5C78B8102E40000000CF177D /* PollVoteBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B80C2E40000000CF177D /* PollVoteBuilder.swift */; }; + 5C78B8152E40000000CF177D /* PollDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8112E40000000CF177D /* PollDraft.swift */; }; + 5C78B8252E40000000CF177D /* PollComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78B8212E40000000CF177D /* PollComposerView.swift */; }; D798D2262B085C4200234419 /* Bech32.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C90BD19283AA67F008EE7EF /* Bech32.swift */; }; D798D2282B085CDA00234419 /* NdbNote+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D798D2272B085CDA00234419 /* NdbNote+.swift */; }; D798D2292B08686C00234419 /* ContentParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4DD3DA2A6CA7E8005B4E85 /* ContentParsing.swift */; }; @@ -2099,8 +2125,18 @@ 4C363A8F28247A1D006E126D /* NostrLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrLink.swift; sourceTree = ""; }; 4C363A912825FCF2006E126D /* ProfileUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUpdate.swift; sourceTree = ""; }; 4C363A93282704FA006E126D /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; + 5C78B8022E40000000CF177D /* PollModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollModels.swift; sourceTree = ""; }; + 5C78B8032E40000000CF177D /* PollResultsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollResultsStore.swift; sourceTree = ""; }; + 5C78B8362E40000000CF177D /* PollResultsStore+AppIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollResultsStore+AppIntegration.swift"; sourceTree = ""; }; + 5C78B80C2E40000000CF177D /* PollVoteBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollVoteBuilder.swift; sourceTree = ""; }; + 5C78B8112E40000000CF177D /* PollDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollDraft.swift; sourceTree = ""; }; + 5C78B8212E40000000CF177D /* PollComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollComposerView.swift; sourceTree = ""; }; + 5C78B8312E40000000CF177D /* PollEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollEventView.swift; sourceTree = ""; }; + 5C78B8382E40000000CF177D /* PollEventViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollEventViewFactory.swift; sourceTree = ""; }; + 5C78B83A2E40000000CF177D /* PollEventViewFactory+Stub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollEventViewFactory+Stub.swift"; sourceTree = ""; }; 4C363A9928283854006E126D /* Reply.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reply.swift; sourceTree = ""; }; 4C363A9D2828A822006E126D /* ReplyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyTests.swift; sourceTree = ""; }; + 5C78B8402E40000000CF177D /* PollTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTests.swift; sourceTree = ""; }; 4C363A9F2828A8DD006E126D /* LikeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikeTests.swift; sourceTree = ""; }; 4C363AA128296A7E006E126D /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 4C363AA328296DEE006E126D /* SearchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModel.swift; sourceTree = ""; }; @@ -3689,6 +3725,7 @@ D7BEE6F82D37B37400CF659F /* DraftTests.swift */, 4C363A9F2828A8DD006E126D /* LikeTests.swift */, 4C363A9D2828A822006E126D /* ReplyTests.swift */, + 5C78B8402E40000000CF177D /* PollTests.swift */, 4CE6DEF727F7A08200C66700 /* damusTests.swift */, 4C3EA67A28FF7B3900C48A62 /* InvoiceTests.swift */, 3ACBCB77295FE5C70037388A /* TimeAgoTests.swift */, @@ -3755,6 +3792,37 @@ path = Zaps; sourceTree = ""; }; + 5C78B8002E40000000CF177D /* Polls */ = { + isa = PBXGroup; + children = ( + 5C78B8012E40000000CF177D /* Models */, + 5C78B8202E40000000CF177D /* Views */, + ); + path = Polls; + sourceTree = ""; + }; + 5C78B8012E40000000CF177D /* Models */ = { + isa = PBXGroup; + children = ( + 5C78B8112E40000000CF177D /* PollDraft.swift */, + 5C78B8022E40000000CF177D /* PollModels.swift */, + 5C78B8032E40000000CF177D /* PollResultsStore.swift */, + 5C78B8362E40000000CF177D /* PollResultsStore+AppIntegration.swift */, + 5C78B80C2E40000000CF177D /* PollVoteBuilder.swift */, + ); + path = Models; + sourceTree = ""; + }; + 5C78B8202E40000000CF177D /* Views */ = { + isa = PBXGroup; + children = ( + 5C78B8212E40000000CF177D /* PollComposerView.swift */, + 5C78B8312E40000000CF177D /* PollEventView.swift */, + 5C78B8382E40000000CF177D /* PollEventViewFactory.swift */, + ); + path = Views; + sourceTree = ""; + }; 4CE9FBBB2A6B3D9C007E485C /* Test */ = { isa = PBXGroup; children = ( @@ -3993,9 +4061,10 @@ 5C78A7A02E303DB900CF177D /* Bookmarks */, 5C78A7942E30394300CF177D /* DMs */, F71694E82A66221E001F4053 /* Onboarding */, - 5C78A78B2E3035A200CF177D /* Highlight */, - 5C78A7872E30345900CF177D /* Longform */, - 5C78A7842E30340E00CF177D /* FollowPack */, + 5C78A78B2E3035A200CF177D /* Highlight */, + 5C78A7872E30345900CF177D /* Longform */, + 5C78B8002E40000000CF177D /* Polls */, + 5C78A7842E30340E00CF177D /* FollowPack */, 4CFF8F5729C9FD07008DB934 /* Purple */, 4CE879562996C44A00F758CC /* Zaps */, 4C7D095A2A098C5C00943473 /* Wallet */, @@ -4836,6 +4905,7 @@ 82D6FA9E2CD9820500C925F4 /* Info.plist */, 82D6FAA62CD9820500C925F4 /* share extension.entitlements */, 82D6FA992CD9820500C925F4 /* ShareViewController.swift */, + 5C78B83A2E40000000CF177D /* PollEventViewFactory+Stub.swift */, ); path = "share extension"; sourceTree = ""; @@ -5655,10 +5725,18 @@ 4C9D6D1B2B1D35D7004E5CD9 /* PullDownSearch.swift in Sources */, 4C633352283D419F00B1C9C3 /* SignalModel.swift in Sources */, D7EB00B12CD59C8D00660C07 /* PresentFullScreenItemNotify.swift in Sources */, - 4CFF8F6D29CD022E008DB934 /* WideEventView.swift in Sources */, - 9609F058296E220800069BF3 /* BannerImageView.swift in Sources */, - 4C363A94282704FA006E126D /* Post.swift in Sources */, - 4C216F32286E388800040376 /* DMChatView.swift in Sources */, + 4CFF8F6D29CD022E008DB934 /* WideEventView.swift in Sources */, + 9609F058296E220800069BF3 /* BannerImageView.swift in Sources */, + 4C363A94282704FA006E126D /* Post.swift in Sources */, + 5C78B8042E40000000CF177D /* PollModels.swift in Sources */, + 5C78B8082E40000000CF177D /* PollResultsStore.swift in Sources */, + 5C78B80D2E40000000CF177D /* PollVoteBuilder.swift in Sources */, + 5C78B8122E40000000CF177D /* PollDraft.swift in Sources */, + 5C78B8222E40000000CF177D /* PollComposerView.swift in Sources */, + 5C78B8322E40000000CF177D /* PollEventView.swift in Sources */, + 5C78B8392E40000000CF177D /* PollEventViewFactory.swift in Sources */, + 5C78B8372E40000000CF177D /* PollResultsStore+AppIntegration.swift in Sources */, + 4C216F32286E388800040376 /* DMChatView.swift in Sources */, 4C7D09782A0B0CC900943473 /* WalletModel.swift in Sources */, 4C1253522A76C6130004F4B8 /* ComposeNotify.swift in Sources */, D7100C582B76FC8400C59298 /* MarketingContentView.swift in Sources */, @@ -5943,9 +6021,10 @@ D7DB1FEE2D5AC51B00CF06DA /* NIP44v2EncryptionTests.swift in Sources */, 4C4F14A72A2A61A30045A0B9 /* NostrScriptTests.swift in Sources */, D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */, - 4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */, - 4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */, - 3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */, + 4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */, + 4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */, + 5C78B8412E40000000CF177D /* PollTests.swift in Sources */, + 3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */, 4C7D097E2A0C58B900943473 /* WalletConnectTests.swift in Sources */, 4CB883AA297612FF00DC99E7 /* ZapTests.swift in Sources */, D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */, @@ -5987,6 +6066,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5C78B83B2E40000000CF177D /* PollEventViewFactory+Stub.swift in Sources */, 5C4FA7FB2DC29C3800CE658C /* FollowPackView.swift in Sources */, 4C3624722D5EA18E00DD066E /* amount.c in Sources */, 4C3624712D5EA18300DD066E /* error.c in Sources */, @@ -6225,9 +6305,14 @@ 82D6FB7C2CD99F7900C925F4 /* Liked.swift in Sources */, 82D6FB7D2CD99F7900C925F4 /* DamusState.swift in Sources */, 82D6FB7E2CD99F7900C925F4 /* Mentions.swift in Sources */, - 82D6FB7F2CD99F7900C925F4 /* ProfileUpdate.swift in Sources */, - 82D6FB802CD99F7900C925F4 /* Post.swift in Sources */, - D7F563132DEE71C0008509DE /* NdbFilter.swift in Sources */, + 82D6FB7F2CD99F7900C925F4 /* ProfileUpdate.swift in Sources */, + 82D6FB802CD99F7900C925F4 /* Post.swift in Sources */, + 5C78B8052E40000000CF177D /* PollModels.swift in Sources */, + 5C78B8092E40000000CF177D /* PollResultsStore.swift in Sources */, + 5C78B80E2E40000000CF177D /* PollVoteBuilder.swift in Sources */, + 5C78B8132E40000000CF177D /* PollDraft.swift in Sources */, + 5C78B8232E40000000CF177D /* PollComposerView.swift in Sources */, + D7F563132DEE71C0008509DE /* NdbFilter.swift in Sources */, 82D6FB822CD99F7900C925F4 /* Reply.swift in Sources */, 82D6FB832CD99F7900C925F4 /* SearchModel.swift in Sources */, 82D6FB842CD99F7900C925F4 /* NostrFilter+Hashable.swift in Sources */, @@ -6556,6 +6641,7 @@ D73E5E412C6A97F4007EB227 /* GoldSupportGradient.swift in Sources */, D73E5E422C6A97F4007EB227 /* PinkGradient.swift in Sources */, D73E5E432C6A97F4007EB227 /* GrayGradient.swift in Sources */, + 5C78B83D2E40000000CF177D /* PollEventViewFactory+Stub.swift in Sources */, D7DB93072D66A44100DA1EE5 /* Undistractor.swift in Sources */, D73E5E442C6A97F4007EB227 /* DamusLogoGradient.swift in Sources */, D73E5E452C6A97F4007EB227 /* DamusBackground.swift in Sources */, @@ -7001,10 +7087,15 @@ D703D7902C670D1600A400EA /* NewEventsBits.swift in Sources */, D703D75E2C670A9A00A400EA /* NdbTagElem.swift in Sources */, D703D7622C670ACB00A400EA /* ByteBuffer.swift in Sources */, - D703D7B62C67118200A400EA /* String+extension.swift in Sources */, - D74EA08A2D2BF2A7002290DD /* URLHandler.swift in Sources */, - D703D76C2C670B3900A400EA /* Post.swift in Sources */, - D703D77A2C670BEB00A400EA /* VeriferOptions.swift in Sources */, + D703D7B62C67118200A400EA /* String+extension.swift in Sources */, + D74EA08A2D2BF2A7002290DD /* URLHandler.swift in Sources */, + D703D76C2C670B3900A400EA /* Post.swift in Sources */, + 5C78B8062E40000000CF177D /* PollModels.swift in Sources */, + 5C78B80A2E40000000CF177D /* PollResultsStore.swift in Sources */, + 5C78B80F2E40000000CF177D /* PollVoteBuilder.swift in Sources */, + 5C78B8142E40000000CF177D /* PollDraft.swift in Sources */, + 5C78B8242E40000000CF177D /* PollComposerView.swift in Sources */, + D703D77A2C670BEB00A400EA /* VeriferOptions.swift in Sources */, D73E5F9E2C6AA9F7007EB227 /* nostrscript.c in Sources */, D703D71E2C66E47100A400EA /* ActionViewController.swift in Sources */, D703D7472C67092700A400EA /* UserSettingsStore.swift in Sources */, @@ -7126,9 +7217,14 @@ D7CCFC082B05834500323D86 /* NoteId.swift in Sources */, D7CE1B1A2B0BE135002EDAD4 /* json_parser.c in Sources */, D7EDED2A2B128CB40018B19C /* Nip98HTTPAuth.swift in Sources */, - D7CB5D592B11764000AD4105 /* NewUnmutesNotify.swift in Sources */, - D798D2252B0859D700234419 /* Post.swift in Sources */, - D7EDED172B1177960018B19C /* TranslationService.swift in Sources */, + D7CB5D592B11764000AD4105 /* NewUnmutesNotify.swift in Sources */, + D798D2252B0859D700234419 /* Post.swift in Sources */, + 5C78B8072E40000000CF177D /* PollModels.swift in Sources */, + 5C78B80B2E40000000CF177D /* PollResultsStore.swift in Sources */, + 5C78B8102E40000000CF177D /* PollVoteBuilder.swift in Sources */, + 5C78B8152E40000000CF177D /* PollDraft.swift in Sources */, + 5C78B8252E40000000CF177D /* PollComposerView.swift in Sources */, + D7EDED172B1177960018B19C /* TranslationService.swift in Sources */, D7CCFC0F2B0587F600323D86 /* Keys.swift in Sources */, D7CB5D542B1174F700AD4105 /* NIP05.swift in Sources */, D798D2232B0859B700234419 /* KeychainStorage.swift in Sources */, diff --git a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 6de7b6889..000000000 --- a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,144 +0,0 @@ -{ - "originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc", - "pins" : [ - { - "identity" : "codescanner", - "kind" : "remoteSourceControl", - "location" : "https://github.com/twostraws/CodeScanner.git", - "state" : { - "revision" : "9fa582f4b36c69c2a55bff5fb3377eb170ae273c" - } - }, - { - "identity" : "cryptoswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", - "state" : { - "revision" : "e74bbbfbef939224b242ae7c342a90e60b88b5ce" - } - }, - { - "identity" : "emojikit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tyiu/EmojiKit", - "state" : { - "revision" : "47a4b1402de26be0299dcb4d667c1faaf21a7874", - "version" : "0.2.0" - } - }, - { - "identity" : "emojipicker", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tyiu/EmojiPicker.git", - "state" : { - "revision" : "3f48903721eae223238ff0af17c22d6373d33813", - "version" : "0.2.0" - } - }, - { - "identity" : "faviconfinder", - "kind" : "remoteSourceControl", - "location" : "https://github.com/will-lumley/FaviconFinder.git", - "state" : { - "revision" : "9279f4371f4877ca302ba3bf1015f3f58ae4a56c", - "version" : "5.1.4" - } - }, - { - "identity" : "gsplayer", - "kind" : "remoteSourceControl", - "location" : "https://github.com/wxxsw/GSPlayer", - "state" : { - "revision" : "aa6dad7943d52f5207f7fcc2ad3e4274583443b8", - "version" : "0.2.26" - } - }, - { - "identity" : "kingfisher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/onevcat/Kingfisher", - "state" : { - "revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3", - "version" : "8.3.1" - } - }, - { - "identity" : "secp256k1.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jb55/secp256k1.swift", - "state" : { - "revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "ee97538f5b81ae89698fd95938896dec5217b148", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-markdown-ui", - "kind" : "remoteSourceControl", - "location" : "https://github.com/damus-io/swift-markdown-ui", - "state" : { - "revision" : "76bb7971da7fbf429de1c84f1244adf657242fee" - } - }, - { - "identity" : "swift-snapshot-testing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-snapshot-testing", - "state" : { - "revision" : "5b356adceabff6ca027f6574aac79e9fee145d26", - "version" : "1.14.1" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", - "state" : { - "revision" : "74203046135342e4a4a627476dd6caf8b28fe11b", - "version" : "509.0.0" - } - }, - { - "identity" : "swift-trie", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tyiu/swift-trie", - "state" : { - "revision" : "4c50bff6c168f74425f70476be62a072980d2da7", - "version" : "0.1.2" - } - }, - { - "identity" : "swiftsoup", - "kind" : "remoteSourceControl", - "location" : "https://github.com/scinfu/SwiftSoup.git", - "state" : { - "revision" : "bba848db50462894e7fc0891d018dfecad4ef11e", - "version" : "2.8.7" - } - }, - { - "identity" : "swiftycrop", - "kind" : "remoteSourceControl", - "location" : "https://github.com/benedom/SwiftyCrop", - "state" : { - "revision" : "454d0a0d4faf6f3a19c8d817ab9d7d27524bd79f" - } - }, - { - "identity" : "swipeactions", - "kind" : "remoteSourceControl", - "location" : "https://github.com/damus-io/SwipeActions.git", - "state" : { - "revision" : "33d99756c3112e1a07c1732e3cddc5ad5bd0c5f4" - } - } - ], - "version" : 3 -} diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 239694586..f7d5483f2 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -686,6 +686,7 @@ struct ContentView: View { dms: home.dms, previews: PreviewCache(), zaps: Zaps(our_pubkey: pubkey), + polls: MainActor.assumeIsolated { PollResultsStore() }, lnurls: LNUrls(), settings: settings, relay_filters: relay_filters, @@ -1260,4 +1261,3 @@ func logout(_ state: DamusState?) state?.close() notify(.logout) } - diff --git a/damus/Core/Nostr/NostrEvent.swift b/damus/Core/Nostr/NostrEvent.swift index 0e6cb3e08..5ac03fd8f 100644 --- a/damus/Core/Nostr/NostrEvent.swift +++ b/damus/Core/Nostr/NostrEvent.swift @@ -130,7 +130,10 @@ class NostrEventOld: Codable, Identifiable, CustomStringConvertible, Equatable, extension NostrEventOld { var is_textlike: Bool { - return kind == 1 || kind == 42 || kind == 30023 + return kind == NostrKind.text.rawValue || + kind == NostrKind.chat.rawValue || + kind == NostrKind.longform.rawValue || + kind == NostrKind.poll.rawValue } var too_big: Bool { diff --git a/damus/Core/Nostr/NostrKind.swift b/damus/Core/Nostr/NostrKind.swift index 7634aeff6..db9a821c7 100644 --- a/damus/Core/Nostr/NostrKind.swift +++ b/damus/Core/Nostr/NostrKind.swift @@ -17,7 +17,9 @@ enum NostrKind: UInt32, Codable { case delete = 5 case boost = 6 case like = 7 + case poll_response = 1018 case chat = 42 + case poll = 1068 case mute_list = 10000 case relay_list = 10002 case interest_list = 10015 diff --git a/damus/Core/Storage/DamusState.swift b/damus/Core/Storage/DamusState.swift index dbabdf3f1..aee3d660d 100644 --- a/damus/Core/Storage/DamusState.swift +++ b/damus/Core/Storage/DamusState.swift @@ -20,6 +20,7 @@ class DamusState: HeadlessDamusState { let dms: DirectMessagesModel let previews: PreviewCache let zaps: Zaps + let polls: PollResultsStore let lnurls: LNUrls let settings: UserSettingsStore let relay_filters: RelayFilters @@ -39,7 +40,35 @@ class DamusState: HeadlessDamusState { let favicon_cache: FaviconCache private(set) var nostrNetwork: NostrNetworkManager - init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache) { + init( + keypair: Keypair, + likes: EventCounter, + boosts: EventCounter, + contacts: Contacts, + mutelist_manager: MutelistManager, + profiles: Profiles, + dms: DirectMessagesModel, + previews: PreviewCache, + zaps: Zaps, + polls: PollResultsStore, + lnurls: LNUrls, + settings: UserSettingsStore, + relay_filters: RelayFilters, + relay_model_cache: RelayModelCache, + drafts: Drafts, + events: EventCache, + bookmarks: BookmarksManager, + replies: ReplyCounter, + wallet: WalletModel, + nav: NavigationCoordinator, + music: MusicController?, + video: DamusVideoCoordinator, + ndb: Ndb, + purple: DamusPurple? = nil, + quote_reposts: EventCounter, + emoji_provider: EmojiProvider, + favicon_cache: FaviconCache + ) { self.keypair = keypair self.likes = likes self.boosts = boosts @@ -49,6 +78,7 @@ class DamusState: HeadlessDamusState { self.dms = dms self.previews = previews self.zaps = zaps + self.polls = polls self.lnurls = lnurls self.settings = settings self.relay_filters = relay_filters @@ -114,6 +144,7 @@ class DamusState: HeadlessDamusState { dms: home.dms, previews: PreviewCache(), zaps: Zaps(our_pubkey: pubkey), + polls: MainActor.assumeIsolated { PollResultsStore() }, lnurls: LNUrls(), settings: settings, relay_filters: relay_filters, @@ -183,6 +214,7 @@ class DamusState: HeadlessDamusState { dms: DirectMessagesModel(our_pubkey: empty_pub), previews: PreviewCache(), zaps: Zaps(our_pubkey: empty_pub), + polls: MainActor.assumeIsolated { PollResultsStore() }, lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: empty_pub), diff --git a/damus/Features/Events/EventView.swift b/damus/Features/Events/EventView.swift index cb4d87277..18c8442ab 100644 --- a/damus/Features/Events/EventView.swift +++ b/damus/Features/Events/EventView.swift @@ -47,6 +47,10 @@ struct EventView: View { LongformPreview(state: damus, ev: event, options: options) } else if event.known_kind == .highlight { HighlightView(state: damus, event: event, options: options) + } else if event.known_kind == .poll, + let poll = PollEvent(event: event), + let pollView = PollEventViewFactory.makePollEventView(damus: damus, event: event, poll: poll, options: options) { + pollView } else { TextEvent(damus: damus, event: event, pubkey: pubkey, options: options) //.padding([.top], 6) @@ -55,6 +59,14 @@ struct EventView: View { } } +struct PollEventViewFactory { + static var builder: (DamusState, NostrEvent, PollEvent, EventViewOptions) -> AnyView? = { _, _, _, _ in nil } + + static func makePollEventView(damus: DamusState, event: NostrEvent, poll: PollEvent, options: EventViewOptions) -> AnyView? { + builder(damus, event, poll, options) + } +} + // blame the porn bots for this code func should_blur_images(settings: UserSettingsStore, contacts: Contacts, ev: NostrEvent, our_pubkey: Pubkey, booster_pubkey: Pubkey? = nil) -> Bool { if settings.undistractMode { @@ -158,4 +170,3 @@ struct EventView_Previews: PreviewProvider { .padding() } } - diff --git a/damus/Features/Events/Models/LoadableNostrEventView.swift b/damus/Features/Events/Models/LoadableNostrEventView.swift index 6d4b4ed3d..dd696c055 100644 --- a/damus/Features/Events/Models/LoadableNostrEventView.swift +++ b/damus/Features/Events/Models/LoadableNostrEventView.swift @@ -62,7 +62,7 @@ class LoadableNostrEventViewModel: ObservableObject { guard let ev = await self.loadEvent(noteId: note_id) else { return .not_found } guard let known_kind = ev.known_kind else { return .unknown_or_unsupported_kind } switch known_kind { - case .text, .highlight: + case .text, .highlight, .poll: return .loaded(route: Route.Thread(thread: ThreadModel(event: ev, damus_state: damus_state))) case .dm: let dm_model = damus_state.dms.lookup_or_create(ev.pubkey) @@ -74,7 +74,7 @@ class LoadableNostrEventViewModel: ObservableObject { case .zap, .zap_request: guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found } return .loaded(route: Route.Zaps(target: zap.target)) - case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list: + case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list, .poll_response: return .unknown_or_unsupported_kind } case .naddr(let naddr): diff --git a/damus/Features/Polls/Models/PollDraft.swift b/damus/Features/Polls/Models/PollDraft.swift new file mode 100644 index 000000000..295477b1c --- /dev/null +++ b/damus/Features/Polls/Models/PollDraft.swift @@ -0,0 +1,56 @@ +// +// PollDraft.swift +// damus +// +// Created by ChatGPT on 2025-04-02. +// + +import Foundation + +struct PollDraftOption: Identifiable, Equatable { + let id: UUID + var text: String +} + +struct PollDraft: Equatable { + var options: [PollDraftOption] + var pollType: PollType + var endsAt: Date? + + static let minimumOptions: Int = 2 + static let maximumOptions: Int = 6 + + static func makeDefault() -> PollDraft { + PollDraft( + options: (0.. PollDraft.minimumOptions else { return } + options.removeAll { $0.id == id } + } + + mutating func ensureMinimumOptions() { + if options.count < PollDraft.minimumOptions { + let missing = PollDraft.minimumOptions - options.count + for _ in 0.. + + init?(event: NostrEvent) { + guard event.known_kind == .poll else { return nil } + + var options: [PollOption] = [] + var optionIds: Set = [] + var relayHints: [RelayURL] = [] + var pollType: PollType = .default + var endsAt: UInt32? = nil + + for tag in event.tags { + guard tag.count >= 2 else { continue } + var iterator = tag.makeIterator() + guard let keyElem = iterator.next() else { continue } + + switch keyElem.string() { + case "option": + guard tag.count >= 3, + let idElem = iterator.next(), + let labelElem = iterator.next() + else { continue } + let optionId = idElem.string() + let optionLabel = labelElem.string() + guard !optionId.isEmpty, !optionLabel.isEmpty, !optionIds.contains(optionId) else { continue } + optionIds.insert(optionId) + options.append(PollOption(id: optionId, label: optionLabel)) + + case "relay": + guard let relayElem = iterator.next(), + let relay = RelayURL(relayElem.string()) + else { continue } + if !relayHints.contains(relay) { + relayHints.append(relay) + } + + case "polltype": + guard let typeElem = iterator.next() else { continue } + pollType = PollType(rawValue: typeElem.string()) ?? .default + + case "endsAt": + guard let endsElem = iterator.next() else { continue } + if let raw = endsElem.u64() { + if raw > UInt64(UInt32.max) { + endsAt = UInt32.max + } else { + endsAt = UInt32(raw) + } + } + + default: + continue + } + } + + guard options.count >= 2 else { return nil } + + self.id = event.id + self.author = event.pubkey + self.createdAt = event.created_at + self.question = event.content + self.options = options + self.pollType = pollType + self.relayHints = relayHints + self.endsAt = endsAt + self.optionIdSet = optionIds + } + + func containsOption(_ optionId: String) -> Bool { + optionIdSet.contains(optionId) + } + + func isExpired(at timestamp: UInt32) -> Bool { + guard let endsAt else { return false } + return timestamp > endsAt + } + + func isExpired(now: Date = .now) -> Bool { + guard let endsAt else { return false } + return UInt32(now.timeIntervalSince1970) > endsAt + } +} + +struct PollResponse { + let pollId: NoteId + let responseId: NoteId + let responder: Pubkey + let createdAt: UInt32 + let optionIds: [String] + + init?(event: NostrEvent) { + guard event.known_kind == .poll_response else { return nil } + + var pollId: NoteId? + for tag in event.tags { + guard tag.count >= 2 else { continue } + var iterator = tag.makeIterator() + guard let keyElem = iterator.next(), keyElem.string() == "e", + let idElem = iterator.next(), let pollIdData = idElem.id() else { + continue + } + pollId = NoteId(pollIdData) + break + } + + guard let pollId else { + return nil + } + + var responses: [String] = [] + for tag in event.tags { + guard tag.count >= 2 else { continue } + var iterator = tag.makeIterator() + guard let keyElem = iterator.next(), keyElem.string() == "response", + let responseElem = iterator.next() + else { + continue + } + let optionId = responseElem.string() + guard !optionId.isEmpty else { continue } + responses.append(optionId) + } + + guard !responses.isEmpty else { return nil } + + self.pollId = pollId + self.responseId = event.id + self.responder = event.pubkey + self.createdAt = event.created_at + self.optionIds = responses + } +} diff --git a/damus/Features/Polls/Models/PollResultsStore+AppIntegration.swift b/damus/Features/Polls/Models/PollResultsStore+AppIntegration.swift new file mode 100644 index 000000000..602a5573b --- /dev/null +++ b/damus/Features/Polls/Models/PollResultsStore+AppIntegration.swift @@ -0,0 +1,28 @@ +// +// PollResultsStore+AppIntegration.swift +// damus +// +// Created by ChatGPT on 2025-04-11. +// + +import Foundation + +extension NostrNetworkManager: PollResponseNetworking { + func subscribeToPollResponses(poll: PollEvent, subId: String, handler: @escaping (NostrResponse) -> Void) { + let filter = NostrFilter(kinds: [.poll_response], referenced_ids: [poll.id]) + let relays = poll.relayHints.isEmpty ? nil : poll.relayHints + + pool.subscribe_to(sub_id: subId, filters: [filter], to: relays) { _, event in + guard case .nostr_event(let response) = event else { return } + handler(response) + } + } + + func sendPollResponseEvent(_ event: NostrEvent, relayHints: [RelayURL]?) { + postbox.send(event, to: relayHints) + } +} + +extension DamusState: PollVotingContext { + var pollNetwork: PollResponseNetworking { nostrNetwork } +} diff --git a/damus/Features/Polls/Models/PollResultsStore.swift b/damus/Features/Polls/Models/PollResultsStore.swift new file mode 100644 index 000000000..5b477be69 --- /dev/null +++ b/damus/Features/Polls/Models/PollResultsStore.swift @@ -0,0 +1,229 @@ +// +// PollResultsStore.swift +// damus +// +// Created by ChatGPT on 2025-04-02. +// + +import Foundation + +protocol PollResponseNetworking { + func subscribeToPollResponses(poll: PollEvent, subId: String, handler: @escaping (NostrResponse) -> Void) + func sendPollResponseEvent(_ event: NostrEvent, relayHints: [RelayURL]?) +} + +protocol PollVotingContext { + var keypair: Keypair { get } + var pollNetwork: PollResponseNetworking { get } +} + +@MainActor +final class PollResultsStore: ObservableObject { + enum PollVoteError: Error { + case noKeypair + case noSelection + case pollClosed + case invalidSelection + case eventBuildFailed + } + + struct PollState { + var poll: PollEvent + var ballotsByPubkey: [Pubkey: Ballot] + + init(poll: PollEvent, ballotsByPubkey: [Pubkey: Ballot] = [:]) { + self.poll = poll + self.ballotsByPubkey = ballotsByPubkey + } + + var id: NoteId { poll.id } + + var tallies: [String: Int] { + var counts: [String: Int] = Dictionary(uniqueKeysWithValues: poll.options.map { ($0.id, 0) }) + for ballot in ballotsByPubkey.values { + for selection in ballot.selections { + counts[selection, default: 0] += 1 + } + } + return counts + } + + var totalVotes: Int { + ballotsByPubkey.values.reduce(0) { acc, ballot in + acc + ballot.selections.count + } + } + + var voterCount: Int { + ballotsByPubkey.keys.count + } + + func hasVoted(pubkey: Pubkey) -> Bool { + ballotsByPubkey[pubkey] != nil + } + + func selections(for pubkey: Pubkey) -> [String]? { + ballotsByPubkey[pubkey]?.selections + } + } + + struct Ballot { + let eventId: NoteId + let createdAt: UInt32 + let selections: [String] + } + + @Published private(set) var polls: [NoteId: PollState] = [:] + + private var pendingResponses: [NoteId: [PollResponse]] = [:] + private var subscriptions: [NoteId: String] = [:] + + func reset() { + polls.removeAll() + pendingResponses.removeAll() + subscriptions.removeAll() + } + + func registerPollEvent(_ event: NostrEvent) { + guard let pollEvent = PollEvent(event: event) else { return } + + var state = polls[pollEvent.id] ?? PollState(poll: pollEvent) + state.poll = pollEvent + + if var queuedResponses = pendingResponses[pollEvent.id] { + var didChange = false + for response in queuedResponses { + if apply(response: response, to: &state) { + didChange = true + } + } + if didChange { + polls[pollEvent.id] = state + } else { + polls[pollEvent.id] = state + } + queuedResponses.removeAll() + pendingResponses[pollEvent.id] = nil + } else { + polls[pollEvent.id] = state + } + } + + func registerResponseEvent(_ event: NostrEvent) { + guard let response = PollResponse(event: event) else { return } + + if var state = polls[response.pollId] { + guard apply(response: response, to: &state) else { return } + polls[response.pollId] = state + } else { + pendingResponses[response.pollId, default: []].append(response) + } + } + + func ensureResults(for poll: PollEvent, network: PollResponseNetworking) { + if subscriptions[poll.id] != nil { return } + let subid = "poll-\(poll.id.hex())" + subscriptions[poll.id] = subid + + network.subscribeToPollResponses(poll: poll, subId: subid) { [weak self] response in + guard let self else { return } + + switch response { + case .event(_, let nostrEvent): + Task { @MainActor in + self.registerResponseEvent(nostrEvent) + } + case .eose: + break + default: + break + } + } + } + + func submitVote(for poll: PollEvent, selections: [String], context: PollVotingContext) -> Result { + guard let keypair = context.keypair.to_full() else { return .failure(.noKeypair) } + guard !selections.isEmpty else { return .failure(.noSelection) } + guard !poll.isExpired(now: Date()) else { return .failure(.pollClosed) } + + let sanitized = sanitizedSelectionIDs(from: selections, poll: poll) + guard !sanitized.isEmpty else { return .failure(.invalidSelection) } + + guard let event = PollVoteBuilder.makeResponseEvent(poll: poll, selections: sanitized, keypair: keypair) else { + return .failure(.eventBuildFailed) + } + + let relays = poll.relayHints.isEmpty ? nil : poll.relayHints + context.pollNetwork.sendPollResponseEvent(event, relayHints: relays) + registerResponseEvent(event) + return .success(()) + } + + func state(for pollId: NoteId) -> PollState? { + polls[pollId] + } + + func tallies(for pollId: NoteId) -> [String: Int]? { + polls[pollId]?.tallies + } + + func hasVoted(pollId: NoteId, pubkey: Pubkey) -> Bool { + polls[pollId]?.hasVoted(pubkey: pubkey) ?? false + } + + func selections(for pollId: NoteId, pubkey: Pubkey) -> [String]? { + polls[pollId]?.selections(for: pubkey) + } + + private func apply(response: PollResponse, to state: inout PollState) -> Bool { + if state.poll.isExpired(at: response.createdAt) { + return false + } + + let sanitizedSelections = sanitizeSelections(from: response, poll: state.poll) + guard !sanitizedSelections.isEmpty else { return false } + + let incomingBallot = Ballot(eventId: response.responseId, createdAt: response.createdAt, selections: sanitizedSelections) + + if let existing = state.ballotsByPubkey[response.responder] { + if existing.createdAt > incomingBallot.createdAt { + return false + } + + if existing.createdAt == incomingBallot.createdAt { + let existingData = existing.eventId.id + let incomingData = incomingBallot.eventId.id + if !existingData.lexicographicallyPrecedes(incomingData) { + return false + } + } + } + + state.ballotsByPubkey[response.responder] = incomingBallot + return true + } + + private func sanitizedSelectionIDs(from selectionIDs: [String], poll: PollEvent) -> [String] { + switch poll.pollType { + case .singleChoice: + guard let firstValid = selectionIDs.first(where: { poll.containsOption($0) }) else { + return [] + } + return [firstValid] + + case .multipleChoice: + var seen: Set = [] + var sanitized: [String] = [] + for option in selectionIDs { + guard poll.containsOption(option), !seen.contains(option) else { continue } + seen.insert(option) + sanitized.append(option) + } + return sanitized + } + } + + private func sanitizeSelections(from response: PollResponse, poll: PollEvent) -> [String] { + sanitizedSelectionIDs(from: response.optionIds, poll: poll) + } +} diff --git a/damus/Features/Polls/Models/PollVoteBuilder.swift b/damus/Features/Polls/Models/PollVoteBuilder.swift new file mode 100644 index 000000000..56c287edc --- /dev/null +++ b/damus/Features/Polls/Models/PollVoteBuilder.swift @@ -0,0 +1,59 @@ +// +// PollVoteBuilder.swift +// damus +// +// Created by ChatGPT on 2025-04-02. +// + +import Foundation + +struct PollVoteBuilder { + static func makeResponseEvent( + poll: PollEvent, + selections: [String], + keypair: FullKeypair, + timestamp: UInt32? = nil + ) -> NostrEvent? { + let sanitizedSelections = sanitize(selections: selections, for: poll) + guard !sanitizedSelections.isEmpty else { return nil } + + var tags: [[String]] = [] + tags.append(["e", poll.id.hex()]) + + for optionId in sanitizedSelections { + tags.append(["response", optionId]) + } + + if !poll.relayHints.isEmpty { + for relay in poll.relayHints { + tags.append(["relay", relay.absoluteString]) + } + } + + let createdAt = timestamp ?? UInt32(Date().timeIntervalSince1970) + return NostrEvent( + content: "", + keypair: keypair.to_keypair(), + kind: NostrKind.poll_response.rawValue, + tags: tags, + createdAt: createdAt + ) + } + + private static func sanitize(selections: [String], for poll: PollEvent) -> [String] { + switch poll.pollType { + case .singleChoice: + return selections.first(where: { poll.containsOption($0) }).map { [$0] } ?? [] + case .multipleChoice: + var seen: Set = [] + var sanitized: [String] = [] + for option in selections { + guard poll.containsOption(option), !seen.contains(option) else { continue } + seen.insert(option) + sanitized.append(option) + } + return sanitized + } + } +} + diff --git a/damus/Features/Polls/Views/PollComposerView.swift b/damus/Features/Polls/Views/PollComposerView.swift new file mode 100644 index 000000000..df3da65a0 --- /dev/null +++ b/damus/Features/Polls/Views/PollComposerView.swift @@ -0,0 +1,135 @@ +// +// PollComposerView.swift +// damus +// +// Created by ChatGPT on 2025-04-02. +// + +import SwiftUI + +struct PollComposerView: View { + @Binding var draft: PollDraft + let onRemove: () -> Void + + @State private var isExpirationEnabled: Bool = false + + private var optionIndices: [UUID: Int] { + Dictionary(uniqueKeysWithValues: draft.options.enumerated().map { ($0.element.id, $0.offset + 1) }) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Poll", systemImage: "chart.bar.xaxis") + .font(.headline) + Spacer() + Button(action: onRemove) { + Text("Remove Poll", comment: "Button to remove the poll composer from the note.") + } + .buttonStyle(.borderless) + } + + Picker("Poll Type", selection: $draft.pollType) { + Text("Single Choice", comment: "Poll type option for single choice polls.") + .tag(PollType.singleChoice) + Text("Multiple Choice", comment: "Poll type option for multiple choice polls.") + .tag(PollType.multipleChoice) + } + .pickerStyle(.segmented) + + VStack(spacing: 8) { + ForEach(draft.options) { option in + HStack { + TextField( + optionLabel(for: option.id), + text: binding(for: option.id) + ) + .textFieldStyle(.roundedBorder) + + if draft.options.count > PollDraft.minimumOptions { + Button { + removeOption(option.id) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.borderless) + } + } + } + + if draft.options.count < PollDraft.maximumOptions { + Button { + draft.addOption() + } label: { + Label("Add Option", systemImage: "plus.circle") + } + .buttonStyle(.bordered) + } + } + + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: $isExpirationEnabled.animation()) { + Text("Set expiration", comment: "Toggle to enable poll expiration.") + } + if isExpirationEnabled { + DatePicker( + "Poll ends", + selection: expirationBinding(), + in: Date().addingTimeInterval(60)..., + displayedComponents: [.date, .hourAndMinute] + ) + .datePickerStyle(.compact) + } + } + } + .onAppear { + isExpirationEnabled = draft.endsAt != nil + draft.ensureMinimumOptions() + } + .onChange(of: isExpirationEnabled) { enabled in + if enabled { + if draft.endsAt == nil { + draft.endsAt = Date().addingTimeInterval(3600) + } + } else { + draft.endsAt = nil + } + } + } + + private func binding(for optionID: UUID) -> Binding { + Binding( + get: { + draft.options.first(where: { $0.id == optionID })?.text ?? "" + }, + set: { newValue in + if let index = draft.options.firstIndex(where: { $0.id == optionID }) { + draft.options[index].text = newValue + } + } + ) + } + + private func optionLabel(for optionID: UUID) -> String { + if let index = optionIndices[optionID] { + return String(format: NSLocalizedString("Option %d", comment: "Label for a poll option field."), index) + } + return NSLocalizedString("Option", comment: "Fallback label for a poll option field.") + } + + private func removeOption(_ optionID: UUID) { + draft.removeOption(id: optionID) + } + + private func expirationBinding() -> Binding { + Binding( + get: { + draft.endsAt ?? Date().addingTimeInterval(3600) + }, + set: { newValue in + draft.endsAt = newValue + } + ) + } +} diff --git a/damus/Features/Polls/Views/PollEventView.swift b/damus/Features/Polls/Views/PollEventView.swift new file mode 100644 index 000000000..279cc8377 --- /dev/null +++ b/damus/Features/Polls/Views/PollEventView.swift @@ -0,0 +1,304 @@ +// +// PollEventView.swift +// damus +// +// Created by ChatGPT on 2025-04-02. +// + +import SwiftUI + +struct PollEventView: View { + let damus: DamusState + let event: NostrEvent + let poll: PollEvent + let options: EventViewOptions + + @ObservedObject private var store: PollResultsStore + + init(damus: DamusState, event: NostrEvent, poll: PollEvent, options: EventViewOptions) { + self.damus = damus + self.event = event + self.poll = poll + self.options = options + self.store = damus.polls + self.store.registerPollEvent(event) + } + + var body: some View { + EventShell(state: damus, event: event, options: options) { + PollEventCard(damusState: damus, poll: poll, store: store) + } + } +} + +private struct PollEventCard: View { + let damusState: DamusState + let poll: PollEvent + @ObservedObject var store: PollResultsStore + + @State private var selectedOptions: Set = [] + @State private var isSubmitting: Bool = false + @State private var errorMessage: String? = nil + + private var pollState: PollResultsStore.PollState? { + store.state(for: poll.id) + } + + private var ourSelections: [String] { + pollState?.selections(for: damusState.pubkey) ?? [] + } + + private var hasVoted: Bool { + pollState?.hasVoted(pubkey: damusState.pubkey) ?? false + } + + private var isExpired: Bool { + poll.isExpired(now: Date()) + } + + private var showResults: Bool { + hasVoted || isExpired + } + + private var canSubmit: Bool { + guard !showResults else { return false } + guard !isSubmitting else { return false } + guard damusState.keypair.to_full() != nil else { return false } + return !selectedOptions.isEmpty + } + + private var expirationText: String? { + guard let endsAt = poll.endsAt else { return nil } + if isExpired { + return NSLocalizedString("Poll closed", comment: "Label shown when the poll has ended.") + } else { + let relative = format_relative_time(endsAt) + return String(format: NSLocalizedString("Ends %@", comment: "Label showing when the poll will end."), relative) + } + } + + private var totalVotes: Int { + pollState?.totalVotes ?? 0 + } + + private var voterCount: Int { + pollState?.voterCount ?? 0 + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(poll.question) + .font(.headline) + + if let expirationText { + Text(expirationText) + .font(.footnote) + .foregroundColor(.secondary) + } + + VStack(alignment: .leading, spacing: 8) { + ForEach(poll.options) { option in + if showResults { + PollResultRow( + option: option, + poll: poll, + pollState: pollState, + isHighlighted: ourSelections.contains(option.id) + ) + } else { + PollSelectionRow( + option: option, + isSelected: selectedOptions.contains(option.id), + pollType: poll.pollType + ) { + toggleSelection(for: option.id) + } + } + } + } + + if showResults { + let voteSummary = summaryText() + Text(voteSummary) + .font(.footnote) + .foregroundColor(.secondary) + } else { + if damusState.keypair.to_full() == nil { + Text(NSLocalizedString("Sign in with your private key to vote.", comment: "Message shown when the user cannot vote without a private key.")) + .font(.footnote) + .foregroundColor(.secondary) + } + + Button(action: submitVote) { + if isSubmitting { + ProgressView() + } else { + Text(NSLocalizedString("Submit Vote", comment: "Button to submit a poll vote.")) + .bold() + } + } + .buttonStyle(GradientButtonStyle(padding: 10)) + .disabled(!canSubmit) + } + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundColor(.red) + } + } + .padding(.vertical, 8) + .onAppear { + selectedOptions = Set(ourSelections) + store.ensureResults(for: poll, network: damusState.nostrNetwork) + } + .onChange(of: ourSelections) { newValue in + if showResults { + selectedOptions = Set(newValue) + } + } + } + + private func toggleSelection(for optionID: String) { + guard !showResults else { return } + switch poll.pollType { + case .singleChoice: + selectedOptions = [optionID] + case .multipleChoice: + if selectedOptions.contains(optionID) { + selectedOptions.remove(optionID) + } else { + selectedOptions.insert(optionID) + } + } + } + + private func submitVote() { + guard !selectedOptions.isEmpty else { return } + isSubmitting = true + errorMessage = nil + + let result = store.submitVote(for: poll, selections: Array(selectedOptions), context: damusState) + switch result { + case .success: + selectedOptions = Set(ourSelections) + case .failure(let error): + errorMessage = errorMessage(for: error) + } + + isSubmitting = false + } + + private func summaryText() -> String { + if poll.pollType == .multipleChoice { + return String( + format: NSLocalizedString("Selections: %d • Voters: %d", comment: "Summary of poll results showing total selections and voter count."), + totalVotes, + voterCount + ) + } else { + return String( + format: NSLocalizedString("Votes: %d", comment: "Summary of poll results showing the total number of votes."), + voterCount + ) + } + } + + private func errorMessage(for error: PollResultsStore.PollVoteError) -> String { + switch error { + case .noKeypair: + return NSLocalizedString("You need your private key to vote.", comment: "Error shown when the user lacks a private key.") + case .noSelection: + return NSLocalizedString("Select at least one option before voting.", comment: "Error shown when no option is selected.") + case .pollClosed: + return NSLocalizedString("This poll has already ended.", comment: "Error shown when voting on a closed poll.") + case .invalidSelection: + return NSLocalizedString("Your selection is not valid for this poll.", comment: "Error shown when the selection is invalid.") + case .eventBuildFailed: + return NSLocalizedString("Could not create your vote. Please try again.", comment: "Generic error shown when vote event creation fails.") + } + } +} + +private struct PollSelectionRow: View { + let option: PollOption + let isSelected: Bool + let pollType: PollType + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Image(systemName: selectionIconName) + .foregroundColor(isSelected ? .accentColor : .secondary) + Text(option.label) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private var selectionIconName: String { + switch pollType { + case .singleChoice: + return isSelected ? "largecircle.fill.circle" : "circle" + case .multipleChoice: + return isSelected ? "checkmark.square.fill" : "square" + } + } +} + +private struct PollResultRow: View { + let option: PollOption + let poll: PollEvent + let pollState: PollResultsStore.PollState? + let isHighlighted: Bool + + private var votesForOption: Int { + pollState?.tallies[option.id] ?? 0 + } + + private var totalVotes: Int { + max(pollState?.totalVotes ?? 0, 0) + } + + private var progress: Double { + guard totalVotes > 0 else { return 0 } + return Double(votesForOption) / Double(totalVotes) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(option.label) + .fontWeight(isHighlighted ? .semibold : .regular) + Spacer() + Text(voteCountLabel()) + .font(.footnote) + .foregroundColor(.secondary) + } + + ProgressView(value: progress) + .tint(isHighlighted ? .accentColor : .blue) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isHighlighted ? Color.accentColor.opacity(0.1) : Color.secondary.opacity(0.08)) + ) + } + + private func voteCountLabel() -> String { + if poll.pollType == .multipleChoice { + return String(format: NSLocalizedString("%d selections", comment: "Number of selections for a poll option in multi-choice polls."), votesForOption) + } else { + return String(format: NSLocalizedString("%d votes", comment: "Number of votes for a poll option."), votesForOption) + } + } +} diff --git a/damus/Features/Polls/Views/PollEventViewFactory.swift b/damus/Features/Polls/Views/PollEventViewFactory.swift new file mode 100644 index 000000000..d06cb7613 --- /dev/null +++ b/damus/Features/Polls/Views/PollEventViewFactory.swift @@ -0,0 +1,16 @@ +// +// PollEventViewFactory.swift +// damus +// +// Created by ChatGPT on 2025-04-11. +// + +import SwiftUI + +extension PollEventViewFactory { + static func registerAppBuilder() { + builder = { damus, event, poll, options in + AnyView(PollEventView(damus: damus, event: event, poll: poll, options: options)) + } + } +} diff --git a/damus/Features/Posting/Models/DraftsModel.swift b/damus/Features/Posting/Models/DraftsModel.swift index 074cf69cd..070b2c51d 100644 --- a/damus/Features/Posting/Models/DraftsModel.swift +++ b/damus/Features/Posting/Models/DraftsModel.swift @@ -34,23 +34,27 @@ class DraftArtifacts: Equatable { /// /// For example, when replying to an event, the user can select which pubkey mentions they want to keep, and which ones to remove. var filtered_pubkeys: Set = [] + /// Optional poll configuration associated with this draft + var pollDraft: PollDraft? = nil /// A unique ID for this draft that allows us to address these if we need to. /// /// This will be the unique identifier in the NIP-37 note let id: String - init(content: NSMutableAttributedString = NSMutableAttributedString(string: ""), media: [UploadedMedia] = [], references: [RefId], id: String) { + init(content: NSMutableAttributedString = NSMutableAttributedString(string: ""), media: [UploadedMedia] = [], references: [RefId], id: String, pollDraft: PollDraft? = nil) { self.content = content self.media = media self.references = references self.id = id + self.pollDraft = pollDraft } - + static func == (lhs: DraftArtifacts, rhs: DraftArtifacts) -> Bool { return ( lhs.media == rhs.media && - lhs.content.string == rhs.content.string // Comparing the text content is not perfect but acceptable in this case because attributes for our post editor are determined purely from text content + lhs.content.string == rhs.content.string && + lhs.pollDraft == rhs.pollDraft // Comparing the text content is not perfect but acceptable in this case because attributes for our post editor are determined purely from text content ) } diff --git a/damus/Features/Posting/Models/Post.swift b/damus/Features/Posting/Models/Post.swift index a3c4d7e0b..0db510204 100644 --- a/damus/Features/Posting/Models/Post.swift +++ b/damus/Features/Posting/Models/Post.swift @@ -25,6 +25,10 @@ struct NostrPost { .map(\.asString) .joined(separator: "") + if self.kind == .poll { + return NostrEvent(content: self.content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: self.tags) + } + if self.kind == .highlight { var new_tags = post_tags.tags.filter({ $0[safe: 0] != "comment" }) if content.count > 0 { diff --git a/damus/Features/Posting/Views/PostView.swift b/damus/Features/Posting/Views/PostView.swift index 18fe208b8..6a6047a84 100644 --- a/damus/Features/Posting/Views/PostView.swift +++ b/damus/Features/Posting/Views/PostView.swift @@ -59,6 +59,7 @@ struct PostView: View { /// /// For example, when replying to an event, the user can select which pubkey mentions they want to keep, and which ones to remove. @State var filtered_pubkeys: Set = [] + @State var pollDraft: PollDraft? = nil @FocusState var focus: Bool @State var attach_media: Bool = false @@ -120,9 +121,64 @@ struct PostView: View { uploadTasks.forEach { $0.cancel() } uploadTasks.removeAll() } - + + var pollComposerAvailable: Bool { + if case .posting = action { + return true + } + return false + } + + var pollQuestionText: String { + post.string.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var pollOptionLabels: [String] { + guard let pollDraft else { return [] } + return pollDraft.options + .map { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + var pollHasDuplicateOptions: Bool { + let lowered = pollOptionLabels.map { $0.lowercased() } + return Set(lowered).count != lowered.count + } + + var pollExpirationIsValid: Bool { + guard let endsAt = pollDraft?.endsAt else { return true } + return endsAt > Date() + } + + var pollIsValid: Bool { + guard pollComposerAvailable, pollDraft != nil else { return false } + if pollQuestionText.isEmpty { return false } + if pollOptionLabels.count < PollDraft.minimumOptions { return false } + if pollHasDuplicateOptions { return false } + if !pollExpirationIsValid { return false } + return true + } + + func activatePollComposer() { + guard pollComposerAvailable else { return } + if pollDraft == nil { + pollDraft = PollDraft.makeDefault() + } + pollDraft?.ensureMinimumOptions() + uploadedMedias.removeAll() + references.removeAll() + filtered_pubkeys.removeAll() + post_changed(post: post, media: uploadedMedias) + } + + func deactivatePollComposer() { + guard pollDraft != nil else { return } + pollDraft = nil + post_changed(post: post, media: uploadedMedias) + } + func send_post() { - let new_post = build_post(state: self.damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: self.references, filtered_pubkeys: filtered_pubkeys) + let new_post = build_post(state: self.damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: self.references, filtered_pubkeys: filtered_pubkeys, pollDraft: pollDraft) notify(.post(.post(new_post))) @@ -141,6 +197,9 @@ struct PostView: View { } var posting_disabled: Bool { + if pollDraft != nil { + return uploading_disabled || !pollIsValid + } switch action { case .highlighting(_): return false @@ -168,7 +227,19 @@ struct PostView: View { .padding(6) }) } - + + var PollButton: some View { + Button(action: { + activatePollComposer() + }, label: { + Image(systemName: "chart.bar.fill") + .imageScale(.large) + .padding(6) + }) + .disabled(!pollComposerAvailable) + .accessibilityLabel(Text("Add Poll", comment: "Button to enable the poll composer.")) + } + var CameraButton: some View { Button(action: { attach_camera = true @@ -180,6 +251,9 @@ struct PostView: View { var AttachmentBar: some View { HStack(alignment: .center, spacing: 15) { + if pollComposerAvailable { + PollButton + } ImageButton CameraButton Spacer() @@ -231,6 +305,7 @@ struct PostView: View { damus_state.drafts.post = nil } + pollDraft = nil damus_state.drafts.save(damus_state: damus_state) } @@ -238,12 +313,15 @@ struct PostView: View { guard let draft = load_draft_for_post(drafts: self.damus_state.drafts, action: self.action) else { self.post = NSMutableAttributedString("") self.uploadedMedias = [] + self.pollDraft = nil self.autoSaveModel.markNothingToSave() // We should not save empty drafts. return false } - + self.uploadedMedias = draft.media self.post = draft.content + self.pollDraft = draft.pollDraft + self.pollDraft?.ensureMinimumOptions() self.autoSaveModel.markSaved() // The draft we just loaded is saved to memory. Mark it as such. return true } @@ -258,8 +336,9 @@ struct PostView: View { draft.media = uploadedMedias draft.references = references draft.filtered_pubkeys = filtered_pubkeys + draft.pollDraft = pollDraft } else { - let artifacts = DraftArtifacts(content: post, media: uploadedMedias, references: references, id: UUID().uuidString) + let artifacts = DraftArtifacts(content: post, media: uploadedMedias, references: references, id: UUID().uuidString, pollDraft: pollDraft) set_draft_for_post(drafts: damus_state.drafts, action: action, artifacts: artifacts) } self.autoSaveModel.needsSaving() @@ -463,7 +542,7 @@ struct PostView: View { } // This if-block observes @ for tagging - if let searching { + if pollDraft == nil, let searching { UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, post: $post) .frame(maxHeight: .infinity) .environmentObject(tagModel) @@ -480,13 +559,28 @@ struct PostView: View { } else { Divider() VStack(alignment: .leading) { - AttachmentBar - .padding(.vertical, 5) + if pollDraft != nil { + PollComposerView( + draft: Binding( + get: { self.pollDraft ?? PollDraft.makeDefault() }, + set: { self.pollDraft = $0 } + ), + onRemove: { deactivatePollComposer() } + ) + .padding(.vertical, 8) .padding(.horizontal) + } else { + AttachmentBar + .padding(.vertical, 5) + .padding(.horizontal) + } } } - } + } .background(DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all)) + .onChange(of: pollDraft) { _ in + post_changed(post: post, media: uploadedMedias) + } .sheet(isPresented: $attach_media) { MediaPicker(mediaPickerEntry: .postView, onMediaSelected: { image_upload_confirm = true }) { media in self.preUploadedMedia.append(media) @@ -836,11 +930,12 @@ func build_post(state: DamusState, action: PostAction, draft: DraftArtifacts) -> action: action, uploadedMedias: draft.media, references: draft.references, - filtered_pubkeys: draft.filtered_pubkeys + filtered_pubkeys: draft.filtered_pubkeys, + pollDraft: draft.pollDraft ) } -func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId], filtered_pubkeys: Set) -> NostrPost { +func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId], filtered_pubkeys: Set, pollDraft: PollDraft? = nil) -> NostrPost { // don't add duplicate pubkeys but retain order var pkset = Set() @@ -858,7 +953,7 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction, acc.append(pk) } - return build_post(state: state, post: post, action: action, uploadedMedias: uploadedMedias, pubkeys: pks) + return build_post(state: state, post: post, action: action, uploadedMedias: uploadedMedias, pubkeys: pks, pollDraft: pollDraft) } /// This builds a Nostr post from draft data from `PostView` or other draft-related classes @@ -874,7 +969,11 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction, /// - uploadedMedias: The medias attached to this post /// - pubkeys: The referenced pubkeys /// - Returns: A NostrPost, which can then be signed into an event. -func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) -> NostrPost { +func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey], pollDraft: PollDraft? = nil) -> NostrPost { + if let pollDraft, case .posting = action, let pollPost = build_poll_post(state: state, post: post, pollDraft: pollDraft) { + return pollPost + } + let post = NSMutableAttributedString(attributedString: post) post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in let linkValue = attributes[.link] @@ -956,6 +1055,61 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction, return NostrPost(content: content.trimmingCharacters(in: .whitespacesAndNewlines), kind: .text, tags: tags) } +func build_poll_post(state: DamusState, post: NSAttributedString, pollDraft: PollDraft) -> NostrPost? { + let question = post.string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !question.isEmpty else { return nil } + + let trimmedOptions = pollDraft.options + .map { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + guard trimmedOptions.count >= PollDraft.minimumOptions else { return nil } + + var optionTags: [[String]] = [] + var seenKeys: Set = [] + + for option in trimmedOptions { + let key = option.lowercased() + guard !seenKeys.contains(key) else { continue } + seenKeys.insert(key) + optionTags.append(["option", generateOptionIdentifier(), option]) + } + + guard optionTags.count >= PollDraft.minimumOptions else { return nil } + + var tags: [[String]] = optionTags + tags.append(["polltype", pollDraft.pollType.rawValue]) + + if let endsAt = pollDraft.endsAt { + let current = Date().timeIntervalSince1970 + let raw = endsAt.timeIntervalSince1970 + if raw > current { + let clamped = min(max(raw, 0), Double(UInt32.max)) + let seconds = UInt32(clamped.rounded()) + tags.append(["endsAt", String(seconds)]) + } + } + + let relayHints = defaultPollRelayHints(state: state) + for relay in relayHints { + tags.append(["relay", relay.absoluteString]) + } + + return NostrPost(content: question, kind: .poll, tags: tags) +} + +private func defaultPollRelayHints(state: DamusState) -> [RelayURL] { + let relayList = state.nostrNetwork.userRelayList.getBestEffortRelayList() + return relayList.relays.values.compactMap { item in + item.rwConfiguration.canWrite ? item.url : nil + } +} + +private func generateOptionIdentifier() -> String { + let base = UUID().uuidString.replacingOccurrences(of: "-", with: "") + return String(base.prefix(10)).lowercased() +} + func isSupportedVideo(url: URL?) -> Bool { guard let url = url else { return false } let fileExtension = url.pathExtension.lowercased() @@ -974,4 +1128,3 @@ func isSupportedImage(url: URL) -> Bool { let supportedTypes = ["jpg", "png", "gif"] return supportedTypes.contains(fileExtension) } - diff --git a/damus/Features/Profile/Models/ProfileModel.swift b/damus/Features/Profile/Models/ProfileModel.swift index 0bfb55199..09648b7d4 100644 --- a/damus/Features/Profile/Models/ProfileModel.swift +++ b/damus/Features/Profile/Models/ProfileModel.swift @@ -75,7 +75,7 @@ class ProfileModel: ObservableObject, Equatable { } func subscribe() { - var text_filter = NostrFilter(kinds: [.text, .longform, .highlight]) + var text_filter = NostrFilter(kinds: [.text, .longform, .highlight, .poll]) var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost]) var relay_list_filter = NostrFilter(kinds: [.relay_list], authors: [pubkey]) @@ -98,7 +98,7 @@ class ProfileModel: ObservableObject, Equatable { return } - let conversation_kinds: [NostrKind] = [.text, .longform, .highlight] + let conversation_kinds: [NostrKind] = [.text, .longform, .highlight, .poll] let limit: UInt32 = 500 let conversations_filter_them = NostrFilter(kinds: conversation_kinds, pubkeys: [damus.pubkey], limit: limit, authors: [pubkey]) let conversations_filter_us = NostrFilter(kinds: conversation_kinds, pubkeys: [pubkey], limit: limit, authors: [damus.pubkey]) @@ -122,7 +122,7 @@ class ProfileModel: ObservableObject, Equatable { } private func add_event(_ ev: NostrEvent) { - if ev.is_textlike || ev.known_kind == .boost { + if ev.is_textlike || ev.known_kind == .boost || ev.known_kind == .poll { if self.events.insert(ev) { self.objectWillChange.send() } diff --git a/damus/Features/Timeline/Models/HomeModel.swift b/damus/Features/Timeline/Models/HomeModel.swift index a18fd4382..3430b932e 100644 --- a/damus/Features/Timeline/Models/HomeModel.swift +++ b/damus/Features/Timeline/Models/HomeModel.swift @@ -192,6 +192,10 @@ class HomeModel: ContactsDelegate { switch kind { case .chat, .longform, .text, .highlight: handle_text_event(sub_id: sub_id, ev) + case .poll: + handle_poll_event(sub_id: sub_id, ev) + case .poll_response: + handle_poll_response(ev) case .contacts: handle_contact_event(sub_id: sub_id, relay_id: relay_id, ev: ev) case .metadata: @@ -623,7 +627,7 @@ class HomeModel: ContactsDelegate { func subscribe_to_home_filters(friends fs: [Pubkey]? = nil, relay_id: RelayURL? = nil) { // TODO: separate likes? var home_filter_kinds: [NostrKind] = [ - .text, .longform, .boost, .highlight + .text, .longform, .boost, .highlight, .poll ] if !damus_state.settings.onlyzaps_mode { home_filter_kinds.append(.like) @@ -777,6 +781,19 @@ class HomeModel: ContactsDelegate { } } + func handle_poll_event(sub_id: String, _ ev: NostrEvent) { + Task { @MainActor in + damus_state.polls.registerPollEvent(ev) + } + handle_text_event(sub_id: sub_id, ev) + } + + func handle_poll_response(_ ev: NostrEvent) { + Task { @MainActor in + damus_state.polls.registerResponseEvent(ev) + } + } + func got_new_dm(notifs: NewEventsBits, ev: NostrEvent) { notification_status.new_events = notifs @@ -1190,4 +1207,3 @@ func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale: } } } - diff --git a/damus/TestData.swift b/damus/TestData.swift index 716e3bfc0..2bd0fb1f2 100644 --- a/damus/TestData.swift +++ b/damus/TestData.swift @@ -83,6 +83,10 @@ var test_damus_state: DamusState = ({ let our_pubkey = test_pubkey let pool = RelayPool(ndb: ndb) let settings = UserSettingsStore() + let polls: PollResultsStore = MainActor.assumeIsolated { + PollResultsStore() + } + let damus = DamusState(keypair: test_keypair, likes: .init(our_pubkey: our_pubkey), boosts: .init(our_pubkey: our_pubkey), @@ -92,6 +96,7 @@ var test_damus_state: DamusState = ({ dms: .init(our_pubkey: our_pubkey), previews: .init(), zaps: .init(our_pubkey: our_pubkey), + polls: polls, lnurls: .init(), settings: settings, relay_filters: .init(our_pubkey: our_pubkey), @@ -448,5 +453,3 @@ let test_thread_note_4 = NdbNote.owned_from_json(json: "{\"created_at\":17181816 let test_thread_note_5 = NdbNote.owned_from_json(json: "{\"created_at\":1718188975,\"tags\":[[\"e\",\"d98e197facb1c9fdeddc1a1caf53060114138e9af73745fd2eb0f7f432df806c\",\"\",\"root\"],[\"p\",\"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0\"],[\"p\",\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\"]],\"pubkey\":\"59cacbd83ad5c54ad91dacf51a49c06e0bef730ac0e7c235a6f6fa29b9230f02\",\"kind\":1,\"content\":\"I’m pretty sure it doesn’t. nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s\",\"sig\":\"74919775e50588aa76de57ef75541678b9618d35849fbee9ea70d84fc38a15610a13d82c48c1d2b62b2c9fe0117be91fa2291b0d93b8fe0209dc3ae3749188d8\",\"id\":\"6e953d06e3cdf6119b7cc4bfdef5139acb410b9bf79c02cd4ca0f2e1bbe6b572\"}")! let test_thread_note_6 = NdbNote.owned_from_json(json: "{\"pubkey\":\"17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4\",\"content\":\"😢\n\ncc nostr:npub13v47pg9dxjq96an8jfev9znhm0k7ntwtlh9y335paj9kyjsjpznqzzl3l8 \n\nSoon™️\",\"sig\":\"3ba1b651bc6c288b31948ee1f8680b7735d8c00e2daa55c3ac8973d111d7bfef2db28bb4a36c912dd803238b2bee9b7054aea387418aa4db33d4991c61a253e1\",\"kind\":1,\"id\":\"62769f438ec80edfd3995358159a9427bf037283affe7790d4f1cacd5837d88f\",\"created_at\":1718403197,\"tags\":[[\"e\",\"d98e197facb1c9fdeddc1a1caf53060114138e9af73745fd2eb0f7f432df806c\",\"\",\"root\"],[\"e\",\"6e953d06e3cdf6119b7cc4bfdef5139acb410b9bf79c02cd4ca0f2e1bbe6b572\",\"\",\"reply\"],[\"p\",\"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0\"],[\"p\",\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\"],[\"p\",\"59cacbd83ad5c54ad91dacf51a49c06e0bef730ac0e7c235a6f6fa29b9230f02\"],[\"p\",\"8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6\"]]}")! let test_thread_note_7 = NdbNote.owned_from_json(json: "{\"kind\":1,\"id\":\"06007f699f5240f90a6e508d2e89e8bef75348c3ebdde43d58d72311f49693a3\",\"tags\":[[\"e\",\"d98e197facb1c9fdeddc1a1caf53060114138e9af73745fd2eb0f7f432df806c\",\"\",\"root\"],[\"e\",\"62769f438ec80edfd3995358159a9427bf037283affe7790d4f1cacd5837d88f\",\"\",\"reply\"],[\"p\",\"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0\"],[\"p\",\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\"],[\"p\",\"8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6\"],[\"p\",\"17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4\"]],\"created_at\":1718403771,\"pubkey\":\"59cacbd83ad5c54ad91dacf51a49c06e0bef730ac0e7c235a6f6fa29b9230f02\",\"sig\":\"72ca242d2c82d0440dbb198822b3a1885a4dac5bc44ef02d8f9760a591d16340ab66649339e796b2810894d89f56c240194bf8d8bfefc833cbbd72d004077c74\",\"content\":\"Would love to see it! 🌐\"}")! - - diff --git a/damus/damusApp.swift b/damus/damusApp.swift index a53f57842..f1550a95d 100644 --- a/damus/damusApp.swift +++ b/damus/damusApp.swift @@ -12,6 +12,11 @@ import StoreKit @main struct damusApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + init() { + PollEventViewFactory.registerAppBuilder() + } + var body: some Scene { WindowGroup { MainView(appDelegate: appDelegate) diff --git a/damus/en-US.lproj/Localizable.strings b/damus/en-US.lproj/Localizable.strings index 701f3edc5..7ad5ad7e8 100644 Binary files a/damus/en-US.lproj/Localizable.strings and b/damus/en-US.lproj/Localizable.strings differ diff --git a/damusTests/Mocking/MockDamusState.swift b/damusTests/Mocking/MockDamusState.swift index fa8c13237..b34110c3f 100644 --- a/damusTests/Mocking/MockDamusState.swift +++ b/damusTests/Mocking/MockDamusState.swift @@ -35,6 +35,7 @@ func generate_test_damus_state( dms: .init(our_pubkey: our_pubkey), previews: .init(), zaps: .init(our_pubkey: our_pubkey), + polls: MainActor.assumeIsolated { PollResultsStore() }, lnurls: .init(), settings: settings, relay_filters: .init(our_pubkey: our_pubkey), diff --git a/damusTests/PollEventViewFactoryTests.swift b/damusTests/PollEventViewFactoryTests.swift new file mode 100644 index 000000000..5f210e45c --- /dev/null +++ b/damusTests/PollEventViewFactoryTests.swift @@ -0,0 +1,43 @@ +import XCTest +import SwiftUI +@testable import damus + +final class PollEventViewFactoryTests: XCTestCase { + + @MainActor + func testFactoryProducesPollViewAfterRegistration() throws { + let originalBuilder = PollEventViewFactory.builder + defer { PollEventViewFactory.builder = originalBuilder } + + PollEventViewFactory.registerAppBuilder() + + let damusState = generate_test_damus_state(mock_profile_info: nil) + let event = makePollEvent(state: damusState) + + let poll = try XCTUnwrap(PollEvent(event: event)) + damusState.polls.registerPollEvent(event) + + let view = PollEventViewFactory.makePollEventView(damus: damusState, event: event, poll: poll, options: []) + XCTAssertNotNil(view, "PollEventViewFactory should return a view once the app builder is registered.") + } + + private func makePollEvent(state: DamusState) -> NostrEvent { + let pollDraft = PollDraft( + options: [ + PollDraftOption(id: UUID(), text: "Apples"), + PollDraftOption(id: UUID(), text: "Bananas"), + PollDraftOption(id: UUID(), text: "Cherries") + ], + pollType: .singleChoice, + endsAt: Date().addingTimeInterval(900) + ) + + let post = build_poll_post( + state: state, + post: NSAttributedString(string: "What's your favourite fruit?"), + pollDraft: pollDraft + ) + + return post!.to_event(keypair: test_keypair_full)! + } +} diff --git a/damusTests/PollTests.swift b/damusTests/PollTests.swift new file mode 100644 index 000000000..51fc64f6a --- /dev/null +++ b/damusTests/PollTests.swift @@ -0,0 +1,209 @@ +import XCTest +@testable import damus + +final class PollTests: XCTestCase { + + @MainActor + override func tearDown() { + PollEventViewFactory.builder = { _, _, _, _ in nil } + super.tearDown() + } + + func testPollEventParsing() { + let pollEvent = makePollEvent( + question: "Favorite fruit?", + optionTuples: [ + ("opt1", "Apples"), + ("opt2", "Bananas") + ], + pollType: .singleChoice, + endsAt: UInt32(Date().addingTimeInterval(3600).timeIntervalSince1970) + ) + + guard let poll = PollEvent(event: pollEvent) else { + XCTFail("Failed to parse poll event") + return + } + + XCTAssertEqual(poll.question, "Favorite fruit?") + XCTAssertEqual(poll.options.count, 2) + XCTAssertEqual(poll.options.first?.id, "opt1") + XCTAssertEqual(poll.pollType, .singleChoice) + XCTAssertNotNil(poll.endsAt) + } + + @MainActor + func testPollResultsStoreSubmitVote() { + let pollEvent = makePollEvent( + question: "Best season", + optionTuples: [ + ("spring", "Spring"), + ("summer", "Summer"), + ("fall", "Fall"), + ("winter", "Winter") + ], + pollType: .singleChoice, + endsAt: UInt32(Date().addingTimeInterval(7200).timeIntervalSince1970) + ) + + guard let poll = PollEvent(event: pollEvent) else { + XCTFail("Failed to parse poll event") + return + } + + let damusState = generate_test_damus_state(mock_profile_info: nil) + + let store: PollResultsStore = MainActor.assumeIsolated { PollResultsStore() } + store.registerPollEvent(pollEvent) + + let result = store.submitVote(for: poll, selections: ["summer"], context: damusState) + + switch result { + case .success: + break + case .failure(let error): + XCTFail("Vote submission failed with error: \(error)") + } + + guard let state = store.state(for: poll.id) else { + XCTFail("Poll state not found") + return + } + + XCTAssertEqual(state.voterCount, 1) + XCTAssertEqual(state.tallies["summer"], 1) + XCTAssertTrue(state.hasVoted(pubkey: damusState.pubkey)) + } + + @MainActor + func testEnsureResultsSubscribesAndProcessesResponses() async throws { + let pollEvent = makePollEvent( + question: "Best snack?", + optionTuples: [ + ("chips", "Chips"), + ("choco", "Chocolate") + ], + pollType: .singleChoice, + endsAt: nil + ) + + let store = PollResultsStore() + guard let poll = PollEvent(event: pollEvent) else { + XCTFail("Failed to parse poll event") + return + } + + store.registerPollEvent(pollEvent) + + let network = MockPollNetwork() + store.ensureResults(for: poll, network: network) + + XCTAssertEqual(network.subscribeCalls.count, 1) + XCTAssertEqual(network.subscribeCalls.first?.subId, "poll-\(poll.id.hex())") + + let responseEvent = NostrEvent( + content: "", + keypair: test_keypair, + kind: NostrKind.poll_response.rawValue, + tags: [ + ["e", poll.id.hex()], + ["response", "choco"] + ] + )! + + guard let handler = network.subscribeCalls.first?.handler else { + XCTFail("Expected subscription handler to be captured") + return + } + + handler(.event(network.subscribeCalls.first!.subId, responseEvent)) + + // Allow the asynchronous Task in ensureResults to process on the main actor. + await Task.yield() + + XCTAssertTrue(store.hasVoted(pollId: poll.id, pubkey: responseEvent.pubkey)) + XCTAssertEqual(store.selections(for: poll.id, pubkey: responseEvent.pubkey), ["choco"]) + } + + @MainActor + func testSubmitVoteUsesVotingContextNetwork() throws { + let pollEvent = makePollEvent( + question: "Best season", + optionTuples: [ + ("spring", "Spring"), + ("summer", "Summer") + ], + pollType: .singleChoice, + endsAt: nil + ) + + let store = PollResultsStore() + guard let poll = PollEvent(event: pollEvent) else { + XCTFail("Failed to parse poll event") + return + } + + store.registerPollEvent(pollEvent) + + let network = MockPollNetwork() + let context = MockVotingContext( + keypair: test_keypair_full.to_keypair(), + pollNetwork: network + ) + + let result = store.submitVote(for: poll, selections: ["summer"], context: context) + switch result { + case .success: + break + case .failure(let error): + XCTFail("Vote submission failed with error: \(error)") + } + + XCTAssertEqual(network.sendCalls.count, 1) + XCTAssertEqual(network.sendCalls.first?.event.kind, NostrKind.poll_response.rawValue) + } + + // MARK: - Helpers + + private func makePollEvent(question: String, optionTuples: [(String, String)], pollType: PollType, endsAt: UInt32?) -> NostrEvent { + let tags: [[String]] = optionTuples.map { ["option", $0.0, $0.1] } + + [["polltype", pollType.rawValue]] + + (endsAt.map { [["endsAt", String($0)]] } ?? []) + + return NostrEvent( + content: question, + keypair: test_keypair, + kind: NostrKind.poll.rawValue, + tags: tags + )! + } + + private final class MockPollNetwork: PollResponseNetworking { + struct SubscribeCall { + let poll: PollEvent + let subId: String + let handler: (NostrResponse) -> Void + } + + struct SendCall { + let event: NostrEvent + let relayHints: [RelayURL]? + } + + private(set) var subscribeCalls: [SubscribeCall] = [] + private(set) var sendCalls: [SendCall] = [] + + func subscribeToPollResponses(poll: PollEvent, subId: String, handler: @escaping (NostrResponse) -> Void) { + subscribeCalls.append(SubscribeCall(poll: poll, subId: subId, handler: handler)) + } + + func sendPollResponseEvent(_ event: NostrEvent, relayHints: [RelayURL]?) { + sendCalls.append(SendCall(event: event, relayHints: relayHints)) + } + } + + private struct MockVotingContext: PollVotingContext { + let keypair: Keypair + let pollNetwork: PollResponseNetworking + } +} diff --git a/share extension/PollEventViewFactory+Stub.swift b/share extension/PollEventViewFactory+Stub.swift new file mode 100644 index 000000000..ee6b82662 --- /dev/null +++ b/share extension/PollEventViewFactory+Stub.swift @@ -0,0 +1,14 @@ +// +// PollEventViewFactory+Stub.swift +// ShareExtension +// +// Created by ChatGPT on 2025-04-11. +// + +import Foundation + +extension PollEventViewFactory { + static func registerAppBuilder() { + builder = { _, _, _, _ in nil } + } +}