diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 33ce06d0c7..9dbf3f865a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -175,7 +175,6 @@ 942ADDD42D9F9613006E0BB0 /* NewTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */; }; 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9402E4487EE007C4595 /* LightBox.swift */; }; 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */; }; - 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; 942BA9C42E55AB54007C4595 /* UILabel+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */; }; 94363E5B2E6002750004EE43 /* SessionListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E5A2E60026F0004EE43 /* SessionListScreen.swift */; }; 94363E5E2E6002960004EE43 /* SessionListScreen+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E5D2E6002940004EE43 /* SessionListScreen+Models.swift */; }; @@ -203,7 +202,6 @@ 945E89D22E95D54700D8D907 /* SessionProPaymentScreen+NoBillingAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945E89D12E95D54000D8D907 /* SessionProPaymentScreen+NoBillingAccess.swift */; }; 945E89D42E95D97000D8D907 /* SessionProPaymentScreen+SharedViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945E89D32E95D96100D8D907 /* SessionProPaymentScreen+SharedViews.swift */; }; 945E89D62E9602AB00D8D907 /* SessionProPaymentScreen+Purchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945E89D52E96028B00D8D907 /* SessionProPaymentScreen+Purchase.swift */; }; - 9463794A2E7131070017A014 /* SessionProManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946379492E71308B0017A014 /* SessionProManagerType.swift */; }; 9463794C2E71371F0017A014 /* SessionProPaymentScreen+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9463794B2E7137120017A014 /* SessionProPaymentScreen+Models.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */; }; @@ -219,7 +217,6 @@ 94805EB22EB087FD0055EBBC /* BottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EB12EB087F90055EBBC /* BottomSheet.swift */; }; 94805EB92EB1E16D0055EBBC /* SessionProSettings+ProFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EB82EB1E1650055EBBC /* SessionProSettings+ProFeatures.swift */; }; 94805EBF2EB462C40055EBBC /* TransitionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EBE2EB462C10055EBBC /* TransitionType.swift */; }; - 94805EC12EB48D910055EBBC /* SessionProState+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EC02EB48D860055EBBC /* SessionProState+Models.swift */; }; 94805EC32EB48ED50055EBBC /* SessionProPaymentScreenContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EC22EB48EC40055EBBC /* SessionProPaymentScreenContent.swift */; }; 94805EC62EB823B80055EBBC /* DismissType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EC52EB823B00055EBBC /* DismissType.swift */; }; 94805EC82EB834D40055EBBC /* UINavigationController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EC72EB834CD0055EBBC /* UINavigationController+Utilities.swift */; }; @@ -234,7 +231,6 @@ 948615CB2ED7D6E5000A5666 /* NavigatableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948615CA2ED7D6E5000A5666 /* NavigatableState.swift */; }; 9499E6032DDD9BF900091434 /* ExpandableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */; }; 9499E68B2DF92F4E00091434 /* ThreadNotificationSettingsViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */; }; - 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */; }; 94AAB14B2E1E198200A6FA18 /* Modal+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */; }; 94AAB14D2E1F39B500A6FA18 /* ProCTAModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */; }; 94AAB14F2E1F6CC100A6FA18 /* SessionProBadge+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB14E2E1F6CB300A6FA18 /* SessionProBadge+SwiftUI.swift */; }; @@ -244,7 +240,7 @@ 94AAB1582E24BD3700A6FA18 /* PinnedConversationsCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB1562E24BD3700A6FA18 /* PinnedConversationsCTA.webp */; }; 94AAB15E2E24C97400A6FA18 /* AnimatedProfileCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */; }; 94AAB1602E24C97400A6FA18 /* AnimatedProfileCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */; }; - 94B6BAF62E30A88800E718BB /* SessionProState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAF52E30A88800E718BB /* SessionProState.swift */; }; + 94B6BAF62E30A88800E718BB /* SessionProManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAF52E30A88800E718BB /* SessionProManager.swift */; }; 94B6BAFE2E39F51800E718BB /* UserProfileModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */; }; 94B6BB002E3AE83C00E718BB /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */; }; 94B6BB022E3AE85C00E718BB /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BB012E3AE85800E718BB /* QRCode.swift */; }; @@ -253,7 +249,6 @@ 94B6BB072E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */; }; 94C58AC92D2E037200609195 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C58AC82D2E036E00609195 /* Permissions.swift */; }; 94CD95BB2E08D9E00097754D /* SessionProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */; }; - 94CD95BD2E09083C0097754D /* LibSession+Pro.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD95BC2E0908340097754D /* LibSession+Pro.swift */; }; 94CD95C12E0CBF430097754D /* _044_AddProMessageFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD95C02E0CBF1C0097754D /* _044_AddProMessageFlag.swift */; }; 94CD962D2E1B85920097754D /* InputViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD962B2E1B85920097754D /* InputViewButton.swift */; }; 94CD962E2E1B85920097754D /* InputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD962A2E1B85920097754D /* InputTextView.swift */; }; @@ -261,7 +256,6 @@ 94CD96412E1BABE90097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; 94CD96452E1BAC0F0097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */; }; - 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716812E8FA19D008294EE /* AttributedLabel.swift */; }; 94D716862E933958008294EE /* SessionProBadge+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */; }; 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; @@ -290,7 +284,6 @@ B877E24226CA12910007970A /* CallVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B877E24126CA12910007970A /* CallVC.swift */; }; B877E24626CA13BA0007970A /* CallVC+Camera.swift in Sources */ = {isa = PBXBuildFile; fileRef = B877E24526CA13BA0007970A /* CallVC+Camera.swift */; }; B879D449247E1BE300DB3608 /* PathVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B879D448247E1BE300DB3608 /* PathVC.swift */; }; - B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */; }; B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF281255B6D84007E1867 /* OWSAudioSession.swift */; }; B8856D23256F116B001CE70E /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2EF255B6DBB007E1867 /* Weak.swift */; }; @@ -320,7 +313,6 @@ B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DE1FB526C22FCB0079C9CE /* CallMessage.swift */; }; B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */; }; B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */; }; - B8F5F58325EC94A6003BF8D4 /* Collection+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F58225EC94A6003BF8D4 /* Collection+Utilities.swift */; }; B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */; }; B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */; }; B8FF8DAE25C0D00F004D1F22 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; }; @@ -371,7 +363,6 @@ C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF241255B6D67007E1867 /* Collection+OWS.swift */; }; C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */; }; C38EF372255B6DCC007E1867 /* MediaMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF358255B6DCC007E1867 /* MediaMessageView.swift */; }; - C38EF385255B6DD2007E1867 /* AttachmentTextToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37C255B6DCF007E1867 /* AttachmentTextToolbar.swift */; }; C38EF387255B6DD2007E1867 /* AttachmentItemCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37E255B6DD0007E1867 /* AttachmentItemCollection.swift */; }; C38EF388255B6DD2007E1867 /* AttachmentApprovalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37F255B6DD0007E1867 /* AttachmentApprovalViewController.swift */; }; C38EF389255B6DD2007E1867 /* AttachmentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF380255B6DD0007E1867 /* AttachmentTextView.swift */; }; @@ -462,7 +453,7 @@ FD05594E2E012D2700DC48CE /* _043_RenameAttachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD05594D2E012D1A00DC48CE /* _043_RenameAttachments.swift */; }; FD0559562E026E1B00DC48CE /* ObservingDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0559542E026CC900DC48CE /* ObservingDatabase.swift */; }; FD0606C32BCE13ED00C3816E /* MessageRequestFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0606C22BCE13ED00C3816E /* MessageRequestFooterView.swift */; }; - FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */; }; + FD078E5427E197CA000769AF /* CommunityManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* CommunityManagerSpec.swift */; }; FD0969F92A69FFE700C5C365 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; FD0969FA2A6A00B000C5C365 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; FD0969FB2A6A00B100C5C365 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; @@ -484,11 +475,22 @@ FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E6288670FD00ED0B66 /* Reaction.swift */; }; FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; }; FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; }; - FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */; }; FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5EB282B8F17000CE219 /* AttachmentError.swift */; }; FD0B77B029B69A65009169BA /* TopBannerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0B77AF29B69A65009169BA /* TopBannerController.swift */; }; FD0B77B229B82B7A009169BA /* ArrayUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */; }; FD0E353C2AB9880B006A81F7 /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0E353A2AB98773006A81F7 /* AppVersion.swift */; }; + FD0F85612EA82C8B004E0B98 /* SessionPro.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85602EA82C87004E0B98 /* SessionPro.swift */; }; + FD0F85632EA82DF9004E0B98 /* SessionProAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85622EA82DF6004E0B98 /* SessionProAPI.swift */; }; + FD0F85662EA82FCC004E0B98 /* PaymentProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85652EA82FC9004E0B98 /* PaymentProvider.swift */; }; + FD0F85682EA83385004E0B98 /* SessionProEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85672EA83382004E0B98 /* SessionProEndpoint.swift */; }; + FD0F856B2EA83525004E0B98 /* AppProPaymentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F856A2EA8351E004E0B98 /* AppProPaymentRequest.swift */; }; + FD0F856D2EA835C5004E0B98 /* Signatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F856C2EA835B6004E0B98 /* Signatures.swift */; }; + FD0F856F2EA83664004E0B98 /* UserTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F856E2EA83661004E0B98 /* UserTransaction.swift */; }; + FD0F85732EA83C44004E0B98 /* AnyCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85722EA83C41004E0B98 /* AnyCodable.swift */; }; + FD0F85752EA83D5D004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85742EA83D49004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift */; }; + FD0F85772EA83D92004E0B98 /* ProProof.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85762EA83D8F004E0B98 /* ProProof.swift */; }; + FD0F85792EA83EAD004E0B98 /* ResponseHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */; }; + FD0F857B2EA85FAB004E0B98 /* Request+SessionProAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */; }; FD10AF0C2AF32B9A007709E5 /* SessionListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD10AF0B2AF32B9A007709E5 /* SessionListViewModel.swift */; }; FD10AF122AF85D11007709E5 /* Feature+ServiceNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD10AF112AF85D11007709E5 /* Feature+ServiceNetwork.swift */; }; FD11E22D2CA4D12C001BAF58 /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD2286782C38D4FF00BC06F7 /* DifferenceKit */; }; @@ -507,6 +509,7 @@ FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */; }; FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B727F51ECA00122BE0 /* Migration.swift */; }; FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E427F6A09900122BE0 /* Identity.swift */; }; + FD184C232EF2100D001089EB /* SessionProError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD184C222EF2100A001089EB /* SessionProError.swift */; }; FD19363C2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD19363B2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift */; }; FD19363F2ACA66DE004BCF0F /* DatabaseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD19363E2ACA66DE004BCF0F /* DatabaseSpec.swift */; }; FD1A553E2E14BE11003761E4 /* PagedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A553D2E14BE0E003761E4 /* PagedData.swift */; }; @@ -515,6 +518,22 @@ FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */; }; FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; FD1D732E2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D732D2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift */; }; + FD1DD8B52EF3ACBA009F2C1B /* LinkPreviewManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36B82EB3FBC20040603E /* LinkPreviewManagerType.swift */; }; + FD1DD8B62EF3ACC9009F2C1B /* VoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */; }; + FD1DD8B72EF3ACCF009F2C1B /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; + FD1DD8B82EF3ACDF009F2C1B /* AttributedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716812E8FA19D008294EE /* AttributedLabel.swift */; }; + FD1DD8B92EF3ACE5009F2C1B /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; + FD1DD8BA2EF3ACF5009F2C1B /* ThemeMessagePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9DA28A244E9003AE748 /* ThemeMessagePreviewView.swift */; }; + FD1DD8BB2EF3AD04009F2C1B /* TimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */; }; + FD1DD8BC2EF3AD0C009F2C1B /* SessionAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA872E24810900148F8D /* SessionAsyncImage.swift */; }; + FD1F3CEB2ED5728100E536D5 /* SetPaymentRefundRequestedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEA2ED5728000E536D5 /* SetPaymentRefundRequestedRequest.swift */; }; + FD1F3CED2ED5728600E536D5 /* SetPaymentRefundRequestedResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */; }; + FD1F3CEF2ED6509900E536D5 /* SessionProUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */; }; + FD1F3CF32ED657AC00E536D5 /* Constants+LibSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF22ED657A800E536D5 /* Constants+LibSession.swift */; }; + FD1F3CF62ED69B6600E536D5 /* SessionProState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF52ED69B6200E536D5 /* SessionProState.swift */; }; + FD1F3CF82ED6A6F400E536D5 /* SessionProRefundingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */; }; + FD1F3CFA2ED7B34C00E536D5 /* SessionProMessageFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF92ED7B34700E536D5 /* SessionProMessageFeatures.swift */; }; + FD1F3CFC2ED7F37600E536D5 /* StringProviders.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CFB2ED7F37300E536D5 /* StringProviders.swift */; }; FD22726B2C32911C004D8A6C /* SendReadReceiptsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272532C32911A004D8A6C /* SendReadReceiptsJob.swift */; }; FD22726C2C32911C004D8A6C /* GroupLeavingJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272542C32911A004D8A6C /* GroupLeavingJob.swift */; }; FD22726D2C32911C004D8A6C /* CheckForAppUpdatesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272552C32911A004D8A6C /* CheckForAppUpdatesJob.swift */; }; @@ -599,11 +618,10 @@ FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; }; FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; }; FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */; }; - FD245C55285065E500B966DD /* OpenGroupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */; }; + FD245C55285065E500B966DD /* CommunityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* CommunityManager.swift */; }; FD245C56285065EA00B966DD /* SNProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7822553AAF200C340D1 /* SNProto.swift */; }; FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7702553A41E00C340D1 /* ControlMessage.swift */; }; FD245C5B2850660500B966DD /* ReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5BC2554B00D00555489 /* ReadReceipt.swift */; }; - FD245C5D2850660F00B966DD /* OWSAudioPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */; }; FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF306255B6DBE007E1867 /* OWSWindowManager.m */; }; FD245C632850664600B966DD /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD245C612850664300B966DD /* Configuration.swift */; }; FD245C682850666300B966DD /* Message+Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A30825574D8400338F3E /* Message+Destination.swift */; }; @@ -616,11 +634,27 @@ FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; }; + FD2C68612EA09527000B0E37 /* MessageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2C68602EA09523000B0E37 /* MessageError.swift */; }; + FD2CFB8E2EDD00F500EC7F98 /* SessionProOriginatingAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB8D2EDD00EE00EC7F98 /* SessionProOriginatingAccount.swift */; }; + FD2CFB932EDD0B4300EC7F98 /* BuildVariant.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB922EDD0B3F00EC7F98 /* BuildVariant.swift */; }; + FD2CFB972EDE645D00EC7F98 /* SessionPro+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB962EDE645900EC7F98 /* SessionPro+Convenience.swift */; }; + FD2CFB992EDFF32E00EC7F98 /* ConversationDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB982EDFF2FD00EC7F98 /* ConversationDataCache.swift */; }; + FD2CFB9B2EE0FECE00EC7F98 /* ConversationDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB9A2EE0FECA00EC7F98 /* ConversationDataHelper.swift */; }; + FD2CFB9D2EE3F63600EC7F98 /* GroupAuthData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB9C2EE3F63400EC7F98 /* GroupAuthData.swift */; }; + FD2CFB9F2EE6293B00EC7F98 /* GlobalSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB9E2EE6293700EC7F98 /* GlobalSearch.swift */; }; FD2DD5902C6DD13C0073D9BE /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD2DD58F2C6DD13C0073D9BE /* DifferenceKit */; }; + FD306BCC2EB02D9E00ADB003 /* GetProDetailsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */; }; + FD306BCE2EB02E3600ADB003 /* Signature.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCD2EB02E3400ADB003 /* Signature.swift */; }; + FD306BD02EB02F3900ADB003 /* GetProDetailsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCF2EB02F3500ADB003 /* GetProDetailsResponse.swift */; }; + FD306BD22EB031AE00ADB003 /* PaymentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD12EB031AB00ADB003 /* PaymentItem.swift */; }; + FD306BD42EB031C200ADB003 /* PaymentStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */; }; + FD306BD62EB0323000ADB003 /* BackendUserProStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */; }; + FD306BD82EB033CD00ADB003 /* Plan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD72EB033CB00ADB003 /* Plan.swift */; }; + FD306BDC2EB0436C00ADB003 /* GenerateProProofRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BDB2EB0436800ADB003 /* GenerateProProofRequest.swift */; }; FD336F602CAA28CF00C0B51B /* CommonSMKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */; }; FD336F612CAA28CF00C0B51B /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; FD336F622CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */; }; - FD336F632CAA28CF00C0B51B /* MockOGMCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5D2CAA28CF00C0B51B /* MockOGMCache.swift */; }; + FD336F632CAA28CF00C0B51B /* MockCommunityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5D2CAA28CF00C0B51B /* MockCommunityManager.swift */; }; FD336F642CAA28CF00C0B51B /* MockCommunityPollerCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */; }; FD336F652CAA28CF00C0B51B /* MockDisplayPictureCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F592CAA28CF00C0B51B /* MockDisplayPictureCache.swift */; }; FD336F662CAA28CF00C0B51B /* MockSwarmPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5F2CAA28CF00C0B51B /* MockSwarmPoller.swift */; }; @@ -656,9 +690,18 @@ FD360EB92ECAB1470050CAF4 /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = FD360EB82ECAB1470050CAF4 /* Punycode */; }; FD360EBB2ECAB1500050CAF4 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = FD360EBA2ECAB1500050CAF4 /* SwiftProtobuf */; }; FD360EBD2ECAB15A0050CAF4 /* Lucide in Frameworks */ = {isa = PBXBuildFile; productRef = FD360EBC2ECAB15A0050CAF4 /* Lucide */; }; + FD360EBF2ECAD5190050CAF4 /* SessionProConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */; }; + FD360EC12ECD239B0050CAF4 /* GetProRevocationsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC02ECD23950050CAF4 /* GetProRevocationsRequest.swift */; }; + FD360EC32ECD23A40050CAF4 /* GetProRevocationsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC22ECD239D0050CAF4 /* GetProRevocationsResponse.swift */; }; + FD360EC52ECD24C30050CAF4 /* RevocationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC42ECD24C00050CAF4 /* RevocationItem.swift */; }; + FD360EC72ECD38750050CAF4 /* OptionSet+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC62ECD38710050CAF4 /* OptionSet+Utilities.swift */; }; FD360EC92ECD3EB20050CAF4 /* DonationCTAModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC82ECD3EAE0050CAF4 /* DonationCTAModal.swift */; }; FD360ECB2ECD59550050CAF4 /* DonationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECA2ECD59520050CAF4 /* DonationsManager.swift */; }; FD360ECD2ECD70590050CAF4 /* DeveloperSettingsModalsAndBannersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECC2ECD70510050CAF4 /* DeveloperSettingsModalsAndBannersViewModel.swift */; }; + FD360ECF2ECEE5F60050CAF4 /* SessionProLoadingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */; }; + FD360ED42ED035150050CAF4 /* ImageLoading+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */; }; + FD360ED62ED3D2280050CAF4 /* ObservationUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED52ED3D2250050CAF4 /* ObservationUtilities.swift */; }; + FD360ED82ED3E5C20050CAF4 /* SessionProPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */; }; FD360EDA2ED3E8BC0050CAF4 /* DonationsCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = FD360ED92ED3E8BC0050CAF4 /* DonationsCTA.webp */; }; FD360EDB2ED3E8BC0050CAF4 /* DonationsCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = FD360ED92ED3E8BC0050CAF4 /* DonationsCTA.webp */; }; FD368A6829DE8F9C000DBF1E /* _026_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */; }; @@ -680,7 +723,6 @@ FD37E9D528A1FCE8003AE748 /* Theme+OceanLight.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9D428A1FCE8003AE748 /* Theme+OceanLight.swift */; }; FD37E9D728A20B5D003AE748 /* UIColor+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9D628A20B5D003AE748 /* UIColor+Utilities.swift */; }; FD37E9D928A230F2003AE748 /* TraitObservingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9D828A230F2003AE748 /* TraitObservingWindow.swift */; }; - FD37E9DB28A244E9003AE748 /* ThemeMessagePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9DA28A244E9003AE748 /* ThemeMessagePreviewView.swift */; }; FD37E9DD28A384EB003AE748 /* PrimaryColorSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9DC28A384EB003AE748 /* PrimaryColorSelectionView.swift */; }; FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9F528A5F106003AE748 /* Configuration.swift */; }; FD37E9FF28A5F2CD003AE748 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9FE28A5F2CD003AE748 /* Configuration.swift */; }; @@ -697,7 +739,7 @@ FD39370C2E4D7BCA00571F17 /* DocumentPickerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD39370B2E4D7BBE00571F17 /* DocumentPickerHandler.swift */; }; FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */; }; - FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; + FD3E0C84283B5835002A425C /* ConversationInfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* ConversationInfoViewModel.swift */; }; FD3F2EE72DE6CC4100FD6849 /* NotificationsManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3F2EE62DE6CC3B00FD6849 /* NotificationsManagerSpec.swift */; }; FD3F2EF22DF273D900FD6849 /* ThemedAttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3F2EF12DF273D100FD6849 /* ThemedAttributedString.swift */; }; FD3FAB592ADF906300DC5421 /* Profile+Updating.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB582ADF906300DC5421 /* Profile+Updating.swift */; }; @@ -894,6 +936,7 @@ FD716E6C28505E1C00C96BF4 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */; }; FD716E7128505E5200C96BF4 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */; }; FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; + FD71B9B02EF25A1200379A99 /* GlobalSearchSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71B9AF2EF25A0E00379A99 /* GlobalSearchSpec.swift */; }; FD72BDA12BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD72BDA02BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift */; }; FD72BDA42BE3690B00CF6CF6 /* CryptoSMKSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD72BDA32BE3690B00CF6CF6 /* CryptoSMKSpec.swift */; }; FD72BDA72BE369DC00CF6CF6 /* CryptoOpenGroupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD72BDA62BE369DC00CF6CF6 /* CryptoOpenGroupSpec.swift */; }; @@ -907,7 +950,6 @@ FD756BEB2D0181D700BD7199 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = FD756BEA2D0181D700BD7199 /* GRDB */; }; FD756BF02D06686500BD7199 /* Lucide in Frameworks */ = {isa = PBXBuildFile; productRef = FD756BEF2D06686500BD7199 /* Lucide */; }; FD756BF22D06687800BD7199 /* Lucide in Frameworks */ = {isa = PBXBuildFile; productRef = FD756BF12D06687800BD7199 /* Lucide */; }; - FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */; }; FD7728962849E7E90018502F /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728952849E7E90018502F /* String+Utilities.swift */; }; FD778B6429B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD778B6329B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift */; }; FD78E9EE2DD6D32500D55B50 /* ImageDataManager+Singleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.swift */; }; @@ -978,9 +1020,18 @@ FD981BCB2DC4A21C00564172 /* MessageDeduplicationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BCA2DC4A21800564172 /* MessageDeduplicationSpec.swift */; }; FD981BCD2DC81ABF00564172 /* MockExtensionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BCC2DC81ABB00564172 /* MockExtensionHelper.swift */; }; FD981BD32DC9770E00564172 /* MentionUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84664F4235022F30083A1CD /* MentionUtilities.swift */; }; - FD981BD52DC978B400564172 /* MentionUtilities+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */; }; FD981BD72DC9A61A00564172 /* NotificationCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD62DC9A61600564172 /* NotificationCategory.swift */; }; FD981BD92DC9A69600564172 /* NotificationUserInfoKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD82DC9A69000564172 /* NotificationUserInfoKey.swift */; }; + FD99A39F2EBAA5EA00E59F94 /* DecodedEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A39E2EBAA5E500E59F94 /* DecodedEnvelope.swift */; }; + FD99A3A22EBAA6AA00E59F94 /* Envelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A12EBAA6A500E59F94 /* Envelope.swift */; }; + FD99A3A42EBAA6BD00E59F94 /* EnvelopeFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */; }; + FD99A3A62EBAAA1700E59F94 /* DecodedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */; }; + FD99A3AC2EBC1B6E00E59F94 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3AB2EBC1B6C00E59F94 /* Server.swift */; }; + FD99A3B02EBD4EDD00E59F94 /* FetchablePair.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3AF2EBD4EDB00E59F94 /* FetchablePair.swift */; }; + FD99A3B22EC3E2F500E59F94 /* OWSAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3B12EC3E2EF00E59F94 /* OWSAudioPlayer.swift */; }; + FD99A3B62EC562DB00E59F94 /* _047_DropUnneededColumnsAndTables.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3B52EC562CB00E59F94 /* _047_DropUnneededColumnsAndTables.swift */; }; + FD99A3B82EC5882A00E59F94 /* AddProPaymentResponseStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3B72EC5882500E59F94 /* AddProPaymentResponseStatus.swift */; }; + FD99A3BA2EC58DE300E59F94 /* _048_SessionProChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3B92EC58DD500E59F94 /* _048_SessionProChanges.swift */; }; FD99D0872D0FA731005D2E15 /* ThreadSafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */; }; FD99D0922D10F5EE005D2E15 /* ThreadSafeSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */; }; FD9AECA52AAA9609009B3406 /* NotificationResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */; }; @@ -995,7 +1046,6 @@ FD9E26C92EA72DC200404C7F /* SessionUIKit.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = FD9E26C82EA72DC200404C7F /* SessionUIKit.xctestplan */; }; FD9E26CB2EA72E2600404C7F /* Quick in Frameworks */ = {isa = PBXBuildFile; productRef = FD9E26CA2EA72E2600404C7F /* Quick */; }; FD9E26CD2EA72E2600404C7F /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = FD9E26CC2EA72E2600404C7F /* Nimble */; }; - FD9E26CE2EA72EFF00404C7F /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; FD9E26D02EA73F4E00404C7F /* UTType+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9E26CF2EA73F4800404C7F /* UTType+Localization.swift */; }; FDA335F52D91157A007E0EB6 /* SessionImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA335F42D911576007E0EB6 /* SessionImageView.swift */; }; FDAA16762AC28A3B00DDBF77 /* UserDefaultsType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */; }; @@ -1005,16 +1055,19 @@ FDAA36A92EB2C3E50040603E /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728972849E8110018502F /* UITableView+ReusableView.swift */; }; FDAA36AA2EB2C4550040603E /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */; }; FDAA36AB2EB2C45E0040603E /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D32C9BAF6B002A2623 /* UICollectionView+ReusableView.swift */; }; - FDAA36AC2EB2C5840040603E /* VoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */; }; FDAA36AD2EB2C61D0040603E /* TimeUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC0F0032BFECE12002CBFB9 /* TimeUnit.swift */; }; - FDAA36AE2EB2C6420040603E /* TimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */; }; FDAA36AF2EB2C6EE0040603E /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */; }; FDAA36B22EB2D2F60040603E /* NVActivityIndicatorView in Frameworks */ = {isa = PBXBuildFile; productRef = FDAA36B12EB2D2F60040603E /* NVActivityIndicatorView */; }; FDAA36B42EB2DFA30040603E /* NVActivityIndicatorView in Frameworks */ = {isa = PBXBuildFile; productRef = FDAA36B32EB2DFA30040603E /* NVActivityIndicatorView */; }; FDAA36B72EB2E55C0040603E /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D2825C7A4B400488AB4 /* InputView.swift */; }; - FDAA36B92EB3FBC80040603E /* LinkPreviewManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36B82EB3FBC20040603E /* LinkPreviewManagerType.swift */; }; FDAA36BC2EB3FC980040603E /* LinkPreviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36BB2EB3FC940040603E /* LinkPreviewManager.swift */; }; FDAA36BE2EB3FFB50040603E /* Task+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36BD2EB3FFB10040603E /* Task+Utilities.swift */; }; + FDAA36C02EB435950040603E /* SessionProUIManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36BF2EB435910040603E /* SessionProUIManagerType.swift */; }; + FDAA36C62EB474C80040603E /* SessionProFeaturesForMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */; }; + FDAA36C82EB475180040603E /* SessionProFeatureStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */; }; + FDAA36CA2EB476090040603E /* SessionProProfileFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C92EB476060040603E /* SessionProProfileFeatures.swift */; }; + FDAA36CE2EB4844F0040603E /* SessionProDecodedProForMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */; }; + FDAA36D02EB485F20040603E /* SessionProDecodedStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */; }; FDAB8A832EB2A4CB000A6C65 /* MentionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C302093D25DCBF07001F572D /* MentionSelectionView.swift */; }; FDAB8A852EB2BC37000A6C65 /* MentionSelectionView+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAB8A842EB2BC2F000A6C65 /* MentionSelectionView+SessionMessagingKit.swift */; }; FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */; }; @@ -1035,9 +1088,7 @@ FDB348892BE8705D00B716C2 /* SessionUtilitiesKit.h in Headers */ = {isa = PBXBuildFile; fileRef = FDB3486C2BE8448500B716C2 /* SessionUtilitiesKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; FDB3DA842E1CA22400148F8D /* UIActivityViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */; }; FDB3DA862E1E1F0E00148F8D /* TaskCancellation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA852E1E1F0B00148F8D /* TaskCancellation.swift */; }; - FDB3DA882E24810C00148F8D /* SessionAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA872E24810900148F8D /* SessionAsyncImage.swift */; }; FDB3DA8B2E24834000148F8D /* AVURLAsset+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA892E2482A400148F8D /* AVURLAsset+Utilities.swift */; }; - FDB3DA8D2E24881B00148F8D /* ImageLoading+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */; }; FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */; }; FDB5DAC12A9443A5002C8721 /* MessageSender+Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB5DAC02A9443A5002C8721 /* MessageSender+Groups.swift */; }; FDB5DAC72A9447E7002C8721 /* _036_GroupsRebuildChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB5DAC62A9447E7002C8721 /* _036_GroupsRebuildChanges.swift */; }; @@ -1099,6 +1150,9 @@ FDD23AF02E459EDD0057E853 /* _020_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945D9C572D6FDBE7003C4C0C /* _020_AddJobUniqueHash.swift */; }; FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; }; FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; + FDD42F462EE7B12100771A4C /* Lucide in Frameworks */ = {isa = PBXBuildFile; productRef = FDD42F452EE7B12100771A4C /* Lucide */; }; + FDD42F482EE8D8ED00771A4C /* Notifications+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD42F472EE8D8E600771A4C /* Notifications+Utilities.swift */; }; + FDD42F4A2EEB790900771A4C /* FetchableTriple.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD42F492EEB790500771A4C /* FetchableTriple.swift */; }; FDDD554E2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDD554D2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift */; }; FDDF074429C3E3D000E5E8B5 /* FetchRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */; }; FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; @@ -1119,7 +1173,6 @@ FDE519F72AB7CDC700450C53 /* Result+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */; }; FDE5218E2E03A06B00061B8E /* AttachmentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5218D2E03A06700061B8E /* AttachmentManager.swift */; }; FDE521942E050B1100061B8E /* DismissCallbackAVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521932E050B0800061B8E /* DismissCallbackAVPlayerViewController.swift */; }; - FDE5219A2E08DBB800061B8E /* ImageLoading+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */; }; FDE5219C2E08E76C00061B8E /* SessionAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5219B2E08E76600061B8E /* SessionAsyncImage.swift */; }; FDE521A02E0D230000061B8E /* ObservationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5219F2E0D22FD00061B8E /* ObservationManager.swift */; }; FDE521A22E0D23AB00061B8E /* ObservableKey+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */; }; @@ -1186,8 +1239,6 @@ FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */; }; FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */; }; FDF0B7512807BA56004C14C5 /* NotificationsManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7502807BA56004C14C5 /* NotificationsManagerType.swift */; }; - FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */; }; - FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */; }; FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */; }; FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B75D280AAF35004C14C5 /* Preferences.swift */; }; FDF222072818CECF000A4995 /* ConversationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF222062818CECF000A4995 /* ConversationViewModel.swift */; }; @@ -1670,7 +1721,6 @@ 945E89D12E95D54000D8D907 /* SessionProPaymentScreen+NoBillingAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+NoBillingAccess.swift"; sourceTree = ""; }; 945E89D32E95D96100D8D907 /* SessionProPaymentScreen+SharedViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+SharedViews.swift"; sourceTree = ""; }; 945E89D52E96028B00D8D907 /* SessionProPaymentScreen+Purchase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+Purchase.swift"; sourceTree = ""; }; - 946379492E71308B0017A014 /* SessionProManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProManagerType.swift; sourceTree = ""; }; 9463794B2E7137120017A014 /* SessionProPaymentScreen+Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+Models.swift"; sourceTree = ""; }; 9471CAA72CACFB4E00090FB7 /* GenerateLicenses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerateLicenses.swift; sourceTree = ""; }; 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; @@ -1690,7 +1740,6 @@ 94805EB12EB087F90055EBBC /* BottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheet.swift; sourceTree = ""; }; 94805EB82EB1E1650055EBBC /* SessionProSettings+ProFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProSettings+ProFeatures.swift"; sourceTree = ""; }; 94805EBE2EB462C10055EBBC /* TransitionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionType.swift; sourceTree = ""; }; - 94805EC02EB48D860055EBBC /* SessionProState+Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProState+Models.swift"; sourceTree = ""; }; 94805EC22EB48EC40055EBBC /* SessionProPaymentScreenContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProPaymentScreenContent.swift; sourceTree = ""; }; 94805EC52EB823B00055EBBC /* DismissType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissType.swift; sourceTree = ""; }; 94805EC72EB834CD0055EBBC /* UINavigationController+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationController+Utilities.swift"; sourceTree = ""; }; @@ -1704,7 +1753,6 @@ 948615CA2ED7D6E5000A5666 /* NavigatableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigatableState.swift; sourceTree = ""; }; 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableLabel.swift; sourceTree = ""; }; 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadNotificationSettingsViewModelSpec.swift; sourceTree = ""; }; - 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Constants+Apple.swift"; sourceTree = ""; }; 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Modal+SwiftUI.swift"; sourceTree = ""; }; 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProCTAModal.swift; sourceTree = ""; }; 94AAB14E2E1F6CB300A6FA18 /* SessionProBadge+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProBadge+SwiftUI.swift"; sourceTree = ""; }; @@ -1713,7 +1761,7 @@ 94AAB1562E24BD3700A6FA18 /* PinnedConversationsCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = PinnedConversationsCTA.webp; sourceTree = ""; }; 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = AnimatedProfileCTA.webp; sourceTree = ""; }; 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView_SwiftUI.swift; sourceTree = ""; }; - 94B6BAF52E30A88800E718BB /* SessionProState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProState.swift; sourceTree = ""; }; + 94B6BAF52E30A88800E718BB /* SessionProManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProManager.swift; sourceTree = ""; }; 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileModal.swift; sourceTree = ""; }; 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = ""; }; 94B6BB012E3AE85800E718BB /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; @@ -1721,7 +1769,6 @@ 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = AnimatedProfileCTAAnimationCropped.webp; sourceTree = ""; }; 94C58AC82D2E036E00609195 /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = ""; }; 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProBadge.swift; sourceTree = ""; }; - 94CD95BC2E0908340097754D /* LibSession+Pro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibSession+Pro.swift"; sourceTree = ""; }; 94CD95C02E0CBF1C0097754D /* _044_AddProMessageFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _044_AddProMessageFlag.swift; sourceTree = ""; }; 94CD962A2E1B85920097754D /* InputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTextView.swift; sourceTree = ""; }; 94CD962B2E1B85920097754D /* InputViewButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputViewButton.swift; sourceTree = ""; }; @@ -1792,7 +1839,6 @@ B8DE1FB526C22FCB0079C9CE /* CallMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMessage.swift; sourceTree = ""; }; B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+OpenGroupInvitation.swift"; sourceTree = ""; }; B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupInvitationView.swift; sourceTree = ""; }; - B8F5F58225EC94A6003BF8D4 /* Collection+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Utilities.swift"; sourceTree = ""; }; B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotification.swift; sourceTree = ""; }; B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaceholderView.swift; sourceTree = ""; }; B9EB5ABC1884C002007CBB57 /* MessageUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MessageUI.framework; path = System/Library/Frameworks/MessageUI.framework; sourceTree = SDKROOT; }; @@ -1839,13 +1885,10 @@ C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIViewController+Utilities.swift"; path = "SignalUtilitiesKit/Utilities/UIViewController+Utilities.swift"; sourceTree = SOURCE_ROOT; }; C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProximityMonitoringManager.swift; path = SessionMessagingKit/Utilities/ProximityMonitoringManager.swift; sourceTree = SOURCE_ROOT; }; C38EF2EF255B6DBB007E1867 /* Weak.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Weak.swift; path = SessionUtilitiesKit/General/Weak.swift; sourceTree = SOURCE_ROOT; }; - C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSAudioPlayer.h; path = SessionMessagingKit/Utilities/OWSAudioPlayer.h; sourceTree = SOURCE_ROOT; }; - C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSAudioPlayer.m; path = SessionMessagingKit/Utilities/OWSAudioPlayer.m; sourceTree = SOURCE_ROOT; }; C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSWindowManager.h; path = SessionMessagingKit/Utilities/OWSWindowManager.h; sourceTree = SOURCE_ROOT; }; C38EF306255B6DBE007E1867 /* OWSWindowManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSWindowManager.m; path = SessionMessagingKit/Utilities/OWSWindowManager.m; sourceTree = SOURCE_ROOT; }; C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DeviceSleepManager.swift; path = SessionMessagingKit/Utilities/DeviceSleepManager.swift; sourceTree = SOURCE_ROOT; }; C38EF358255B6DCC007E1867 /* MediaMessageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MediaMessageView.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift"; sourceTree = SOURCE_ROOT; }; - C38EF37C255B6DCF007E1867 /* AttachmentTextToolbar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentTextToolbar.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift"; sourceTree = SOURCE_ROOT; }; C38EF37E255B6DD0007E1867 /* AttachmentItemCollection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentItemCollection.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift"; sourceTree = SOURCE_ROOT; }; C38EF37F255B6DD0007E1867 /* AttachmentApprovalViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentApprovalViewController.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift"; sourceTree = SOURCE_ROOT; }; C38EF380255B6DD0007E1867 /* AttachmentTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentTextView.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextView.swift"; sourceTree = SOURCE_ROOT; }; @@ -1905,7 +1948,7 @@ C3CA3AC7255CDB2900F4C6D4 /* spanish.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = spanish.txt; sourceTree = ""; }; C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundPoller.swift; sourceTree = ""; }; C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRCopyableLabel.swift; sourceTree = ""; }; - C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManager.swift; sourceTree = ""; }; + C3DB66AB260ACA42001EFC55 /* CommunityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityManager.swift; sourceTree = ""; }; D2179CFB16BB0B3A0006F3AB /* CoreTelephony.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreTelephony.framework; path = System/Library/Frameworks/CoreTelephony.framework; sourceTree = SDKROOT; }; D2179CFD16BB0B480006F3AB /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; D221A089169C9E5E00537ABF /* Session.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Session.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1962,11 +2005,22 @@ FD09B7E6288670FD00ED0B66 /* Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reaction.swift; sourceTree = ""; }; FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = ""; }; FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = ""; }; - FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTypingIndicator.swift; sourceTree = ""; }; FD09C5EB282B8F17000CE219 /* AttachmentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentError.swift; sourceTree = ""; }; FD0B77AF29B69A65009169BA /* TopBannerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopBannerController.swift; sourceTree = ""; }; FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUtilitiesSpec.swift; sourceTree = ""; }; FD0E353A2AB98773006A81F7 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; + FD0F85602EA82C87004E0B98 /* SessionPro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPro.swift; sourceTree = ""; }; + FD0F85622EA82DF6004E0B98 /* SessionProAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProAPI.swift; sourceTree = ""; }; + FD0F85652EA82FC9004E0B98 /* PaymentProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProvider.swift; sourceTree = ""; }; + FD0F85672EA83382004E0B98 /* SessionProEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProEndpoint.swift; sourceTree = ""; }; + FD0F856A2EA8351E004E0B98 /* AppProPaymentRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppProPaymentRequest.swift; sourceTree = ""; }; + FD0F856C2EA835B6004E0B98 /* Signatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signatures.swift; sourceTree = ""; }; + FD0F856E2EA83661004E0B98 /* UserTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTransaction.swift; sourceTree = ""; }; + FD0F85722EA83C41004E0B98 /* AnyCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyCodable.swift; sourceTree = ""; }; + FD0F85742EA83D49004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProPaymentOrGenerateProProofResponse.swift; sourceTree = ""; }; + FD0F85762EA83D8F004E0B98 /* ProProof.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProProof.swift; sourceTree = ""; }; + FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseHeader.swift; sourceTree = ""; }; + FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Request+SessionProAPI.swift"; sourceTree = ""; }; FD10AF0B2AF32B9A007709E5 /* SessionListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListViewModel.swift; sourceTree = ""; }; FD10AF112AF85D11007709E5 /* Feature+ServiceNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feature+ServiceNetwork.swift"; sourceTree = ""; }; FD11E22F2CA4F498001BAF58 /* DestinationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationSpec.swift; sourceTree = ""; }; @@ -1987,6 +2041,7 @@ FD17D7C927F546D900122BE0 /* _001_SUK_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_SUK_InitialSetupMigration.swift; sourceTree = ""; }; FD17D7E427F6A09900122BE0 /* Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identity.swift; sourceTree = ""; }; FD17D7E627F6A16700122BE0 /* _003_SUK_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_SUK_YDBToGRDBMigration.swift; sourceTree = ""; }; + FD184C222EF2100A001089EB /* SessionProError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProError.swift; sourceTree = ""; }; FD19363B2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ResponseInfo+SnodeAPI.swift"; sourceTree = ""; }; FD19363E2ACA66DE004BCF0F /* DatabaseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseSpec.swift; sourceTree = ""; }; FD1A553D2E14BE0E003761E4 /* PagedData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedData.swift; sourceTree = ""; }; @@ -1995,6 +2050,14 @@ FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistableRecord+Utilities.swift"; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; FD1D732D2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _029_BlockCommunityMessageRequests.swift; sourceTree = ""; }; + FD1F3CEA2ED5728000E536D5 /* SetPaymentRefundRequestedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPaymentRefundRequestedRequest.swift; sourceTree = ""; }; + FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPaymentRefundRequestedResponse.swift; sourceTree = ""; }; + FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProUI.swift; sourceTree = ""; }; + FD1F3CF22ED657A800E536D5 /* Constants+LibSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Constants+LibSession.swift"; sourceTree = ""; }; + FD1F3CF52ED69B6200E536D5 /* SessionProState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProState.swift; sourceTree = ""; }; + FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProRefundingStatus.swift; sourceTree = ""; }; + FD1F3CF92ED7B34700E536D5 /* SessionProMessageFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProMessageFeatures.swift; sourceTree = ""; }; + FD1F3CFB2ED7F37300E536D5 /* StringProviders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringProviders.swift; sourceTree = ""; }; FD2272532C32911A004D8A6C /* SendReadReceiptsJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SendReadReceiptsJob.swift; sourceTree = ""; }; FD2272542C32911A004D8A6C /* GroupLeavingJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupLeavingJob.swift; sourceTree = ""; }; FD2272552C32911A004D8A6C /* CheckForAppUpdatesJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CheckForAppUpdatesJob.swift; sourceTree = ""; }; @@ -2063,6 +2126,22 @@ FD29598C2A43BC0B00888A17 /* Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Version.swift; sourceTree = ""; }; FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = ""; }; FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = ""; }; + FD2C68602EA09523000B0E37 /* MessageError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageError.swift; sourceTree = ""; }; + FD2CFB8D2EDD00EE00EC7F98 /* SessionProOriginatingAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProOriginatingAccount.swift; sourceTree = ""; }; + FD2CFB922EDD0B3F00EC7F98 /* BuildVariant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildVariant.swift; sourceTree = ""; }; + FD2CFB962EDE645900EC7F98 /* SessionPro+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionPro+Convenience.swift"; sourceTree = ""; }; + FD2CFB982EDFF2FD00EC7F98 /* ConversationDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationDataCache.swift; sourceTree = ""; }; + FD2CFB9A2EE0FECA00EC7F98 /* ConversationDataHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationDataHelper.swift; sourceTree = ""; }; + FD2CFB9C2EE3F63400EC7F98 /* GroupAuthData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupAuthData.swift; sourceTree = ""; }; + FD2CFB9E2EE6293700EC7F98 /* GlobalSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearch.swift; sourceTree = ""; }; + FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProDetailsRequest.swift; sourceTree = ""; }; + FD306BCD2EB02E3400ADB003 /* Signature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signature.swift; sourceTree = ""; }; + FD306BCF2EB02F3500ADB003 /* GetProDetailsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProDetailsResponse.swift; sourceTree = ""; }; + FD306BD12EB031AB00ADB003 /* PaymentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentItem.swift; sourceTree = ""; }; + FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentStatus.swift; sourceTree = ""; }; + FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendUserProStatus.swift; sourceTree = ""; }; + FD306BD72EB033CB00ADB003 /* Plan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plan.swift; sourceTree = ""; }; + FD306BDB2EB0436800ADB003 /* GenerateProProofRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerateProProofRequest.swift; sourceTree = ""; }; FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonSMKMockExtensions.swift; sourceTree = ""; }; FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomArgSummaryDescribable+SessionMessagingKit.swift"; sourceTree = ""; }; FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCommunityPollerCache.swift; sourceTree = ""; }; @@ -2070,15 +2149,23 @@ FD336F5A2CAA28CF00C0B51B /* MockGroupPollerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGroupPollerCache.swift; sourceTree = ""; }; FD336F5B2CAA28CF00C0B51B /* MockLibSessionCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLibSessionCache.swift; sourceTree = ""; }; FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNotificationsManager.swift; sourceTree = ""; }; - FD336F5D2CAA28CF00C0B51B /* MockOGMCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOGMCache.swift; sourceTree = ""; }; + FD336F5D2CAA28CF00C0B51B /* MockCommunityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCommunityManager.swift; sourceTree = ""; }; FD336F5E2CAA28CF00C0B51B /* MockPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPoller.swift; sourceTree = ""; }; FD336F5F2CAA28CF00C0B51B /* MockSwarmPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSwarmPoller.swift; sourceTree = ""; }; FD336F6B2CAA29C200C0B51B /* CommunityPollerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityPollerSpec.swift; sourceTree = ""; }; FD336F6E2CAA37CB00C0B51B /* MockCommunityPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCommunityPoller.swift; sourceTree = ""; }; FD3559452CC1FF140088F2A9 /* _034_AddMissingWhisperFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _034_AddMissingWhisperFlag.swift; sourceTree = ""; }; + FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProConfig.swift; sourceTree = ""; }; + FD360EC02ECD23950050CAF4 /* GetProRevocationsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProRevocationsRequest.swift; sourceTree = ""; }; + FD360EC22ECD239D0050CAF4 /* GetProRevocationsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProRevocationsResponse.swift; sourceTree = ""; }; + FD360EC42ECD24C00050CAF4 /* RevocationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevocationItem.swift; sourceTree = ""; }; + FD360EC62ECD38710050CAF4 /* OptionSet+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OptionSet+Utilities.swift"; sourceTree = ""; }; FD360EC82ECD3EAE0050CAF4 /* DonationCTAModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationCTAModal.swift; sourceTree = ""; }; FD360ECA2ECD59520050CAF4 /* DonationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationsManager.swift; sourceTree = ""; }; FD360ECC2ECD70510050CAF4 /* DeveloperSettingsModalsAndBannersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsModalsAndBannersViewModel.swift; sourceTree = ""; }; + FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProLoadingState.swift; sourceTree = ""; }; + FD360ED52ED3D2250050CAF4 /* ObservationUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationUtilities.swift; sourceTree = ""; }; + FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProPlan.swift; sourceTree = ""; }; FD360ED92ED3E8BC0050CAF4 /* DonationsCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = DonationsCTA.webp; sourceTree = ""; }; FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _026_AddFTSIfNeeded.swift; sourceTree = ""; }; FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.swift"; sourceTree = ""; }; @@ -2120,7 +2207,7 @@ FD39370B2E4D7BBE00571F17 /* DocumentPickerHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentPickerHandler.swift; sourceTree = ""; }; FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdLookupSpec.swift; sourceTree = ""; }; - FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; + FD3E0C83283B5835002A425C /* ConversationInfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationInfoViewModel.swift; sourceTree = ""; }; FD3F2EE62DE6CC3B00FD6849 /* NotificationsManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManagerSpec.swift; sourceTree = ""; }; FD3F2EF12DF273D100FD6849 /* ThemedAttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemedAttributedString.swift; sourceTree = ""; }; FD3FAB582ADF906300DC5421 /* Profile+Updating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile+Updating.swift"; sourceTree = ""; }; @@ -2237,6 +2324,7 @@ FD716E6528502EE200C96BF4 /* CurrentCallProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentCallProtocol.swift; sourceTree = ""; }; FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = ""; }; FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; + FD71B9AF2EF25A0E00379A99 /* GlobalSearchSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchSpec.swift; sourceTree = ""; }; FD72BDA02BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindowLevel+Utilities.swift"; sourceTree = ""; }; FD72BDA32BE3690B00CF6CF6 /* CryptoSMKSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSMKSpec.swift; sourceTree = ""; }; FD72BDA62BE369DC00CF6CF6 /* CryptoOpenGroupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoOpenGroupSpec.swift; sourceTree = ""; }; @@ -2247,7 +2335,6 @@ FD7443472D07CA9F00862443 /* CGRect+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGRect+Utilities.swift"; sourceTree = ""; }; FD7443482D07CA9F00862443 /* CGSize+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGSize+Utilities.swift"; sourceTree = ""; }; FD7443492D07CA9F00862443 /* Codable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Utilities.swift"; sourceTree = ""; }; - FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModelSpec.swift; sourceTree = ""; }; FD7728952849E7E90018502F /* String+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; FD7728972849E8110018502F /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD778B6329B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _028_GenerateInitialUserConfigDumps.swift; sourceTree = ""; }; @@ -2312,9 +2399,18 @@ FD981BC82DC4640D00564172 /* ExtensionHelperSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionHelperSpec.swift; sourceTree = ""; }; FD981BCA2DC4A21800564172 /* MessageDeduplicationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicationSpec.swift; sourceTree = ""; }; FD981BCC2DC81ABB00564172 /* MockExtensionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockExtensionHelper.swift; sourceTree = ""; }; - FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionUtilities+DisplayName.swift"; sourceTree = ""; }; FD981BD62DC9A61600564172 /* NotificationCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategory.swift; sourceTree = ""; }; FD981BD82DC9A69000564172 /* NotificationUserInfoKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationUserInfoKey.swift; sourceTree = ""; }; + FD99A39E2EBAA5E500E59F94 /* DecodedEnvelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodedEnvelope.swift; sourceTree = ""; }; + FD99A3A12EBAA6A500E59F94 /* Envelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Envelope.swift; sourceTree = ""; }; + FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvelopeFlags.swift; sourceTree = ""; }; + FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodedMessage.swift; sourceTree = ""; }; + FD99A3AB2EBC1B6C00E59F94 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; + FD99A3AF2EBD4EDB00E59F94 /* FetchablePair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchablePair.swift; sourceTree = ""; }; + FD99A3B12EC3E2EF00E59F94 /* OWSAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSAudioPlayer.swift; sourceTree = ""; }; + FD99A3B52EC562CB00E59F94 /* _047_DropUnneededColumnsAndTables.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _047_DropUnneededColumnsAndTables.swift; sourceTree = ""; }; + FD99A3B72EC5882500E59F94 /* AddProPaymentResponseStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProPaymentResponseStatus.swift; sourceTree = ""; }; + FD99A3B92EC58DD500E59F94 /* _048_SessionProChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _048_SessionProChanges.swift; sourceTree = ""; }; FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafe.swift; sourceTree = ""; }; FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeSpec.swift; sourceTree = ""; }; FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationResolution.swift; sourceTree = ""; }; @@ -2332,6 +2428,12 @@ FDAA36B82EB3FBC20040603E /* LinkPreviewManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewManagerType.swift; sourceTree = ""; }; FDAA36BB2EB3FC940040603E /* LinkPreviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewManager.swift; sourceTree = ""; }; FDAA36BD2EB3FFB10040603E /* Task+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+Utilities.swift"; sourceTree = ""; }; + FDAA36BF2EB435910040603E /* SessionProUIManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProUIManagerType.swift; sourceTree = ""; }; + FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeaturesForMessage.swift; sourceTree = ""; }; + FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeatureStatus.swift; sourceTree = ""; }; + FDAA36C92EB476060040603E /* SessionProProfileFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProProfileFeatures.swift; sourceTree = ""; }; + FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProDecodedProForMessage.swift; sourceTree = ""; }; + FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProDecodedStatus.swift; sourceTree = ""; }; FDAB8A842EB2BC2F000A6C65 /* MentionSelectionView+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionSelectionView+SessionMessagingKit.swift"; sourceTree = ""; }; FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = ""; }; FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadUpdateInfo.swift; sourceTree = ""; }; @@ -2351,7 +2453,6 @@ FDB3DA852E1E1F0B00148F8D /* TaskCancellation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCancellation.swift; sourceTree = ""; }; FDB3DA872E24810900148F8D /* SessionAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAsyncImage.swift; sourceTree = ""; }; FDB3DA892E2482A400148F8D /* AVURLAsset+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVURLAsset+Utilities.swift"; sourceTree = ""; }; - FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageLoading+Convenience.swift"; sourceTree = ""; }; FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewError.swift; sourceTree = ""; }; FDB5DAC02A9443A5002C8721 /* MessageSender+Groups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSender+Groups.swift"; sourceTree = ""; }; FDB5DAC62A9447E7002C8721 /* _036_GroupsRebuildChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _036_GroupsRebuildChanges.swift; sourceTree = ""; }; @@ -2394,7 +2495,7 @@ FDC2909327D710B4005DAE71 /* SOGSEndpointSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSEndpointSpec.swift; sourceTree = ""; }; FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSErrorSpec.swift; sourceTree = ""; }; FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalizationSpec.swift; sourceTree = ""; }; - FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManagerSpec.swift; sourceTree = ""; }; + FDC2909D27D85751005DAE71 /* CommunityManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityManagerSpec.swift; sourceTree = ""; }; FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleExtensions.swift; sourceTree = ""; }; FDC4380827B31D4E00C60D73 /* SOGSError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSError.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; @@ -2427,6 +2528,8 @@ FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = ""; }; FDD383702AFDD0E1001367F2 /* BencodeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BencodeResponse.swift; sourceTree = ""; }; FDD383722AFDD6D7001367F2 /* BencodeResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BencodeResponseSpec.swift; sourceTree = ""; }; + FDD42F472EE8D8E600771A4C /* Notifications+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notifications+Utilities.swift"; sourceTree = ""; }; + FDD42F492EEB790500771A4C /* FetchableTriple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchableTriple.swift; sourceTree = ""; }; FDD82C3E2A205D0A00425F05 /* ProcessResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessResult.swift; sourceTree = ""; }; FDDD554D2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _033_ScheduleAppUpdateCheckJob.swift; sourceTree = ""; }; FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FetchRequest+Utilities.swift"; sourceTree = ""; }; @@ -2527,8 +2630,6 @@ FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedReplyModel.swift; sourceTree = ""; }; FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionAttachment.swift; sourceTree = ""; }; FDF0B7502807BA56004C14C5 /* NotificationsManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManagerType.swift; sourceTree = ""; }; - FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverError.swift; sourceTree = ""; }; - FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSenderError.swift; sourceTree = ""; }; FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MessageSender+Convenience.swift"; sourceTree = ""; }; FDF0B75D280AAF35004C14C5 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; FDF222062818CECF000A4995 /* ConversationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewModel.swift; sourceTree = ""; }; @@ -2682,6 +2783,7 @@ FD6673FA2D7021F800041530 /* SessionUtil in Frameworks */, FD2286732C38D43900BC06F7 /* DifferenceKit in Frameworks */, FDC4386C27B4E90300C60D73 /* SessionUtilitiesKit.framework in Frameworks */, + FDD42F462EE7B12100771A4C /* Lucide in Frameworks */, C3C2A70B25539E1E00C340D1 /* SessionNetworkingKit.framework in Frameworks */, FD6A39132C2A946A00762359 /* SwiftProtobuf in Frameworks */, ); @@ -2885,7 +2987,6 @@ 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */, FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, FDE521932E050B0800061B8E /* DismissCallbackAVPlayerViewController.swift */, - FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, 45C0DC1D1E69011F00E04C47 /* UIStoryboard+OWS.swift */, 45B5360D206DD8BB00D61655 /* UIResponder+OWS.swift */, @@ -2893,8 +2994,6 @@ FD37E9D828A230F2003AE748 /* TraitObservingWindow.swift */, C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */, C35E8AAD2485E51D00ACB629 /* IP2Country.swift */, - FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */, - FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, B8D84EA225DF745A005A043E /* LinkPreview+Convenience.swift */, FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */, FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, @@ -3068,6 +3167,7 @@ 9438D5542E6A6843008C7FFE /* SessionProSettings */ = { isa = PBXGroup; children = ( + FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */, 9438D5562E6A6862008C7FFE /* SessionProPaymentScreen.swift */, 945E89D32E95D96100D8D907 /* SessionProPaymentScreen+SharedViews.swift */, 945E89D52E96028B00D8D907 /* SessionProPaymentScreen+Purchase.swift */, @@ -3141,8 +3241,6 @@ 948615C02ED7D39B000A5666 /* SessionProSettingsViewModel.swift */, 948615C12ED7D39B000A5666 /* SessionProSettingsViewModel+Database.swift */, 94805EC22EB48EC40055EBBC /* SessionProPaymentScreenContent.swift */, - 94B6BAF52E30A88800E718BB /* SessionProState.swift */, - 94805EC02EB48D860055EBBC /* SessionProState+Models.swift */, ); path = SessionPro; sourceTree = ""; @@ -3290,7 +3388,7 @@ FDE754BF2C9BAEF6002A2623 /* Array+Utilities.swift */, FD47E0AA2AA68EEA00A55E41 /* Authentication.swift */, FDC438CC27BC641200C60D73 /* Set+Utilities.swift */, - B8F5F58225EC94A6003BF8D4 /* Collection+Utilities.swift */, + 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */, FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */, C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */, FD2272EB2C352155004D8A6C /* Feature.swift */, @@ -3380,12 +3478,14 @@ C300A5BB2554AFFB00555489 /* Messages */ = { isa = PBXGroup; children = ( + FD99A3A02EBAA69600E59F94 /* Decoding */, C300A5C62554B02D00555489 /* Visible Messages */, C300A5C72554B03900555489 /* Control Messages */, C3C2A74325539EB700C340D1 /* Message.swift */, C352A30825574D8400338F3E /* Message+Destination.swift */, 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */, FDB11A5E2DD5B77800BEF49F /* Message+Origin.swift */, + FD2C68602EA09523000B0E37 /* MessageError.swift */, FDF71EA22B072C2800A8D6B5 /* LibSessionMessage.swift */, FDB11A5C2DD300CF00BEF49F /* SNProtoContent+Utilities.swift */, ); @@ -3555,7 +3655,6 @@ children = ( FD37E9C428A1C701003AE748 /* Themes */, 947AD68F2C8968FF000B2730 /* Constants.swift */, - 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */, B8BB82BD2394D4CE00BA5194 /* Fonts.swift */, FDF848F029406A30007DCAE5 /* Format.swift */, FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */, @@ -3575,6 +3674,7 @@ FD8A5B282DC060DD004C689B /* Double+Utilities.swift */, 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */, B84664F4235022F30083A1CD /* MentionUtilities.swift */, + FDD42F472EE8D8E600771A4C /* Notifications+Utilities.swift */, FD8A5B242DC05B16004C689B /* Number+Utilities.swift */, FD8A5B1D2DBF4BB8004C689B /* ScreenLock+Errors.swift */, 7BA1E0E72A8087DB00123D0D /* SwiftUI+Utilities.swift */, @@ -3859,7 +3959,6 @@ C38EF37F255B6DD0007E1867 /* AttachmentApprovalViewController.swift */, C38EF37E255B6DD0007E1867 /* AttachmentItemCollection.swift */, C38EF382255B6DD1007E1867 /* AttachmentPrepViewController.swift */, - C38EF37C255B6DCF007E1867 /* AttachmentTextToolbar.swift */, C38EF380255B6DD0007E1867 /* AttachmentTextView.swift */, ); path = "Attachment Approval"; @@ -3883,7 +3982,7 @@ children = ( FD23CE202A661CE80000B97C /* Crypto */, FDC4381827B34EAD00C60D73 /* Types */, - C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */, + C3DB66AB260ACA42001EFC55 /* CommunityManager.swift */, ); path = "Open Groups"; sourceTree = ""; @@ -3900,12 +3999,12 @@ FD981BC52DC3310800564172 /* ExtensionHelper.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.swift */, + FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, FDAB8A842EB2BC2F000A6C65 /* MentionSelectionView+SessionMessagingKit.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */, FD1A55422E179AE6003761E4 /* ObservableKeyEvent+Utilities.swift */, - C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */, - C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */, + FD99A3B12EC3E2EF00E59F94 /* OWSAudioPlayer.swift */, C38EF281255B6D84007E1867 /* OWSAudioSession.swift */, FDF0B75D280AAF35004C14C5 /* Preferences.swift */, FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */, @@ -3931,6 +4030,7 @@ FD7F74682BAB8A5D006DDFD8 /* LibSession */, FD6B92DF2E77C1CB004463B5 /* PushNotification */, 947D7FD32D509FC900E8E413 /* SessionNetwork */, + FD0F855F2EA82C7B004E0B98 /* SessionPro */, FD6B92892E779D8D004463B5 /* SOGS */, FD2272842C33E28D004D8A6C /* StorageServer */, FD6B92A52E77A3BD004463B5 /* Models */, @@ -4006,7 +4106,7 @@ C3A721332558BDDF0043A11F /* Open Groups */, C300A5F02554B08500555489 /* Sending & Receiving */, FD8ECF7529340F4800C0D1BB /* LibSession */, - FD3E0C82283B581F002A425C /* Shared Models */, + FDAA36C32EB4740E0040603E /* SessionPro */, FDAA36BA2EB3FC8C0040603E /* Types */, C3BBE0B32554F0D30050F1E3 /* Utilities */, FD245C612850664300B966DD /* Configuration.swift */, @@ -4224,6 +4324,7 @@ FD09797C27FBDB2000936362 /* Notification+Utilities.swift */, FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */, FD09797127FAA2F500936362 /* Optional+Utilities.swift */, + FD360EC62ECD38710050CAF4 /* OptionSet+Utilities.swift */, FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */, FD00CDCA2D5317A3006B96D3 /* Scheduler+Utilities.swift */, FDB11A532DCD7A7B00BEF49F /* Task+Utilities.swift */, @@ -4253,7 +4354,6 @@ FD09799827FFC1A300936362 /* Attachment.swift */, FD09799A27FFC82D00936362 /* Quote.swift */, FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */, - FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */, FD981BC32DC304E100564172 /* MessageDeduplication.swift */, FD5C7308285007920029977D /* BlindedIdLookup.swift */, FD09B7E6288670FD00ED0B66 /* Reaction.swift */, @@ -4263,6 +4363,54 @@ path = Models; sourceTree = ""; }; + FD0F855F2EA82C7B004E0B98 /* SessionPro */ = { + isa = PBXGroup; + children = ( + FD0F85692EA83518004E0B98 /* Requests */, + FD0F85642EA82FC2004E0B98 /* Types */, + FD0F85602EA82C87004E0B98 /* SessionPro.swift */, + FD0F85622EA82DF6004E0B98 /* SessionProAPI.swift */, + FD0F85672EA83382004E0B98 /* SessionProEndpoint.swift */, + ); + path = SessionPro; + sourceTree = ""; + }; + FD0F85642EA82FC2004E0B98 /* Types */ = { + isa = PBXGroup; + children = ( + FD99A3B72EC5882500E59F94 /* AddProPaymentResponseStatus.swift */, + FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */, + FD306BD12EB031AB00ADB003 /* PaymentItem.swift */, + FD0F85652EA82FC9004E0B98 /* PaymentProvider.swift */, + FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */, + FD306BD72EB033CB00ADB003 /* Plan.swift */, + FD0F85762EA83D8F004E0B98 /* ProProof.swift */, + FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */, + FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */, + FD360EC42ECD24C00050CAF4 /* RevocationItem.swift */, + FD306BCD2EB02E3400ADB003 /* Signature.swift */, + FD0F856C2EA835B6004E0B98 /* Signatures.swift */, + FD0F856E2EA83661004E0B98 /* UserTransaction.swift */, + ); + path = Types; + sourceTree = ""; + }; + FD0F85692EA83518004E0B98 /* Requests */ = { + isa = PBXGroup; + children = ( + FD0F856A2EA8351E004E0B98 /* AppProPaymentRequest.swift */, + FD0F85742EA83D49004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift */, + FD306BDB2EB0436800ADB003 /* GenerateProProofRequest.swift */, + FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */, + FD306BCF2EB02F3500ADB003 /* GetProDetailsResponse.swift */, + FD360EC02ECD23950050CAF4 /* GetProRevocationsRequest.swift */, + FD360EC22ECD239D0050CAF4 /* GetProRevocationsResponse.swift */, + FD1F3CEA2ED5728000E536D5 /* SetPaymentRefundRequestedRequest.swift */, + FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */, + ); + path = Requests; + sourceTree = ""; + }; FD17D79427F3E03300122BE0 /* Migrations */ = { isa = PBXGroup; children = ( @@ -4312,6 +4460,8 @@ 94CD95C02E0CBF1C0097754D /* _044_AddProMessageFlag.swift */, 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */, FD9E26AE2EA5DC7100404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift */, + FD99A3B52EC562CB00E59F94 /* _047_DropUnneededColumnsAndTables.swift */, + FD99A3B92EC58DD500E59F94 /* _048_SessionProChanges.swift */, ); path = Migrations; sourceTree = ""; @@ -4328,6 +4478,8 @@ isa = PBXGroup; children = ( FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */, + FD99A3AF2EBD4EDB00E59F94 /* FetchablePair.swift */, + FDD42F492EEB790500771A4C /* FetchableTriple.swift */, FD17D7B727F51ECA00122BE0 /* Migration.swift */, FD4BB22A2D63F20600D0DC3D /* MigrationHelper.swift */, FD7162DA281B6C440060647B /* TypedTableAlias.swift */, @@ -4369,6 +4521,14 @@ path = Database; sourceTree = ""; }; + FD1F3CF42ED69B5B00E536D5 /* Utilities */ = { + isa = PBXGroup; + children = ( + FD2CFB962EDE645900EC7F98 /* SessionPro+Convenience.swift */, + ); + path = Utilities; + sourceTree = ""; + }; FD2272842C33E28D004D8A6C /* StorageServer */ = { isa = PBXGroup; children = ( @@ -4401,8 +4561,8 @@ FD2272D22C34ECBB004D8A6C /* Types */ = { isa = PBXGroup; children = ( - 946379492E71308B0017A014 /* SessionProManagerType.swift */, FD39370B2E4D7BBE00571F17 /* DocumentPickerHandler.swift */, + FD0F85722EA83C41004E0B98 /* AnyCodable.swift */, FD0E353A2AB98773006A81F7 /* AppVersion.swift */, FDB3486D2BE8457F00B716C2 /* BackgroundTaskManager.swift */, FDE755042C9BB4ED002A2623 /* Bencode.swift */, @@ -4526,17 +4686,6 @@ path = Contacts; sourceTree = ""; }; - FD3E0C82283B581F002A425C /* Shared Models */ = { - isa = PBXGroup; - children = ( - FD71162B28E1451400B47552 /* Position.swift */, - FD848B86283B844B000E298B /* MessageViewModel.swift */, - FDE7549A2C940108002A2623 /* MessageViewModel+DeletionActions.swift */, - FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */, - ); - path = "Shared Models"; - sourceTree = ""; - }; FD3F2EE52DE6CC3500FD6849 /* Notifications */ = { isa = PBXGroup; children = ( @@ -4561,6 +4710,7 @@ FD52CB622E13B61700A4DA70 /* ObservableKey.swift */, FD42ECD52E3308AC002D03EA /* ObservableKey+SessionUtilitiesKit.swift */, FDE5219F2E0D22FD00061B8E /* ObservationManager.swift */, + FD360ED52ED3D2250050CAF4 /* ObservationUtilities.swift */, FDB3DA852E1E1F0B00148F8D /* TaskCancellation.swift */, ); path = Observations; @@ -4850,11 +5000,13 @@ 94805EC52EB823B00055EBBC /* DismissType.swift */, 94805EBE2EB462C10055EBBC /* TransitionType.swift */, FDE6E99729F8E63A00F93C5D /* Accessibility.swift */, + FD2CFB922EDD0B3F00EC7F98 /* BuildVariant.swift */, FD71163128E2C42A00B47552 /* IconSize.swift */, FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */, FDAA36B82EB3FBC20040603E /* LinkPreviewManagerType.swift */, 943C6D832B86B5F1004ACE64 /* Localization.swift */, - 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */, + FDAA36BF2EB435910040603E /* SessionProUIManagerType.swift */, + FD1F3CFB2ED7F37300E536D5 /* StringProviders.swift */, FDC0F0032BFECE12002CBFB9 /* TimeUnit.swift */, ); path = Types; @@ -4898,28 +5050,28 @@ path = Views; sourceTree = ""; }; - FD72BDA22BE368FA00CF6CF6 /* Crypto */ = { + FD71B9AE2EF251AF00379A99 /* Types */ = { isa = PBXGroup; children = ( - FD72BDA32BE3690B00CF6CF6 /* CryptoSMKSpec.swift */, + FD71B9AF2EF25A0E00379A99 /* GlobalSearchSpec.swift */, ); - path = Crypto; + path = Types; sourceTree = ""; }; - FD72BDA52BE369B600CF6CF6 /* Crypto */ = { + FD72BDA22BE368FA00CF6CF6 /* Crypto */ = { isa = PBXGroup; children = ( - FD72BDA62BE369DC00CF6CF6 /* CryptoOpenGroupSpec.swift */, + FD72BDA32BE3690B00CF6CF6 /* CryptoSMKSpec.swift */, ); path = Crypto; sourceTree = ""; }; - FD7692F52A53A2C7000E4B70 /* Shared Models */ = { + FD72BDA52BE369B600CF6CF6 /* Crypto */ = { isa = PBXGroup; children = ( - FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */, + FD72BDA62BE369DC00CF6CF6 /* CryptoOpenGroupSpec.swift */, ); - path = "Shared Models"; + path = Crypto; sourceTree = ""; }; FD7728A1284F0DF50018502F /* Message Handling */ = { @@ -5095,7 +5247,6 @@ FD8ECF8E29381FB200C0D1BB /* Config Handling */ = { isa = PBXGroup; children = ( - 94CD95BC2E0908340097754D /* LibSession+Pro.swift */, FD2272F32C352D8D004D8A6C /* LibSession+Contacts.swift */, FD2272F42C352D8D004D8A6C /* LibSession+ConvoInfoVolatile.swift */, FD2272F92C352D8E004D8A6C /* LibSession+GroupInfo.swift */, @@ -5147,6 +5298,17 @@ path = Utilities; sourceTree = ""; }; + FD99A3A02EBAA69600E59F94 /* Decoding */ = { + isa = PBXGroup; + children = ( + FD99A3A12EBAA6A500E59F94 /* Envelope.swift */, + FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */, + FD99A39E2EBAA5E500E59F94 /* DecodedEnvelope.swift */, + FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */, + ); + path = Decoding; + sourceTree = ""; + }; FD9E26C32EA72D5600404C7F /* SessionUIKitTests */ = { isa = PBXGroup; children = ( @@ -5176,7 +5338,45 @@ FDAA36BA2EB3FC8C0040603E /* Types */ = { isa = PBXGroup; children = ( + FD2CFB982EDFF2FD00EC7F98 /* ConversationDataCache.swift */, + FD2CFB9A2EE0FECA00EC7F98 /* ConversationDataHelper.swift */, + FD3E0C83283B5835002A425C /* ConversationInfoViewModel.swift */, + FD1F3CF22ED657A800E536D5 /* Constants+LibSession.swift */, + FD2CFB9E2EE6293700EC7F98 /* GlobalSearch.swift */, FDAA36BB2EB3FC940040603E /* LinkPreviewManager.swift */, + FD848B86283B844B000E298B /* MessageViewModel.swift */, + FDE7549A2C940108002A2623 /* MessageViewModel+DeletionActions.swift */, + FD71162B28E1451400B47552 /* Position.swift */, + ); + path = Types; + sourceTree = ""; + }; + FDAA36C32EB4740E0040603E /* SessionPro */ = { + isa = PBXGroup; + children = ( + FDAA36C42EB474B50040603E /* Types */, + FD1F3CF42ED69B5B00E536D5 /* Utilities */, + 94B6BAF52E30A88800E718BB /* SessionProManager.swift */, + ); + path = SessionPro; + sourceTree = ""; + }; + FDAA36C42EB474B50040603E /* Types */ = { + isa = PBXGroup; + children = ( + FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */, + FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */, + FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */, + FD184C222EF2100A001089EB /* SessionProError.swift */, + FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */, + FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */, + FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */, + FD1F3CF92ED7B34700E536D5 /* SessionProMessageFeatures.swift */, + FD2CFB8D2EDD00EE00EC7F98 /* SessionProOriginatingAccount.swift */, + FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */, + FDAA36C92EB476060040603E /* SessionProProfileFeatures.swift */, + FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */, + FD1F3CF52ED69B6200E536D5 /* SessionProState.swift */, ); path = Types; sourceTree = ""; @@ -5223,6 +5423,7 @@ isa = PBXGroup; children = ( FDC1BD652CFD6C4E002CDC71 /* Config.swift */, + FD2CFB9C2EE3F63400EC7F98 /* GroupAuthData.swift */, FD78E9F52DDD43AB00D55B50 /* Mutation.swift */, FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */, FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */, @@ -5242,6 +5443,7 @@ isa = PBXGroup; children = ( 7B81682B28B72F480069F315 /* PendingChange.swift */, + FD99A3AB2EBC1B6C00E59F94 /* Server.swift */, ); path = Types; sourceTree = ""; @@ -5270,8 +5472,8 @@ FD96F3A229DBC3BA00401309 /* Jobs */, FDC4389827BA001800C60D73 /* Open Groups */, FDE754A72C9B964D002A2623 /* Sending & Receiving */, - FD7692F52A53A2C7000E4B70 /* Shared Models */, FD8ECF802934385900C0D1BB /* LibSession */, + FD71B9AE2EF251AF00379A99 /* Types */, FD981BC72DC4640100564172 /* Utilities */, ); path = SessionMessagingKitTests; @@ -5282,7 +5484,7 @@ children = ( FD72BDA52BE369B600CF6CF6 /* Crypto */, FD83B9C127CF33EE005E1583 /* Models */, - FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */, + FDC2909D27D85751005DAE71 /* CommunityManagerSpec.swift */, ); path = "Open Groups"; sourceTree = ""; @@ -5292,6 +5494,7 @@ children = ( FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */, FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */, + FD336F5D2CAA28CF00C0B51B /* MockCommunityManager.swift */, FD336F6E2CAA37CB00C0B51B /* MockCommunityPoller.swift */, FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */, FD336F592CAA28CF00C0B51B /* MockDisplayPictureCache.swift */, @@ -5300,7 +5503,6 @@ FD78E9F12DDA9E9B00D55B50 /* MockImageDataManager.swift */, FD336F5B2CAA28CF00C0B51B /* MockLibSessionCache.swift */, FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */, - FD336F5D2CAA28CF00C0B51B /* MockOGMCache.swift */, FD336F5E2CAA28CF00C0B51B /* MockPoller.swift */, FD336F5F2CAA28CF00C0B51B /* MockSwarmPoller.swift */, ); @@ -5444,8 +5646,6 @@ FDF0B7562807F35E004C14C5 /* Errors */ = { isa = PBXGroup; children = ( - FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */, - FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */, FD09C5EB282B8F17000CE219 /* AttachmentError.swift */, ); path = Errors; @@ -5597,7 +5797,6 @@ files = ( C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */, B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */, - B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5782,6 +5981,7 @@ FD6673F92D7021F800041530 /* SessionUtil */, FDEFDC722E8B9F3300EBCD81 /* SDWebImageWebPCoder */, FD360EA82ECAB0DE0050CAF4 /* SDWebImage */, + FDD42F452EE7B12100771A4C /* Lucide */, ); productName = SessionMessagingKit; productReference = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; @@ -6577,19 +6777,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */, 948615C52ED7D4D4000A5666 /* ModalActivityIndicatorViewController.swift in Sources */, 9438D51A2E6951B3008C7FFE /* AnimatedToggle.swift in Sources */, - FDAA36AC2EB2C5840040603E /* VoiceMessageRecordingView.swift in Sources */, - FD9E26CE2EA72EFF00404C7F /* QuoteView_SwiftUI.swift in Sources */, - FDAA36B92EB3FBC80040603E /* LinkPreviewManagerType.swift in Sources */, - FDAA36AC2EB2C5840040603E /* VoiceMessageRecordingView.swift in Sources */, - FD9E26CE2EA72EFF00404C7F /* QuoteView_SwiftUI.swift in Sources */, - FDAA36B92EB3FBC80040603E /* LinkPreviewManagerType.swift in Sources */, - 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */, 942256952C23F8DD00C0FDBF /* AttributedText.swift in Sources */, 942256992C23F8DD00C0FDBF /* Toast.swift in Sources */, C331FF972558FA6B00070591 /* Fonts.swift in Sources */, + FD1DD8B72EF3ACCF009F2C1B /* QuoteView_SwiftUI.swift in Sources */, 9438D5572E6A6869008C7FFE /* SessionProPaymentScreen.swift in Sources */, 943B43642EC3FDC0008ABC34 /* ListItemAccessory+LoadingIndicator.swift in Sources */, FD8A5B022DBEFF73004C689B /* SessionNetworkScreen+Models.swift in Sources */, @@ -6621,6 +6814,7 @@ 942256962C23F8DD00C0FDBF /* CompatibleScrollingVStack.swift in Sources */, FD71165B28E6DDBC00B47552 /* StyledNavigationController.swift in Sources */, C331FFE32558FB0000070591 /* TabBar.swift in Sources */, + FDD42F482EE8D8ED00771A4C /* Notifications+Utilities.swift in Sources */, 94805EC82EB834D40055EBBC /* UINavigationController+Utilities.swift in Sources */, FD37E9D528A1FCE8003AE748 /* Theme+OceanLight.swift in Sources */, FDF848F129406A30007DCAE5 /* Format.swift in Sources */, @@ -6631,6 +6825,7 @@ 9438658F2EAB380700DB989A /* MutipleLinksModal.swift in Sources */, 948615C92ED7D646000A5666 /* Publisher+Utilities.swift in Sources */, 94363E5E2E6002960004EE43 /* SessionListScreen+Models.swift in Sources */, + FD1DD8BC2EF3AD0C009F2C1B /* SessionAsyncImage.swift in Sources */, 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */, FD8A5B292DC060E2004C689B /* Double+Utilities.swift in Sources */, FD8A5B0E2DBF2DB1004C689B /* SessionHostingViewController.swift in Sources */, @@ -6640,11 +6835,13 @@ FD37E9C828A1D73F003AE748 /* Theme+Colors.swift in Sources */, 942256982C23F8DD00C0FDBF /* SessionTextField.swift in Sources */, 9422EE2B2B8C3A97004C740D /* String+Utilities.swift in Sources */, + FD1F3CFC2ED7F37600E536D5 /* StringProviders.swift in Sources */, FD37EA0128A60473003AE748 /* UIKit+Theme.swift in Sources */, FD37E9CF28A1EB1B003AE748 /* Theme.swift in Sources */, + FD1DD8B82EF3ACDF009F2C1B /* AttributedLabel.swift in Sources */, 94B6BB002E3AE83C00E718BB /* QRCodeView.swift in Sources */, - FDB3DA882E24810C00148F8D /* SessionAsyncImage.swift in Sources */, 9499E6032DDD9BF900091434 /* ExpandableLabel.swift in Sources */, + FD1DD8B62EF3ACC9009F2C1B /* VoiceMessageRecordingView.swift in Sources */, 94AAB14D2E1F39B500A6FA18 /* ProCTAModal.swift in Sources */, 948615CB2ED7D6E5000A5666 /* NavigatableState.swift in Sources */, FDE754BA2C9B97B8002A2623 /* UIDevice+Utilities.swift in Sources */, @@ -6656,12 +6853,14 @@ FDA335F52D91157A007E0EB6 /* SessionImageView.swift in Sources */, 94519A972E851F1400F02723 /* SessionProPaymentScreen+RequestRefund.swift in Sources */, FD9E26B32EA72CC500404C7F /* UIEdgeInsets+Utilities.swift in Sources */, + FD2CFB932EDD0B4300EC7F98 /* BuildVariant.swift in Sources */, FD8A5B0D2DBF2CA1004C689B /* Localization.swift in Sources */, 945E89D62E9602AB00D8D907 /* SessionProPaymentScreen+Purchase.swift in Sources */, FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */, 94805EBF2EB462C40055EBBC /* TransitionType.swift in Sources */, 943B43602EC3FCD6008ABC34 /* ListItemAccessory+Icon.swift in Sources */, 94AAB1512E1F753500A6FA18 /* CyclicGradientView.swift in Sources */, + FD1DD8BB2EF3AD04009F2C1B /* TimeInterval+Utilities.swift in Sources */, 943B43562EC2AFAC008ABC34 /* SessionListScreen+ListItemDataMatrix.swift in Sources */, FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */, FDAA36AD2EB2C61D0040603E /* TimeUnit.swift in Sources */, @@ -6671,6 +6870,7 @@ 94363E662E60186A0004EE43 /* SessionListScreen+Section.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, FD6673FD2D77F54600041530 /* ScreenLockViewController.swift in Sources */, + FD1F3CEF2ED6509900E536D5 /* SessionProUI.swift in Sources */, 94805EB22EB087FD0055EBBC /* BottomSheet.swift in Sources */, FDAA36A92EB2C3E50040603E /* UITableView+ReusableView.swift in Sources */, 94B6BB042E3B208C00E718BB /* Seperator+SwiftUI.swift in Sources */, @@ -6697,16 +6897,12 @@ FD8A5B202DC03337004C689B /* AdaptiveText.swift in Sources */, 947D7FE92D51837200E8E413 /* Text+CopyButton.swift in Sources */, FD71162A28DA83DF00B47552 /* GradientView.swift in Sources */, + FDAA36C02EB435950040603E /* SessionProUIManagerType.swift in Sources */, 94363E682E6024A40004EE43 /* SessionListScreen+AccessoryViews.swift in Sources */, 94AAB14F2E1F6CC100A6FA18 /* SessionProBadge+SwiftUI.swift in Sources */, 94AAB14B2E1E198200A6FA18 /* Modal+SwiftUI.swift in Sources */, 94AAB1532E1F8AE200A6FA18 /* ShineButton.swift in Sources */, - 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */, - FDAA36AE2EB2C6420040603E /* TimeInterval+Utilities.swift in Sources */, - FDAA36AE2EB2C6420040603E /* TimeInterval+Utilities.swift in Sources */, - 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */, FD37E9D728A20B5D003AE748 /* UIColor+Utilities.swift in Sources */, - 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */, FDAA36AA2EB2C4550040603E /* ReusableView.swift in Sources */, FD9E26D02EA73F4E00404C7F /* UTType+Localization.swift in Sources */, 948615BF2ED51F5F000A5666 /* ToolBarManager.swift in Sources */, @@ -6720,7 +6916,9 @@ FD16AB5F2A1DD98F0083D849 /* ProfilePictureView.swift in Sources */, C331FFE42558FB0000070591 /* SessionButton.swift in Sources */, C331FFE92558FB0000070591 /* Separator.swift in Sources */, + FD1DD8B92EF3ACE5009F2C1B /* SRCopyableLabel.swift in Sources */, FD71163228E2C42A00B47552 /* IconSize.swift in Sources */, + FD1DD8B52EF3ACBA009F2C1B /* LinkPreviewManagerType.swift in Sources */, C33100282559000A00070591 /* UIView+Utilities.swift in Sources */, FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */, 9463794C2E71371F0017A014 /* SessionProPaymentScreen+Models.swift in Sources */, @@ -6738,7 +6936,6 @@ C38EF3C6255B6DE7007E1867 /* ImageEditorModel.swift in Sources */, C38EF3C3255B6DE7007E1867 /* ImageEditorTextItem.swift in Sources */, C38EF3C5255B6DE7007E1867 /* OWSViewController+ImageEditor.swift in Sources */, - C38EF385255B6DD2007E1867 /* AttachmentTextToolbar.swift in Sources */, FD71161E28D9772700B47552 /* UIViewController+OWS.swift in Sources */, C38EF389255B6DD2007E1867 /* AttachmentTextView.swift in Sources */, C38EF3FF255B6DF7007E1867 /* TappableView.swift in Sources */, @@ -6778,11 +6975,13 @@ buildActionMask = 2147483647; files = ( FD6B92E12E77C1E1004463B5 /* PushNotification.swift in Sources */, + FD306BD02EB02F3900ADB003 /* GetProDetailsResponse.swift in Sources */, FD2272B12C33E337004D8A6C /* ProxiedContentDownloader.swift in Sources */, FDF848C329405C5A007DCAE5 /* DeleteMessagesRequest.swift in Sources */, FD6B928E2E779E99004463B5 /* FileServerEndpoint.swift in Sources */, FDF8489129405C13007DCAE5 /* SnodeAPINamespace.swift in Sources */, FD2272AB2C33E337004D8A6C /* ValidatableResponse.swift in Sources */, + FD306BCC2EB02D9E00ADB003 /* GetProDetailsRequest.swift in Sources */, FD2272B72C33E337004D8A6C /* Request.swift in Sources */, FD2272B92C33E337004D8A6C /* ResponseInfo.swift in Sources */, FD6B92F82E77C725004463B5 /* ProcessResult.swift in Sources */, @@ -6792,11 +6991,14 @@ FDF848C229405C5A007DCAE5 /* OxenDaemonRPCRequest.swift in Sources */, FDF848DC29405C5B007DCAE5 /* RevokeSubaccountRequest.swift in Sources */, FDF848D029405C5B007DCAE5 /* UpdateExpiryResponse.swift in Sources */, + FD0F85792EA83EAD004E0B98 /* ResponseHeader.swift in Sources */, FD6B92E82E77C5B7004463B5 /* PushNotificationEndpoint.swift in Sources */, FDE71B032E77CCEE0023F5F9 /* HTTPHeader+FileServer.swift in Sources */, + FD306BD42EB031C200ADB003 /* PaymentStatus.swift in Sources */, FD6B92AC2E77A993004463B5 /* SOGSEndpoint.swift in Sources */, FD6B92922E779FC8004463B5 /* SessionNetwork.swift in Sources */, FDF848D329405C5B007DCAE5 /* UpdateExpiryAllResponse.swift in Sources */, + FD360EC12ECD239B0050CAF4 /* GetProRevocationsRequest.swift in Sources */, FDD20C162A09E64A003898FB /* GetExpiriesRequest.swift in Sources */, FDF848BC29405C5A007DCAE5 /* SnodeRecursiveResponse.swift in Sources */, FDF848C029405C5A007DCAE5 /* ONSResolveResponse.swift in Sources */, @@ -6806,6 +7008,7 @@ FDFC4D9A29F0C51500992FB6 /* String+Trimming.swift in Sources */, FD6B92992E77A06E004463B5 /* Token.swift in Sources */, FDB5DAF32A96DD4F002C8721 /* PreparedRequest+Sending.swift in Sources */, + FD306BD82EB033CD00ADB003 /* Plan.swift in Sources */, FDF848C629405C5B007DCAE5 /* DeleteAllMessagesRequest.swift in Sources */, FDE2875F2E96061E00442E03 /* ExtendExpirationResponse.swift in Sources */, FDF848D429405C5B007DCAE5 /* DeleteAllBeforeResponse.swift in Sources */, @@ -6814,9 +7017,12 @@ FD6B92B12E77AA03004463B5 /* HTTPHeader+SOGS.swift in Sources */, FD6B92F72E77C6D7004463B5 /* Crypto+PushNotification.swift in Sources */, FD6B92B22E77AA03004463B5 /* UpdateTypes.swift in Sources */, + FD0F85682EA83385004E0B98 /* SessionProEndpoint.swift in Sources */, + FD360EC52ECD24C30050CAF4 /* RevocationItem.swift in Sources */, FD6B92B32E77AA03004463B5 /* Personalization.swift in Sources */, FD6B929B2E77A084004463B5 /* NetworkInfo.swift in Sources */, FD47E0B52AA6D7AA00A55E41 /* Request+SnodeAPI.swift in Sources */, + FD0F856B2EA83525004E0B98 /* AppProPaymentRequest.swift in Sources */, FDE71B052E77E1AA0023F5F9 /* ObservableKey+SessionNetworkingKit.swift in Sources */, FD5E93D12C100FD70038C25A /* FileUploadResponse.swift in Sources */, FDF848D629405C5B007DCAE5 /* SnodeMessage.swift in Sources */, @@ -6824,20 +7030,28 @@ FDE754E32C9BAFF4002A2623 /* Crypto+SessionNetworkingKit.swift in Sources */, FD2272AD2C33E337004D8A6C /* Network.swift in Sources */, FD2272B32C33E337004D8A6C /* BatchRequest.swift in Sources */, + FD306BCE2EB02E3600ADB003 /* Signature.swift in Sources */, FDF848D129405C5B007DCAE5 /* SnodeSwarmItem.swift in Sources */, FDF848DD29405C5B007DCAE5 /* LegacySendMessageRequest.swift in Sources */, + FD0F856F2EA83664004E0B98 /* UserTransaction.swift in Sources */, FDF848BD29405C5A007DCAE5 /* GetMessagesRequest.swift in Sources */, FD2272B02C33E337004D8A6C /* NetworkError.swift in Sources */, + FD306BD62EB0323000ADB003 /* BackendUserProStatus.swift in Sources */, + FD0F85772EA83D92004E0B98 /* ProProof.swift in Sources */, + FD1F3CED2ED5728600E536D5 /* SetPaymentRefundRequestedResponse.swift in Sources */, FD6B92AB2E77A920004463B5 /* SOGS.swift in Sources */, FD19363C2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift in Sources */, FD6B92E92E77C5D1004463B5 /* SubscribeResponse.swift in Sources */, + FD99A3B82EC5882A00E59F94 /* AddProPaymentResponseStatus.swift in Sources */, FD6B92EA2E77C5D1004463B5 /* NotificationMetadata.swift in Sources */, FD6B92EB2E77C5D1004463B5 /* AuthenticatedRequest.swift in Sources */, + FD306BD22EB031AE00ADB003 /* PaymentItem.swift in Sources */, FD6B92EF2E77C5D1004463B5 /* UnsubscribeRequest.swift in Sources */, FD6B92F02E77C5D1004463B5 /* SubscribeRequest.swift in Sources */, FD6B92F22E77C5D1004463B5 /* UnsubscribeResponse.swift in Sources */, 947D7FD62D509FC900E8E413 /* SessionNetworkAPI.swift in Sources */, 947D7FD72D509FC900E8E413 /* HTTPClient.swift in Sources */, + FD0F85752EA83D5D004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift in Sources */, FD6B92B42E77AA11004463B5 /* PinnedMessage.swift in Sources */, FD6B92B52E77AA11004463B5 /* SendDirectMessageResponse.swift in Sources */, FD6B92B62E77AA11004463B5 /* UserUnbanRequest.swift in Sources */, @@ -6852,9 +7066,11 @@ FD6B92BF2E77AA11004463B5 /* DeleteInboxResponse.swift in Sources */, FD6B92C02E77AA11004463B5 /* SendDirectMessageRequest.swift in Sources */, FD6B92C12E77AA11004463B5 /* CapabilitiesResponse.swift in Sources */, + FD306BDC2EB0436C00ADB003 /* GenerateProProofRequest.swift in Sources */, FD6B92C22E77AA11004463B5 /* SOGSMessage.swift in Sources */, 947D7FD82D509FC900E8E413 /* KeyValueStore+SessionNetwork.swift in Sources */, FDF848DB29405C5B007DCAE5 /* DeleteMessagesResponse.swift in Sources */, + FD360EC32ECD23A40050CAF4 /* GetProRevocationsResponse.swift in Sources */, FD6B92F42E77C61A004463B5 /* ServiceInfo.swift in Sources */, FDF848E629405D6E007DCAE5 /* Destination.swift in Sources */, FD6B92A32E77A18B004463B5 /* SnodeAPI.swift in Sources */, @@ -6865,6 +7081,7 @@ FDF848CA29405C5B007DCAE5 /* DeleteAllBeforeRequest.swift in Sources */, FD2272AF2C33E337004D8A6C /* JSON.swift in Sources */, FD2272D62C34ED6A004D8A6C /* RetryWithDependencies.swift in Sources */, + FD1F3CEB2ED5728100E536D5 /* SetPaymentRefundRequestedRequest.swift in Sources */, FDF848D229405C5B007DCAE5 /* LegacyGetMessagesRequest.swift in Sources */, FDE287592E95BBAF00442E03 /* HTTPFragmentParam.swift in Sources */, FDF848E529405D6E007DCAE5 /* SnodeAPIError.swift in Sources */, @@ -6872,8 +7089,10 @@ FDE287552E94CFDB00442E03 /* URL+Utilities.swift in Sources */, FDF848D529405C5B007DCAE5 /* DeleteAllMessagesResponse.swift in Sources */, FD2272B22C33E337004D8A6C /* PreparedRequest.swift in Sources */, + FD0F85662EA82FCC004E0B98 /* PaymentProvider.swift in Sources */, FDF848BF29405C5A007DCAE5 /* SnodeResponse.swift in Sources */, FD6B92C82E77AD39004463B5 /* Crypto+SOGS.swift in Sources */, + FD0F85632EA82DF9004E0B98 /* SessionProAPI.swift in Sources */, FD6B92942E77A003004463B5 /* SessionNetworkEndpoint.swift in Sources */, FDD20C182A09E7D3003898FB /* GetExpiriesResponse.swift in Sources */, FD2272BB2C33E337004D8A6C /* HTTPMethod.swift in Sources */, @@ -6883,10 +7102,12 @@ FDF848D929405C5B007DCAE5 /* SnodeAuthenticatedRequestBody.swift in Sources */, FD6B92AD2E77A9F1004463B5 /* SOGSError.swift in Sources */, FD2272BA2C33E337004D8A6C /* HTTPHeader.swift in Sources */, + FD0F856D2EA835C5004E0B98 /* Signatures.swift in Sources */, FDF848CD29405C5B007DCAE5 /* GetNetworkTimestampResponse.swift in Sources */, FDF848DA29405C5B007DCAE5 /* GetMessagesResponse.swift in Sources */, FD6B92C62E77AD0F004463B5 /* Crypto+FileServer.swift in Sources */, FD2286682C37DA3B00BC06F7 /* LibSession+Networking.swift in Sources */, + FD0F857B2EA85FAB004E0B98 /* Request+SessionProAPI.swift in Sources */, FD2272A92C33E337004D8A6C /* ContentProxy.swift in Sources */, FD6B92E62E77C5A2004463B5 /* Service.swift in Sources */, FD6B92E72E77C5A2004463B5 /* Request+PushNotificationAPI.swift in Sources */, @@ -6905,6 +7126,7 @@ FD2272B42C33E337004D8A6C /* SwarmDrainBehaviour.swift in Sources */, 941375BB2D5184C20058F244 /* HTTPHeader+SessionNetwork.swift in Sources */, FD2272AE2C33E337004D8A6C /* HTTPQueryParam.swift in Sources */, + FD0F85612EA82C8B004E0B98 /* SessionPro.swift in Sources */, FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */, FD2272C42C34E9AA004D8A6C /* BencodeResponse.swift in Sources */, ); @@ -6921,7 +7143,9 @@ 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */, FD428B192B4B576F006D0888 /* AppContext.swift in Sources */, FD2272D42C34ECE1004D8A6C /* BencodeEncoder.swift in Sources */, + FDD42F4A2EEB790900771A4C /* FetchableTriple.swift in Sources */, FDE755052C9BB4EE002A2623 /* BencodeDecoder.swift in Sources */, + FD360EC72ECD38750050CAF4 /* OptionSet+Utilities.swift in Sources */, FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */, FDE754DC2C9BAF8A002A2623 /* CryptoError.swift in Sources */, FDFF9FDF2A787F57005E0628 /* JSONEncoder+Utilities.swift in Sources */, @@ -6944,6 +7168,7 @@ FD2272EA2C351CA7004D8A6C /* Threading.swift in Sources */, FD0E353C2AB9880B006A81F7 /* AppVersion.swift in Sources */, FD7F745F2BAAA3B4006DDFD8 /* TypeConversion+Utilities.swift in Sources */, + FD99A3B02EBD4EDD00E59F94 /* FetchablePair.swift in Sources */, FDE755062C9BB4EE002A2623 /* Bencode.swift in Sources */, C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */, 941375BD2D5195F30058F244 /* KeyValueStore.swift in Sources */, @@ -6951,7 +7176,6 @@ FD2272E02C3502BE004D8A6C /* Setting+Theme.swift in Sources */, FDE519F72AB7CDC700450C53 /* Result+Utilities.swift in Sources */, FD5931A72A8DA5DA0040147D /* SQLInterpolation+Utilities.swift in Sources */, - 9463794A2E7131070017A014 /* SessionProManagerType.swift in Sources */, FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */, FD3765EA2ADE37B400DC1489 /* Authentication.swift in Sources */, FDE755202C9BC1A6002A2623 /* CacheConfig.swift in Sources */, @@ -6972,6 +7196,7 @@ FDB3DA8B2E24834000148F8D /* AVURLAsset+Utilities.swift in Sources */, FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */, FDB11A542DCD7A7F00BEF49F /* Task+Utilities.swift in Sources */, + FD360ED62ED3D2280050CAF4 /* ObservationUtilities.swift in Sources */, FDE7551A2C9BC169002A2623 /* UIApplicationState+Utilities.swift in Sources */, 94C58AC92D2E037200609195 /* Permissions.swift in Sources */, FD09796B27F6C67500936362 /* Failable.swift in Sources */, @@ -6980,6 +7205,7 @@ FDE754DD2C9BAF8A002A2623 /* Mnemonic.swift in Sources */, FD52CB652E13B6E900A4DA70 /* ObservationBuilder.swift in Sources */, FDBEE52E2B6A18B900C143A0 /* UserDefaultsConfig.swift in Sources */, + FD0F85732EA83C44004E0B98 /* AnyCodable.swift in Sources */, FD78EA042DDEC3C500D55B50 /* MultiTaskManager.swift in Sources */, FD78EA062DDEC8F600D55B50 /* AsyncSequence+Utilities.swift in Sources */, FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */, @@ -6998,7 +7224,6 @@ 7B0EFDEE274F598600FFAAE7 /* TimestampUtils.swift in Sources */, FD2272D02C34EBD0004D8A6C /* FileManager.swift in Sources */, FD10AF122AF85D11007709E5 /* Feature+ServiceNetwork.swift in Sources */, - B8F5F58325EC94A6003BF8D4 /* Collection+Utilities.swift in Sources */, FD17D7A127F40D2500122BE0 /* Storage.swift in Sources */, FDE754DB2C9BAF8A002A2623 /* Crypto.swift in Sources */, FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */, @@ -7053,6 +7278,7 @@ FD2272FB2C352D8E004D8A6C /* LibSession+UserGroups.swift in Sources */, B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, FD22726F2C32911C004D8A6C /* ProcessPendingGroupMemberRemovalsJob.swift in Sources */, + FD1F3CFA2ED7B34C00E536D5 /* SessionProMessageFeatures.swift in Sources */, FD981BD72DC9A61A00564172 /* NotificationCategory.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, FDF71EA52B07363500A8D6B5 /* MessageReceiver+LibSession.swift in Sources */, @@ -7063,10 +7289,12 @@ FDE754A32C9A8FD1002A2623 /* SwarmPoller.swift in Sources */, 7B81682C28B72F480069F315 /* PendingChange.swift in Sources */, FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */, + FDAA36CE2EB4844F0040603E /* SessionProDecodedProForMessage.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, C300A5F22554B09800555489 /* MessageSender.swift in Sources */, FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */, FDE5218E2E03A06B00061B8E /* AttachmentManager.swift in Sources */, + FD99A3AC2EBC1B6E00E59F94 /* Server.swift in Sources */, FD47E0B12AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift in Sources */, FD2272832C337830004D8A6C /* GroupPoller.swift in Sources */, FD22726C2C32911C004D8A6C /* GroupLeavingJob.swift in Sources */, @@ -7085,10 +7313,13 @@ FDDD554E2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, 94CD95C12E0CBF430097754D /* _044_AddProMessageFlag.swift in Sources */, + FD2CFB9F2EE6293B00EC7F98 /* GlobalSearch.swift in Sources */, FD2272FC2C352D8E004D8A6C /* LibSession+Contacts.swift in Sources */, + FD99A3B22EC3E2F500E59F94 /* OWSAudioPlayer.swift in Sources */, FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */, FD05593D2DFA3A2800DC48CE /* VoipPayloadKey.swift in Sources */, FD2272782C32911C004D8A6C /* AttachmentDownloadJob.swift in Sources */, + FD99A39F2EBAA5EA00E59F94 /* DecodedEnvelope.swift in Sources */, FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */, FD428B232B4B9969006D0888 /* _031_RebuildFTSIfNeeded_2_4_5.swift in Sources */, FD2272742C32911C004D8A6C /* ConfigMessageReceiveJob.swift in Sources */, @@ -7097,13 +7328,17 @@ FDB5DAC72A9447E7002C8721 /* _036_GroupsRebuildChanges.swift in Sources */, FD09B7E5288670BB00ED0B66 /* _017_EmojiReacts.swift in Sources */, 7B8D5FC428332600008324D9 /* VisibleMessage+Reaction.swift in Sources */, + FDAA36C82EB475180040603E /* SessionProFeatureStatus.swift in Sources */, + FD360EBF2ECAD5190050CAF4 /* SessionProConfig.swift in Sources */, FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */, + FD1F3CF82ED6A6F400E536D5 /* SessionProRefundingStatus.swift in Sources */, FD2272FA2C352D8E004D8A6C /* LibSession+SharedGroup.swift in Sources */, FD37EA0F28AB3330003AE748 /* _014_FixHiddenModAdminSupport.swift in Sources */, FD2272772C32911C004D8A6C /* AttachmentUploadJob.swift in Sources */, FD22727C2C32911C004D8A6C /* GroupPromoteMemberJob.swift in Sources */, FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */, FDE754F22C9BB08B002A2623 /* Crypto+SessionMessagingKit.swift in Sources */, + FD2CFB9B2EE0FECE00EC7F98 /* ConversationDataHelper.swift in Sources */, FD4C53AF2CC1D62E003B10F4 /* _035_ReworkRecipientState.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, FD6C67242CF6E72E00B350A7 /* NoopSessionCallManager.swift in Sources */, @@ -7113,13 +7348,14 @@ FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, FD78EA0D2DDFEDE200D55B50 /* LibSession+Local.swift in Sources */, FDD23AE12E457CDE0057E853 /* _005_SNK_SetupStandardJobs.swift in Sources */, - FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */, + FD3E0C84283B5835002A425C /* ConversationInfoViewModel.swift in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FDE754F02C9BB08B002A2623 /* Crypto+Attachments.swift in Sources */, FD17D79927F40AB800122BE0 /* _009_SMK_YDBToGRDBMigration.swift in Sources */, FDE754A12C9A60A6002A2623 /* Crypto+OpenGroup.swift in Sources */, FDF0B7512807BA56004C14C5 /* NotificationsManagerType.swift in Sources */, FD2272722C32911C004D8A6C /* FailedAttachmentDownloadsJob.swift in Sources */, + FD1F3CF62ED69B6600E536D5 /* SessionProState.swift in Sources */, FDD23AEA2E458EB00057E853 /* _012_AddJobPriority.swift in Sources */, FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */, B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */, @@ -7141,25 +7377,26 @@ FDB5DAE02A95D84D002C8721 /* GroupUpdateMemberLeftMessage.swift in Sources */, FD8FD7622C37B7BD001E38C7 /* Position.swift in Sources */, 7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */, - 94B6BAF62E30A88800E718BB /* SessionProState.swift in Sources */, + 94B6BAF62E30A88800E718BB /* SessionProManager.swift in Sources */, FDB5DADE2A95D847002C8721 /* GroupUpdatePromoteMessage.swift in Sources */, + FD360ED42ED035150050CAF4 /* ImageLoading+Convenience.swift in Sources */, FD245C5B2850660500B966DD /* ReadReceipt.swift in Sources */, FD428B1F2B4B758B006D0888 /* AppReadiness.swift in Sources */, FD22726B2C32911C004D8A6C /* SendReadReceiptsJob.swift in Sources */, B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, + FD2CFB992EDFF32E00EC7F98 /* ConversationDataCache.swift in Sources */, + FD99A3A42EBAA6BD00E59F94 /* EnvelopeFlags.swift in Sources */, C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */, + FD184C232EF2100D001089EB /* SessionProError.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, FDF0B7422804EA4F004C14C5 /* _007_SMK_SetupStandardJobs.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, FD8ECF7D2934293A00C0D1BB /* _027_SessionUtilChanges.swift in Sources */, FD17D7A227F40F0500122BE0 /* _006_SMK_InitialSetupMigration.swift in Sources */, - FD245C5D2850660F00B966DD /* OWSAudioPlayer.m in Sources */, FD2272FE2C352D8E004D8A6C /* LibSession+GroupMembers.swift in Sources */, - FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */, FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, + FD99A3A22EBAA6AA00E59F94 /* Envelope.swift in Sources */, FD2272732C32911C004D8A6C /* ConfigurationSyncJob.swift in Sources */, - FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, - 94805EC12EB48D910055EBBC /* SessionProState+Models.swift in Sources */, FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, FD2272752C32911C004D8A6C /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, FD2272712C32911C004D8A6C /* MessageReceiveJob.swift in Sources */, @@ -7177,14 +7414,19 @@ FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */, FDD23AEB2E458F4D0057E853 /* _020_AddJobUniqueHash.swift in Sources */, FDB11A502DCC6ADE00BEF49F /* ThreadUpdateInfo.swift in Sources */, + FD99A3B62EC562DB00E59F94 /* _047_DropUnneededColumnsAndTables.swift in Sources */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, + FD360ECF2ECEE5F60050CAF4 /* SessionProLoadingState.swift in Sources */, FDC1BD662CFD6C4F002CDC71 /* Config.swift in Sources */, + FD99A3BA2EC58DE300E59F94 /* _048_SessionProChanges.swift in Sources */, FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */, - FD245C55285065E500B966DD /* OpenGroupManager.swift in Sources */, + FD245C55285065E500B966DD /* CommunityManager.swift in Sources */, + FDAA36CA2EB476090040603E /* SessionProProfileFeatures.swift in Sources */, C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, FDE7549B2C940108002A2623 /* MessageViewModel+DeletionActions.swift in Sources */, FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, FD37EA0D28AB2A45003AE748 /* _013_FixDeletedMessageReadState.swift in Sources */, + FD2CFB8E2EDD00F500EC7F98 /* SessionProOriginatingAccount.swift in Sources */, FDD23AE32E457CFE0057E853 /* _010_FlagMessageHashAsDeletedOrInvalid.swift in Sources */, 7BAA7B6628D2DE4700AE1489 /* _018_OpenGroupPermission.swift in Sources */, FD2286692C37DA5500BC06F7 /* PollerType.swift in Sources */, @@ -7201,6 +7443,8 @@ FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */, FDBA8A842D597975007C19C0 /* FailedGroupInvitesAndPromotionsJob.swift in Sources */, FD778B6429B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift in Sources */, + FD360ED82ED3E5C20050CAF4 /* SessionProPlan.swift in Sources */, + FD2CFB9D2EE3F63600EC7F98 /* GroupAuthData.swift in Sources */, FD1A55432E179AED003761E4 /* ObservableKeyEvent+Utilities.swift in Sources */, FDD23AE92E458E020057E853 /* _003_SUK_YDBToGRDBMigration.swift in Sources */, FD8ECF7F2934298100C0D1BB /* ConfigDump.swift in Sources */, @@ -7208,13 +7452,17 @@ FD2272792C32911C004D8A6C /* DisplayPictureDownloadJob.swift in Sources */, FD981BD92DC9A69600564172 /* NotificationUserInfoKey.swift in Sources */, FDD23AE52E458C940057E853 /* _022_DropSnodeCache.swift in Sources */, + FD2C68612EA09527000B0E37 /* MessageError.swift in Sources */, FD16AB612A1DD9B60083D849 /* ProfilePictureView+Convenience.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, FD2272762C32911C004D8A6C /* ExpirationUpdateJob.swift in Sources */, + FDAA36C62EB474C80040603E /* SessionProFeaturesForMessage.swift in Sources */, FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */, FD368A6829DE8F9C000DBF1E /* _026_AddFTSIfNeeded.swift in Sources */, FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, + FD1F3CF32ED657AC00E536D5 /* Constants+LibSession.swift in Sources */, + FD2CFB972EDE645D00EC7F98 /* SessionPro+Convenience.swift in Sources */, FDFE75B12ABD2D2400655640 /* _030_MakeBrokenProfileTimestampsNullable.swift in Sources */, FD09799B27FFC82D00936362 /* Quote.swift in Sources */, FD2273012C352D8E004D8A6C /* LibSession+Shared.swift in Sources */, @@ -7227,7 +7475,7 @@ FD2272FF2C352D8E004D8A6C /* LibSession+UserProfile.swift in Sources */, FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, FDB5DAE82A95D96C002C8721 /* MessageReceiver+Groups.swift in Sources */, - 94CD95BD2E09083C0097754D /* LibSession+Pro.swift in Sources */, + FD99A3A62EBAAA1700E59F94 /* DecodedMessage.swift in Sources */, FD1D732E2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift in Sources */, FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */, FDAA167F2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift in Sources */, @@ -7238,11 +7486,11 @@ FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, - FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, FDAB8A852EB2BC37000A6C65 /* MentionSelectionView+SessionMessagingKit.swift in Sources */, FDB5DADA2A95D839002C8721 /* GroupUpdateInfoChangeMessage.swift in Sources */, FDF71EA32B072C2800A8D6B5 /* LibSessionMessage.swift in Sources */, FDAA167D2AC528A200DDBF77 /* Preferences+Sound.swift in Sources */, + FDAA36D02EB485F20040603E /* SessionProDecodedStatus.swift in Sources */, FDD23AED2E4590A10057E853 /* _041_RenameTableSettingToKeyValueStore.swift in Sources */, FDE754FE2C9BB0D0002A2623 /* Threading+SessionMessagingKit.swift in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, @@ -7321,7 +7569,6 @@ 7B1B52E028580D51006069F2 /* EmojiSkinTonePicker.swift in Sources */, FD71164428E2CB8A00B47552 /* SessionCell+Accessory.swift in Sources */, 7B1B52DF28580D51006069F2 /* EmojiPickerCollectionView.swift in Sources */, - FDE5219A2E08DBB800061B8E /* ImageLoading+Convenience.swift in Sources */, FD71165228E410BE00B47552 /* SessionTableSection.swift in Sources */, C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */, 9422568B2C23F8C800C0FDBF /* LoadAccountScreen.swift in Sources */, @@ -7346,7 +7593,6 @@ FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */, FD37EA0328A9FDCC003AE748 /* HelpViewModel.swift in Sources */, FDFDE124282D04F20098B17F /* MediaDismissAnimationController.swift in Sources */, - FDB3DA8D2E24881B00148F8D /* ImageLoading+Convenience.swift in Sources */, 7BA6890F27325CE300EFC32F /* SessionCallManager+CXProvider.swift in Sources */, 7B46AAAF28766DF4001AF2DC /* AllMediaViewController.swift in Sources */, FD71162228D983ED00B47552 /* QRCodeScanningViewController.swift in Sources */, @@ -7400,14 +7646,13 @@ 942256892C23F8C800C0FDBF /* LandingScreen.swift in Sources */, B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */, 942256882C23F8C800C0FDBF /* PNModeScreen.swift in Sources */, - FD37E9DB28A244E9003AE748 /* ThemeMessagePreviewView.swift in Sources */, FD7443422D07A27E00862443 /* SyncPushTokensJob.swift in Sources */, - FD37E9DB28A244E9003AE748 /* ThemeMessagePreviewView.swift in Sources */, 7B3A3934298882D6002FE4AC /* SessionCarouselViewDelegate.swift in Sources */, 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, 942256802C23F8BB00C0FDBF /* StartConversationScreen.swift in Sources */, 7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */, 7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */, + FD1DD8BA2EF3ACF5009F2C1B /* ThemeMessagePreviewView.swift in Sources */, FD7115F428C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift in Sources */, 7B7CB190270FB2150079FF93 /* MiniCallView.swift in Sources */, 7B13E1E92810F01300BD4F64 /* SessionCallManager+Action.swift in Sources */, @@ -7436,7 +7681,6 @@ C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, 3488F9362191CC4000E524CC /* MediaView.swift in Sources */, B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */, - FD981BD52DC978B400564172 /* MentionUtilities+DisplayName.swift in Sources */, 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */, FD4B200E283492210034334B /* AfterLayoutCallbackTableView.swift in Sources */, FD12A8472AD63C3400EEBA0D /* PagedObservationSource.swift in Sources */, @@ -7650,8 +7894,9 @@ FD981BCD2DC81ABF00564172 /* MockExtensionHelper.swift in Sources */, FD336F602CAA28CF00C0B51B /* CommonSMKMockExtensions.swift in Sources */, FD336F612CAA28CF00C0B51B /* MockNotificationsManager.swift in Sources */, + FD71B9B02EF25A1200379A99 /* GlobalSearchSpec.swift in Sources */, FD336F622CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift in Sources */, - FD336F632CAA28CF00C0B51B /* MockOGMCache.swift in Sources */, + FD336F632CAA28CF00C0B51B /* MockCommunityManager.swift in Sources */, FD481A922CAD17DE00ECC4CF /* LibSessionGroupMembersSpec.swift in Sources */, FD336F642CAA28CF00C0B51B /* MockCommunityPollerCache.swift in Sources */, FD336F652CAA28CF00C0B51B /* MockDisplayPictureCache.swift in Sources */, @@ -7672,7 +7917,6 @@ FD23CE342A67C4D90000B97C /* MockNetwork.swift in Sources */, FD61FCF92D308CC9005752DE /* GroupMemberSpec.swift in Sources */, FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, - FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */, FD0969F92A69FFE700C5C365 /* Mocked.swift in Sources */, FD481A902CAD16F100ECC4CF /* LibSessionGroupInfoSpec.swift in Sources */, FDE754A92C9B964D002A2623 /* MessageSenderGroupsSpec.swift in Sources */, @@ -7684,7 +7928,7 @@ FD01502A2CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */, FD01503B2CA24328005B08A1 /* MockJobRunner.swift in Sources */, FD3F2EE72DE6CC4100FD6849 /* NotificationsManagerSpec.swift in Sources */, - FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, + FD078E5427E197CA000769AF /* CommunityManagerSpec.swift in Sources */, FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, FDE287612E970D5C00442E03 /* Async+Utilities.swift in Sources */, @@ -11090,7 +11334,7 @@ repositoryURL = "https://github.com/session-foundation/libsession-util-spm"; requirement = { kind = exactVersion; - version = 1.5.7; + version = 1.5.9; }; }; FD6A38E72C2A630E00762359 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */ = { @@ -11450,6 +11694,11 @@ package = FD6A39202C2AA91D00762359 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */; productName = NVActivityIndicatorView; }; + FDD42F452EE7B12100771A4C /* Lucide */ = { + isa = XCSwiftPackageProductDependency; + package = FD756BEE2D06686500BD7199 /* XCRemoteSwiftPackageReference "session-lucide" */; + productName = Lucide; + }; FDEF57292C3CF50B00131302 /* WebRTC */ = { isa = XCSwiftPackageProductDependency; package = FD6A390E2C2A93CD00762359 /* XCRemoteSwiftPackageReference "WebRTC" */; diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 200c4861d2..d507c27397 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/session-foundation/libsession-util-spm", "state" : { - "revision" : "38baf3f75ba50e6ba3950caa5709a40971c13e89", - "version" : "1.5.7" + "revision" : "fa667ed2cc1e0633cff131c41746c59088dd9370", + "version" : "1.5.9" } }, { diff --git a/Session.xcodeproj/xcshareddata/xcschemes/Session_CompileLibSession.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/Session_CompileLibSession.xcscheme index f6f423999e..34930aa4d8 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/Session_CompileLibSession.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/Session_CompileLibSession.xcscheme @@ -52,7 +52,6 @@ buildConfiguration = "Debug_Compile_LibSession" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - enableAddressSanitizer = "YES" enableASanStackUseAfterReturn = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index f08656626c..445a51611e 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -218,7 +218,9 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { let webRTCSession: WebRTCSession = self.webRTCSession let timestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - let disappearingMessagesConfiguration = try? thread.disappearingMessagesConfiguration.fetchOne(db)?.forcedWithDisappearAfterReadIfNeeded() + let disappearingMessagesConfiguration = try? DisappearingMessagesConfiguration + .fetchOne(db, id: thread.id)? + .forcedWithDisappearAfterReadIfNeeded() let message: CallMessage = CallMessage( uuid: self.uuid, kind: .preOffer, @@ -248,7 +250,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { message: message, threadId: thread.id, interactionId: interaction?.id, - authMethod: try Authentication.with(db, swarmPublicKey: thread.id, using: dependencies) + authMethod: try Authentication.with(swarmPublicKey: thread.id, using: dependencies) ) .retry(5) // Start the timeout timer for the call diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index 81c9c922d1..f16d95940a 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -209,11 +209,7 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { let call: SessionCall = dependencies[singleton: .storage].read({ [dependencies] db in SessionCall( for: caller, - contactName: Profile.displayName( - db, - id: caller, - threadVariant: .contact - ), + contactName: Profile.displayName(db, id: caller), uuid: uuid, mode: mode, using: dependencies @@ -235,7 +231,7 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { if let conversationVC: ConversationVC = currentFrontMostViewController as? ConversationVC, - conversationVC.viewModel.threadData.threadId == call.sessionId + conversationVC.viewModel.state.threadId == call.sessionId { let callVC = CallVC(for: call, using: dependencies) currentFrontMostViewController.present(callVC, animated: true, completion: nil) diff --git a/Session/Calls/WebRTC/WebRTCSession.swift b/Session/Calls/WebRTC/WebRTCSession.swift index d579d8a1a5..a9fe94c978 100644 --- a/Session/Calls/WebRTC/WebRTCSession.swift +++ b/Session/Calls/WebRTC/WebRTCSession.swift @@ -181,14 +181,11 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { } dependencies[singleton: .storage] - .writePublisher { db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in - ( - try Authentication.with(db, swarmPublicKey: thread.id, using: dependencies), - try DisappearingMessagesConfiguration.fetchOne(db, id: thread.id) - ) + .writePublisher { db -> DisappearingMessagesConfiguration? in + try DisappearingMessagesConfiguration.fetchOne(db, id: thread.id) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .tryFlatMap { authMethod, disappearingMessagesConfiguration in + .tryFlatMap { disappearingMessagesConfiguration in try MessageSender.preparedSend( message: CallMessage( uuid: uuid, @@ -201,7 +198,10 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { namespace: .default, interactionId: nil, attachments: nil, - authMethod: authMethod, + authMethod: try Authentication.with( + swarmPublicKey: thread.id, + using: dependencies + ), onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ).send(using: dependencies) @@ -226,7 +226,7 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { let mediaConstraints: RTCMediaConstraints = mediaConstraints(false) return dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + .readPublisher { db -> DisappearingMessagesConfiguration? in /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread guard SessionThread @@ -235,12 +235,9 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { .isNotEmpty(db) else { throw WebRTCSessionError.noThread } - return ( - try Authentication.with(db, swarmPublicKey: sessionId, using: dependencies), - try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) - ) + return try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) } - .flatMap { [weak self, dependencies] authMethod, disappearingMessagesConfiguration in + .flatMap { [weak self, dependencies] disappearingMessagesConfiguration in Future { resolver in self?.peerConnection?.answer(for: mediaConstraints) { [weak self] sdp, error in if let error = error { @@ -271,7 +268,10 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { namespace: .default, interactionId: nil, attachments: nil, - authMethod: authMethod, + authMethod: try Authentication.with( + swarmPublicKey: sessionId, + using: dependencies + ), onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -313,7 +313,7 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { self.queuedICECandidates.removeAll() return dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + .readPublisher { db -> DisappearingMessagesConfiguration? in /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread guard SessionThread @@ -322,13 +322,10 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { .isNotEmpty(db) else { throw WebRTCSessionError.noThread } - return ( - try Authentication.with(db, swarmPublicKey: contactSessionId, using: dependencies), - try DisappearingMessagesConfiguration.fetchOne(db, id: contactSessionId) - ) + return try DisappearingMessagesConfiguration.fetchOne(db, id: contactSessionId) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .tryFlatMap { [dependencies] authMethod, disappearingMessagesConfiguration in + .tryFlatMap { [dependencies] disappearingMessagesConfiguration in Log.info(.calls, "Batch sending \(candidates.count) ICE candidates.") return try MessageSender @@ -346,7 +343,10 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { namespace: .default, interactionId: nil, attachments: nil, - authMethod: authMethod, + authMethod: try Authentication.with( + swarmPublicKey: contactSessionId, + using: dependencies + ), onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -368,7 +368,7 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { public func endCall(with sessionId: String) { return dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + .readPublisher { db -> DisappearingMessagesConfiguration? in /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread guard SessionThread @@ -377,13 +377,10 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { .isNotEmpty(db) else { throw WebRTCSessionError.noThread } - return ( - try Authentication.with(db, swarmPublicKey: sessionId, using: dependencies), - try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) - ) + return try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .tryFlatMap { [dependencies, uuid] authMethod, disappearingMessagesConfiguration in + .tryFlatMap { [dependencies, uuid] disappearingMessagesConfiguration in Log.info(.calls, "Sending end call message.") return try MessageSender @@ -398,7 +395,10 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { namespace: .default, interactionId: nil, attachments: nil, - authMethod: authMethod, + authMethod: try Authentication.with( + swarmPublicKey: sessionId, + using: dependencies + ), onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index 51af3eeb16..ba324f408f 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -302,7 +302,9 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa identifier: "Contact" ), trailingImage: { - guard (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: memberInfo.profile) }) else { return nil } + guard memberInfo.profile?.proFeatures.contains(.proBadge) == true else { + return nil + } return SessionProBadge.trailingImage( size: .small, diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 37faac7b3e..714d3316ea 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -439,15 +439,13 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate /// When this is triggered via the "Recreate Group" action for Legacy Groups the screen will have been /// pushed instead of presented and, as a result, we need to dismiss the `activityIndicatorViewController` /// and want the transition to be animated in order to behave nicely - await MainActor.run { [weak self, dependencies] in - dependencies[singleton: .app].presentConversationCreatingIfNeeded( - for: thread.id, - variant: thread.variant, - action: .none, - dismissing: (self?.presentingViewController ?? indicator), - animated: (self?.presentingViewController == nil) - ) - } + await dependencies[singleton: .app].presentConversationCreatingIfNeeded( + for: thread.id, + variant: thread.variant, + action: .none, + dismissing: (self.presentingViewController ?? indicator), + animated: (self.presentingViewController == nil) + ) } catch { await MainActor.run { [weak self] in diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index cd95b52fc1..5b39acf6d8 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -187,7 +187,11 @@ extension ContextMenuVC { static func actions( for cellViewModel: MessageViewModel, - in threadViewModel: SessionThreadViewModel, + threadInfo: ConversationInfoViewModel, + authMethod: AuthenticationMethod, + reactionsSupported: Bool, + recentReactionEmoji: [String], + isUserModeratorOrAdmin: Bool, forMessageInfoScreen: Bool, delegate: ContextMenuActionDelegate?, using dependencies: Dependencies @@ -221,18 +225,18 @@ extension ContextMenuVC { cellViewModel.cellType == .genericAttachment || cellViewModel.cellType == .mediaMessage ) && - (cellViewModel.attachments ?? []).count == 1 && - (cellViewModel.attachments ?? []).first?.isVisualMedia == true && - (cellViewModel.attachments ?? []).first?.isValid == true && ( - (cellViewModel.attachments ?? []).first?.state == .downloaded || - (cellViewModel.attachments ?? []).first?.state == .uploaded + cellViewModel.attachments.count == 1 && + cellViewModel.attachments.first?.isVisualMedia == true && + cellViewModel.attachments.first?.isValid == true && ( + cellViewModel.attachments.first?.state == .downloaded || + cellViewModel.attachments.first?.state == .uploaded ) ) ) let canSave: Bool = { switch cellViewModel.cellType { case .mediaMessage: - return (cellViewModel.attachments ?? []) + return cellViewModel.attachments .filter { attachment in attachment.isValid && attachment.isVisualMedia && ( @@ -242,7 +246,7 @@ extension ContextMenuVC { }.isEmpty == false case .audio, .genericAttachment: - return (cellViewModel.attachments ?? []) + return cellViewModel.attachments .filter { attachment in attachment.isValid && ( attachment.state == .downloaded || @@ -258,40 +262,22 @@ extension ContextMenuVC { cellViewModel.threadVariant != .community && !forMessageInfoScreen ) - let canDelete: Bool = (MessageViewModel.DeletionBehaviours.deletionActions( + let canDelete: Bool = ((try? MessageViewModel.DeletionBehaviours.deletionActions( for: [cellViewModel], - with: threadViewModel, + threadInfo: threadInfo, + authMethod: authMethod, + isUserModeratorOrAdmin: isUserModeratorOrAdmin, using: dependencies - ) != nil) + )) != nil) let canBan: Bool = ( cellViewModel.threadVariant == .community && - dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( - publicKey: threadViewModel.currentUserSessionId, - for: threadViewModel.openGroupRoomToken, - on: threadViewModel.openGroupServer, - currentUserSessionIds: (threadViewModel.currentUserSessionIds ?? []) - ) + isUserModeratorOrAdmin ) - let shouldShowEmojiActions: Bool = { - guard cellViewModel.threadVariant != .legacyGroup else { return false } - - if cellViewModel.threadVariant == .community { - return ( - !forMessageInfoScreen && - dependencies[singleton: .openGroupManager].doesOpenGroupSupport( - capability: .reactions, - on: cellViewModel.threadOpenGroupServer - ) - ) - } - return (threadViewModel.threadIsMessageRequest != true && !forMessageInfoScreen) - }() let recentEmojis: [EmojiWithSkinTones] = { - guard shouldShowEmojiActions else { return [] } + guard reactionsSupported else { return [] } - return (threadViewModel.recentReactionEmoji ?? []) - .compactMap { EmojiWithSkinTones(rawValue: $0) } + return recentReactionEmoji.compactMap { EmojiWithSkinTones(rawValue: $0) } }() let generatedActions: [Action] = [ (canRetry ? Action.retry(cellViewModel, delegate) : nil), @@ -305,7 +291,7 @@ extension ContextMenuVC { (forMessageInfoScreen ? nil : Action.info(cellViewModel, delegate)), ] .appending( - contentsOf: (shouldShowEmojiActions ? recentEmojis : []) + contentsOf: (reactionsSupported ? recentEmojis : []) .map { Action.react(cellViewModel, $0, delegate) } ) .appending(forMessageInfoScreen ? nil : Action.emojiPlusButton(cellViewModel, delegate)) @@ -322,7 +308,7 @@ extension ContextMenuVC { protocol ContextMenuActionDelegate { func info(_ cellViewModel: MessageViewModel) @MainActor func retry(_ cellViewModel: MessageViewModel, completion: (@MainActor () -> Void)?) - func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + @MainActor func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) func copy(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) func copySessionID(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) diff --git a/Session/Conversations/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index 69e80a7b90..a4b759c7c1 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -90,7 +90,7 @@ extension ConversationSearchController: UISearchResultsUpdating { .readPublisher { db -> [Interaction.TimestampInfo] in try Interaction.idsForTermWithin( threadId: threadId, - pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) + pattern: try GlobalSearch.pattern(db, searchTerm: searchText) ) .fetchAll(db) } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index e29a99e6da..3a36f7f119 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -29,7 +29,7 @@ extension ConversationVC: @MainActor @objc func handleTitleViewTapped() { // Don't take the user to settings for unapproved threads - guard viewModel.threadData.threadRequiresApproval == false else { return } + guard !viewModel.state.threadInfo.requiresApproval else { return } openSettingsFromTitleView() } @@ -47,26 +47,42 @@ extension ConversationVC: @MainActor func openSettingsFromTitleView() { // If we shouldn't be able to access settings then disable the title view shortcuts - guard viewModel.threadData.canAccessSettings(using: viewModel.dependencies) else { return } + guard viewModel.state.threadInfo.canAccessSettings else { return } - switch (titleView.currentLabelType, viewModel.threadData.threadVariant, viewModel.threadData.currentUserIsClosedGroupMember, viewModel.threadData.currentUserIsClosedGroupAdmin) { - case (.userCount, .group, _, true), (.userCount, .legacyGroup, _, true): + switch (titleView.currentLabelType, viewModel.state.threadVariant, viewModel.state.threadInfo.groupInfo?.currentUserRole) { + case (.userCount, .group, .admin), (.userCount, .legacyGroup, .admin): let viewController = SessionTableViewController( viewModel: EditGroupViewModel( - threadId: self.viewModel.threadData.threadId, + threadId: self.viewModel.state.threadId, using: self.viewModel.dependencies ) ) navigationController?.pushViewController(viewController, animated: true) - case (.userCount, .group, true, _), (.userCount, .legacyGroup, true, _): + case (.userCount, .group, .some), (.userCount, .legacyGroup, .some): let viewController: UIViewController = ThreadSettingsViewModel.createMemberListViewController( - threadId: self.viewModel.threadData.threadId, - transitionToConversation: { [weak self, dependencies = viewModel.dependencies] selectedMemberId in + threadId: self.viewModel.state.threadId, + transitionToConversation: { [weak self, dependencies = viewModel.dependencies] maybeThreadInfo in + guard let threadInfo: ConversationInfoViewModel = maybeThreadInfo else { + self?.navigationController?.present( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "theError".localized(), + body: .text("errorUnknown".localized()), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ), + animated: true, + completion: nil + ) + return + } + self?.navigationController?.pushViewController( ConversationVC( - threadId: selectedMemberId, - threadVariant: .contact, + threadInfo: threadInfo, + focusedInteractionInfo: nil, using: dependencies ), animated: true @@ -76,31 +92,30 @@ extension ConversationVC: ) navigationController?.pushViewController(viewController, animated: true) - case (.disappearingMessageSetting, _, _, _): - guard let config: DisappearingMessagesConfiguration = self.viewModel.threadData.disappearingMessagesConfiguration else { + case (.disappearingMessageSetting, _, _): + guard let config: DisappearingMessagesConfiguration = self.viewModel.state.threadInfo.disappearingMessagesConfiguration else { return openSettings() } let viewController = SessionTableViewController( viewModel: ThreadDisappearingMessagesSettingsViewModel( - threadId: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, - currentUserIsClosedGroupMember: self.viewModel.threadData.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: self.viewModel.threadData.currentUserIsClosedGroupAdmin, + threadId: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, + currentUserRole: self.viewModel.state.threadInfo.groupInfo?.currentUserRole, config: config, using: self.viewModel.dependencies ) ) navigationController?.pushViewController(viewController, animated: true) - case (.userCount, _, _, _), (.none, _, _, _), (.notificationSettings, _, _, _): openSettings() + case (.userCount, _, _), (.none, _, _), (.notificationSettings, _, _): openSettings() } } @objc func openSettings() { - let viewController = SessionTableViewController(viewModel: ThreadSettingsViewModel( - threadId: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, + let viewController = SessionTableViewController( + viewModel: ThreadSettingsViewModel( + threadInfo: self.viewModel.state.threadInfo, didTriggerSearch: { [weak self] in DispatchQueue.main.async { self?.hasPendingInputKeyboardPresentationEvent = true @@ -131,7 +146,7 @@ extension ConversationVC: // MARK: - Call @objc func startCall(_ sender: Any?) { - guard viewModel.threadData.threadIsBlocked != true else { + guard !viewModel.state.threadInfo.isBlocked else { self.showBlockedModalIfNeeded() return } @@ -197,17 +212,15 @@ extension ConversationVC: return } - let threadId: String = self.viewModel.threadData.threadId - guard Permissions.microphone == .granted, - self.viewModel.threadData.threadVariant == .contact, + self.viewModel.state.threadVariant == .contact, viewModel.dependencies[singleton: .callManager].currentCall == nil else { return } let call: SessionCall = SessionCall( - for: threadId, - contactName: self.viewModel.threadData.displayName, + for: self.viewModel.state.threadId, + contactName: self.viewModel.state.threadInfo.displayName.deformatted(), uuid: UUID().uuidString.lowercased(), mode: .offer, using: viewModel.dependencies @@ -220,19 +233,19 @@ extension ConversationVC: @MainActor @discardableResult func showBlockedModalIfNeeded() -> Bool { guard - self.viewModel.threadData.threadVariant == .contact && - self.viewModel.threadData.threadIsBlocked == true + self.viewModel.state.threadVariant == .contact && + self.viewModel.state.threadInfo.isBlocked else { return false } let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: String( format: "blockUnblock".localized(), - self.viewModel.threadData.displayName + self.viewModel.state.threadInfo.displayName.deformatted() ), body: .attributedText( "blockUnblockName" - .put(key: "name", value: viewModel.threadData.displayName) + .put(key: "name", value: viewModel.state.threadInfo.displayName.deformatted()) .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) ), confirmTitle: "blockUnblock".localized(), @@ -477,8 +490,8 @@ extension ConversationVC: } func handleLibraryButtonTapped() { - let threadId: String = self.viewModel.threadData.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant + let threadId: String = self.viewModel.state.threadId + let threadVariant: SessionThread.Variant = self.viewModel.state.threadVariant let quoteViewModel: QuoteViewModel? = self.snInputView.quoteViewModel Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: viewModel.dependencies) { [weak self, dependencies = viewModel.dependencies] granted in @@ -511,8 +524,8 @@ extension ConversationVC: } let sendMediaNavController = SendMediaNavigationController.showingCameraFirst( - threadId: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, + threadId: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, quoteViewModel: self.snInputView.quoteViewModel, onQuoteCancelled: { [weak self] in self?.snInputView.quoteViewModel = nil @@ -535,12 +548,12 @@ extension ConversationVC: let viewController: AttachmentApprovalViewController = AttachmentApprovalViewController( mode: .modal, delegate: self, - threadId: viewModel.threadData.threadId, - threadVariant: viewModel.threadData.threadVariant, + threadId: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, attachments: attachments, messageText: snInputView.text, quoteViewModel: snInputView.quoteViewModel, - disableLinkPreviewImageDownload: (viewModel.threadData.threadCanUpload != true), + disableLinkPreviewImageDownload: !self.viewModel.state.threadInfo.canUpload, didLoadLinkPreview: nil, onQuoteCancelled: { [weak self] in self?.snInputView.quoteViewModel = nil @@ -557,16 +570,17 @@ extension ConversationVC: // MARK: - InputViewDelegate @MainActor func handleDisabledInputTapped() { - guard viewModel.threadData.threadIsBlocked == true else { return } + guard viewModel.state.threadInfo.isBlocked else { return } self.showBlockedModalIfNeeded() } @MainActor func handleCharacterLimitLabelTapped() { - guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .longerMessages(renew: viewModel.dependencies[singleton: .sessionProState].isSessionProExpired), - onConfirm: { [weak self, dependencies = viewModel.dependencies] in - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + let manager: SessionProManagerType = viewModel.dependencies[singleton: .sessionProManager] + let didShowCTAModal: Bool = manager.showSessionProCTAIfNeeded( + .longerMessages(renew: (manager.currentUserCurrentProState.status == .expired)), + onConfirm: { [weak self, manager] in + manager.showSessionProBottomSheetIfNeeded( afterClosed: { [weak self] in self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") }, @@ -581,33 +595,29 @@ extension ConversationVC: presenting: { [weak self] modal in self?.present(modal, animated: true) } - ) else { - return - } + ) + + guard !didShowCTAModal else { return } - let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( - for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: viewModel.isCurrentUserSessionPro + let numberOfCharactersLeft: Int = viewModel.dependencies[singleton: .sessionProManager].numberOfCharactersLeft( + for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines) ) - let limit: Int = (viewModel.isCurrentUserSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit) let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( - title: ( - (numberOfCharactersLeft >= 0) ? - "modalMessageCharacterDisplayTitle".localized() : - "modalMessageCharacterTooLongTitle".localized() + title: (numberOfCharactersLeft >= 0 ? + "modalMessageCharacterDisplayTitle".localized() : + "modalMessageCharacterTooLongTitle".localized() ), body: .text( - ( - (numberOfCharactersLeft >= 0) ? - "modalMessageCharacterDisplayDescription" - .putNumber(numberOfCharactersLeft) - .put(key: "limit", value: limit) - .localized() : - "modalMessageCharacterTooLongDescription" - .put(key: "limit", value: limit) - .localized() + (numberOfCharactersLeft >= 0 ? + "modalMessageCharacterDisplayDescription" + .putNumber(numberOfCharactersLeft) + .put(key: "limit", value: viewModel.dependencies[singleton: .sessionProManager].characterLimit) + .localized() : + "modalMessageCharacterTooLongDescription" + .put(key: "limit", value: viewModel.dependencies[singleton: .sessionProManager].characterLimit) + .localized() ), scrollMode: .never ), @@ -659,7 +669,7 @@ extension ConversationVC: /// This logic was added because an Apple reviewer rejected an emergency update as they thought these buttons were /// unresponsive (even though there is copy on the screen communicating that they are intentionally disabled) - in order /// to prevent this happening in the future we've added this toast when pressing on the disabled button - guard viewModel.threadData.threadIsMessageRequest == true else { return } + guard viewModel.state.threadInfo.isMessageRequest else { return } let toastController: ToastController = ToastController( text: "messageRequestDisabledToastAttachments".localized(), @@ -676,7 +686,7 @@ extension ConversationVC: /// This logic was added because an Apple reviewer rejected an emergency update as they thought these buttons were /// unresponsive (even though there is copy on the screen communicating that they are intentionally disabled) - in order /// to prevent this happening in the future we've added this toast when pressing on the disabled button - guard viewModel.threadData.threadIsMessageRequest == true else { return } + guard viewModel.state.threadInfo.isMessageRequest else { return } let toastController: ToastController = ToastController( text: "messageRequestDisabledToastVoiceMessages".localized(), @@ -692,12 +702,10 @@ extension ConversationVC: // MARK: --Message Sending @MainActor func handleSendButtonTapped() { - guard LibSession.numberOfCharactersLeft( - for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: viewModel.isCurrentUserSessionPro + guard viewModel.dependencies[singleton: .sessionProManager].numberOfCharactersLeft( + for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines) ) >= 0 else { - showModalForMessagesExceedingCharacterLimit(viewModel.isCurrentUserSessionPro) - return + return showModalForMessagesExceedingCharacterLimit() } sendMessage( @@ -707,11 +715,12 @@ extension ConversationVC: ) } - @MainActor func showModalForMessagesExceedingCharacterLimit(_ isSessionPro: Bool) { - guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .longerMessages(renew: viewModel.dependencies[singleton: .sessionProState].isSessionProExpired), - onConfirm: { [weak self, dependencies = viewModel.dependencies] in - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + @MainActor func showModalForMessagesExceedingCharacterLimit() { + let manager: SessionProManagerType = viewModel.dependencies[singleton: .sessionProManager] + let didShowCTAModal: Bool = manager.showSessionProCTAIfNeeded( + .longerMessages(renew: (manager.currentUserCurrentProState.status == .expired)), + onConfirm: { [weak self, manager] in + manager.showSessionProBottomSheetIfNeeded( afterClosed: { [weak self] in self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") }, @@ -726,16 +735,16 @@ extension ConversationVC: presenting: { [weak self] modal in self?.present(modal, animated: true) } - ) else { - return - } + ) + + guard !didShowCTAModal else { return } let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "modalMessageCharacterTooLongTitle".localized(), body: .text( "modalMessageTooLongDescription" - .put(key: "limit", value: (isSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit)) + .put(key: "limit", value: viewModel.dependencies[singleton: .sessionProManager].characterLimit) .localized(), scrollMode: .never ), @@ -772,7 +781,7 @@ extension ConversationVC: // If we have no content then do nothing guard !processedText.isEmpty || !attachments.isEmpty else { return } - if processedText.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed { + if processedText.contains(mnemonic) && !viewModel.state.threadInfo.isNoteToSelf && !hasPermissionToSendSeed { // Warn the user if they're about to send their seed to someone let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -810,47 +819,58 @@ extension ConversationVC: Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } - let optimisticData: ConversationViewModel.OptimisticMessageData = await viewModel.optimisticallyAppendOutgoingMessage( - text: processedText, - sentTimestampMs: sentTimestampMs, - attachments: attachments, - linkPreviewViewModel: linkPreviewViewModel, - quoteViewModel: quoteViewModel - ) - await approveMessageRequestIfNeeded( - for: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, - displayName: self.viewModel.threadData.displayName, - isDraft: (self.viewModel.threadData.threadIsDraft == true), - timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting - ) - - await sendMessage(optimisticData: optimisticData) + do { + let optimisticData: ConversationViewModel.OptimisticMessageData = try await viewModel.optimisticallyAppendOutgoingMessage( + text: processedText, + sentTimestampMs: sentTimestampMs, + attachments: attachments, + linkPreviewViewModel: linkPreviewViewModel, + quoteViewModel: quoteViewModel + ) + await approveMessageRequestIfNeeded( + for: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, + displayName: self.viewModel.state.threadInfo.displayName.deformatted(), + isDraft: self.viewModel.state.threadInfo.isDraft, + timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting + ) + + await sendMessage(optimisticData: optimisticData) + } + catch { + await MainActor.run { [weak self] in + self?.handleCharacterLimitLabelTapped() + } + } } } private func sendMessage(optimisticData: ConversationViewModel.OptimisticMessageData) async { - let threadId: String = self.viewModel.threadData.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant + let state: ConversationViewModel.State = self.viewModel.state // Actually send the message do { try await viewModel.dependencies[singleton: .storage].writeAsync { [weak self, dependencies = viewModel.dependencies] db in // Update the thread to be visible (if it isn't already) - if self?.viewModel.threadData.threadShouldBeVisible == false { - try SessionThread.updateVisibility( - db, - threadId: threadId, - isVisible: true, - additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], - using: dependencies - ) - } + try SessionThread.upsert( + db, + id: state.threadId, + variant: state.threadVariant, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true), + isDraft: .setTo(false) + ), + using: dependencies + ) // Insert the interaction and associated it with the optimistically inserted message so // we can remove it once the database triggers a UI update let insertedInteraction: Interaction = try optimisticData.interaction.inserted(db) - self?.viewModel.associate(optimisticMessageId: optimisticData.id, to: insertedInteraction.id) + self?.viewModel.associate( + db, + optimisticMessageId: optimisticData.temporaryId, + to: insertedInteraction.id + ) // If there is a LinkPreview draft then check the state of any existing link previews and // insert a new one if needed @@ -858,10 +878,13 @@ extension ConversationVC: let invalidLinkPreviewAttachmentStates: [Attachment.State] = [ .failedDownload, .pendingDownload, .downloading, .failedUpload, .invalid ] - let linkPreviewAttachmentId: String? = try? insertedInteraction.linkPreview - .select(.attachmentId) - .asRequest(of: String.self) - .fetchOne(db) + let linkPreviewAttachmentId: String? = try? Interaction + .linkPreview( + url: insertedInteraction.linkPreviewUrl, + timestampMs: insertedInteraction.timestampMs + )? + .fetchOne(db)? + .attachmentId let linkPreviewAttachmentState: Attachment.State = linkPreviewAttachmentId .map { try? Attachment @@ -887,11 +910,16 @@ extension ConversationVC: } // If there is a Quote the insert it now - if let interactionId: Int64 = insertedInteraction.id, let quoteViewModel: QuoteViewModel = optimisticData.quoteViewModel { + if + let interactionId: Int64 = insertedInteraction.id, + let quoteViewModel: QuoteViewModel = optimisticData.quoteViewModel, + let quotedAuthorId: String = quoteViewModel.quotedInfo?.authorId, + let quotedTimestampMs: Int64 = quoteViewModel.quotedInfo?.timestampMs + { try Quote( interactionId: interactionId, - authorId: quoteViewModel.authorId, - timestampMs: quoteViewModel.timestampMs + authorId: quotedAuthorId, + timestampMs: quotedTimestampMs ).insert(db) } @@ -902,33 +930,11 @@ extension ConversationVC: toInteractionWithId: insertedInteraction.id ) - // If we are sending a blinded message then we need to update the blinded profile - // information to ensure the name is up to date (as it won't be updated otherwise - // because the message would get deduped when fetched from the poller) - // FIXME: Remove this once we don't generate unique Profile entries for the current users blinded ids - if (try? SessionId.Prefix(from: optimisticData.interaction.authorId)) != .standard { - let currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - let sentTimestamp: TimeInterval = (Double(optimisticData.interaction.timestampMs) / 1000) - - try? Profile.updateIfNeeded( - db, - publicKey: optimisticData.interaction.authorId, - displayNameUpdate: .contactUpdate(currentUserProfile.name), - displayPictureUpdate: DisplayPictureManager.Update.from( - currentUserProfile, - fallback: .none, - using: dependencies - ), - profileUpdateTimestamp: currentUserProfile.profileLastUpdated, - using: dependencies - ) - } - try MessageSender.send( db, interaction: insertedInteraction, - threadId: threadId, - threadVariant: threadVariant, + threadId: state.threadId, + threadVariant: state.threadVariant, using: dependencies ) } @@ -936,7 +942,7 @@ extension ConversationVC: await handleMessageSent() } catch { - viewModel.failedToStoreOptimisticOutgoingMessage(id: optimisticData.id, error: error) + await viewModel.failedToStoreOptimisticOutgoingMessage(id: optimisticData.temporaryId, error: error) } } @@ -947,10 +953,10 @@ extension ConversationVC: } await viewModel.dependencies[singleton: .typingIndicators].didStopTyping( - threadId: viewModel.threadData.threadId, + threadId: viewModel.state.threadId, direction: .outgoing ) - try? await viewModel.dependencies[singleton: .storage].writeAsync { [threadId = viewModel.threadData.threadId] db in + try? await viewModel.dependencies[singleton: .storage].writeAsync { [threadId = viewModel.state.threadId] db in _ = try SessionThread .filter(id: threadId) .updateAll(db, SessionThread.Columns.messageDraft.set(to: "")) @@ -988,13 +994,12 @@ extension ConversationVC: guard !viewIsAppearing else { return } let newText: String = (inputTextView.text ?? "") - let currentUserSessionIds: Set = (viewModel.threadData.currentUserSessionIds ?? []) if !newText.isEmpty { - Task { [threadData = viewModel.threadData, dependencies = viewModel.dependencies] in + Task { [state = viewModel.state, dependencies = viewModel.dependencies] in await viewModel.dependencies[singleton: .typingIndicators].startIfNeeded( - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, + threadId: state.threadId, + threadVariant: state.threadVariant, direction: .outgoing, timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() ) @@ -1002,7 +1007,7 @@ extension ConversationVC: } Task.detached(priority: .userInitiated) { [weak self] in - await self?.updateMentions(for: newText, currentUserSessionIds: currentUserSessionIds) + await self?.updateMentions(for: newText) } // Note: When calculating the number of characters left, we need to use the original mention @@ -1041,7 +1046,7 @@ extension ConversationVC: mentions = mentions.filter { newText.contains($0.displayName) } } - func updateMentions(for newText: String, currentUserSessionIds: Set) async { + func updateMentions(for newText: String) async { let currentStartIndex: String.Index? = await MainActor.run { currentMentionStartIndex } guard !newText.isEmpty else { @@ -1123,12 +1128,12 @@ extension ConversationVC: currentMentionStartIndex = nil mentions = [] } - + // MARK: MessageCellDelegate func handleItemLongPressed(_ cellViewModel: MessageViewModel) { // Show the unblock modal if needed - guard self.viewModel.threadData.threadIsBlocked != true else { + guard !self.viewModel.state.threadInfo.isBlocked else { self.showBlockedModalIfNeeded() return } @@ -1136,9 +1141,9 @@ extension ConversationVC: guard // FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement) let keyWindow: UIWindow = UIApplication.shared.keyWindow, - let sectionIndex: Int = self.viewModel.interactionData + let sectionIndex: Int = self.sections .firstIndex(where: { $0.model == .messages }), - let index = self.viewModel.interactionData[sectionIndex] + let index = self.sections[sectionIndex] .elements .firstIndex(of: cellViewModel), let cell = tableView.cellForRow(at: IndexPath(row: index, section: sectionIndex)) as? MessageCell, @@ -1147,7 +1152,11 @@ extension ConversationVC: contextMenuWindow == nil, let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( for: cellViewModel, - in: self.viewModel.threadData, + threadInfo: self.viewModel.state.threadInfo, + authMethod: self.viewModel.state.authMethod.value, + reactionsSupported: self.viewModel.state.reactionsSupported, + recentReactionEmoji: self.viewModel.state.recentReactionEmoji, + isUserModeratorOrAdmin: self.viewModel.state.isUserModeratorOrAdmin, forMessageInfoScreen: false, delegate: self, using: viewModel.dependencies @@ -1202,7 +1211,7 @@ extension ConversationVC: switch messageInfo.state { case .permissionDenied: let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal( - caller: cellViewModel.authorName, + caller: cellViewModel.authorName(), presentingViewController: self, using: viewModel.dependencies ) @@ -1249,7 +1258,7 @@ extension ConversationVC: ) { [weak self, dependencies = viewModel.dependencies] _ in dependencies[singleton: .storage].writeAsync { db in let userSessionId: SessionId = dependencies[cache: .general].sessionId - let currentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let currentTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let interactionId = try messageDisappearingConfig .upserted(db) @@ -1284,6 +1293,13 @@ extension ConversationVC: disappearingMessagesConfig: messageDisappearingConfig, using: dependencies ) + + /// Notify of update + db.addConversationEvent( + id: cellViewModel.threadId, + variant: cellViewModel.threadVariant, + type: .updated(.disappearingMessageConfiguration(messageDisappearingConfig)) + ) } self?.dismiss(animated: true, completion: nil) } @@ -1296,7 +1312,7 @@ extension ConversationVC: // If it's an incoming media message and the thread isn't trusted then show the placeholder view if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { let message: ThemedAttributedString = "attachmentsAutoDownloadModalDescription" - .put(key: "conversation_name", value: cellViewModel.authorName) + .put(key: "conversation_name", value: cellViewModel.authorName()) .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -1351,7 +1367,7 @@ extension ConversationVC: case .failedUpload: break case .failedDownload: - let threadId: String = self.viewModel.threadData.threadId + let threadId: String = self.viewModel.state.threadId // Retry downloading the failed attachment viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in @@ -1402,8 +1418,8 @@ extension ConversationVC: } let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController( - for: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, + for: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, interactionId: cellViewModel.id, selectedAttachmentId: mediaView.attachment.id, options: [ .sliderEnabled, .showAllMediaButton ], @@ -1418,7 +1434,7 @@ extension ConversationVC: case .audio: guard !handleLinkTapIfNeeded(cell: cell, targetView: (cell as? VisibleMessageCell)?.documentView), - let attachment: Attachment = cellViewModel.attachments?.first, + let attachment: Attachment = cellViewModel.attachments.first, let path: String = try? viewModel.dependencies[singleton: .attachmentManager] .createTemporaryFileForOpening( downloadUrl: attachment.downloadUrl, @@ -1445,7 +1461,7 @@ extension ConversationVC: case .genericAttachment: guard !handleLinkTapIfNeeded(cell: cell, targetView: (cell as? VisibleMessageCell)?.documentView), - let attachment: Attachment = cellViewModel.attachments?.first, + let attachment: Attachment = cellViewModel.attachments.first, let path: String = try? viewModel.dependencies[singleton: .attachmentManager] .createTemporaryFileForOpening( downloadUrl: attachment.downloadUrl, @@ -1512,22 +1528,15 @@ extension ConversationVC: // If the message contains both links and a quote, and the user tapped on the quote; OR the // message only contained a quote, then scroll to the quote case (true, true, _, .some(let quoteViewModel), _), (false, _, _, .some(let quoteViewModel), _): - let maybeTimestampMs: Int64? = viewModel.dependencies[singleton: .storage].read { db in - try Interaction - .filter(id: quoteViewModel.quotedInteractionId) - .select(.timestampMs) - .asRequest(of: Int64.self) - .fetchOne(db) - } - - guard let timestampMs: Int64 = maybeTimestampMs else { - return - } + guard + let quotedInteractionId: Int64 = quoteViewModel.quotedInfo?.interactionId, + let quotedInteractionTimestampMs: Int64 = quoteViewModel.quotedInfo?.timestampMs + else { return } self.scrollToInteractionIfNeeded( with: Interaction.TimestampInfo( - id: quoteViewModel.quotedInteractionId, - timestampMs: timestampMs + id: quotedInteractionId, + timestampMs: quotedInteractionTimestampMs ), focusBehaviour: .highlight, originalIndexPath: self.tableView.indexPath(for: cell) @@ -1606,122 +1615,49 @@ extension ConversationVC: } func showUserProfileModal(for cellViewModel: MessageViewModel) { - guard viewModel.threadData.threadCanWrite == true else { return } - // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) - guard (try? SessionId.Prefix(from: cellViewModel.authorId)) != .blinded25 else { return } - - let dependencies: Dependencies = viewModel.dependencies - - let (info, _) = ProfilePictureView.Info.generateInfoFrom( - size: .hero, - publicKey: cellViewModel.authorId, - threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureUrl: nil, - profile: cellViewModel.profile, - using: dependencies - ) - - guard let profileInfo: ProfilePictureView.Info = info else { return } + guard viewModel.state.threadInfo.canWrite else { return } - let (sessionId, blindedId): (String?, String?) = { + Task.detached(priority: .userInitiated) { [weak self, dependencies = viewModel.dependencies] in guard - (try? SessionId.Prefix(from: cellViewModel.authorId)) == .blinded15, - let openGroupServer: String = cellViewModel.threadOpenGroupServer, - let openGroupPublicKey: String = cellViewModel.threadOpenGroupPublicKey - else { - return (cellViewModel.authorId, nil) - } - let lookup: BlindedIdLookup? = dependencies[singleton: .storage].write { db in - try BlindedIdLookup.fetchOrCreate( - db, - blindedId: cellViewModel.authorId, - openGroupServer: openGroupServer, - openGroupPublicKey: openGroupPublicKey, - isCheckingForOutbox: false, - using: dependencies - ) - } - return (lookup?.sessionId, cellViewModel.authorId.truncated(prefix: 10, suffix: 10)) - }() - - let (displayName, contactDisplayName): (String?, String?) = { - guard let sessionId: String = sessionId else { - return (cellViewModel.authorNameSuppressedId, nil) - } - - let profile: Profile? = ( - dependencies.mutate(cache: .libSession) { $0.profile(contactId: sessionId) } ?? - dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId) } - ) - - let isCurrentUser: Bool = (viewModel.threadData.currentUserSessionIds?.contains(sessionId) == true) - guard !isCurrentUser else { - return ("you".localized(), "you".localized()) - } - - return ( - (profile?.displayName(for: .contact) ?? cellViewModel.authorNameSuppressedId), - profile?.displayName(for: .contact, ignoringNickname: true) - ) - }() - - let qrCodeImage: UIImage? = { - guard let sessionId: String = sessionId else { return nil } - return QRCode.generate(for: sessionId, hasBackground: false, iconName: "SessionWhite40") // stringlint:ignore - }() - - let isMessasgeRequestsEnabled: Bool = { - guard cellViewModel.threadVariant == .community else { return true } - return cellViewModel.profile?.blocksCommunityMessageRequests != true - }() - - DispatchQueue.main.async { [weak self] in - let userProfileModal: ModalHostingViewController = ModalHostingViewController( - modal: UserProfileModal( - info: .init( - sessionId: sessionId, - blindedId: blindedId, - qrCodeImage: qrCodeImage, - profileInfo: profileInfo, - displayName: displayName, - contactDisplayName: contactDisplayName, - isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: cellViewModel.profile) }), - isMessageRequestsEnabled: isMessasgeRequestsEnabled, - onStartThread: { [weak self] in - self?.startThread( + let info: UserProfileModal.Info = await cellViewModel.createUserProfileModalInfo( + openGroupServer: self?.viewModel.state.threadInfo.communityInfo?.server, + openGroupPublicKey: self?.viewModel.state.threadInfo.communityInfo?.publicKey, + onStartThread: { + Task.detached(priority: .userInitiated) { [weak self] in + await self?.startThread( with: cellViewModel.authorId, - openGroupServer: cellViewModel.threadOpenGroupServer, - openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey - ) - }, - onProBadgeTapped: { [weak self, dependencies] in - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired), - dismissType: .single, - onConfirm: { - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( - afterClosed: { [weak self] in - self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") - }, - presenting: { bottomSheet in - dependencies[singleton: .appContext].frontMostViewController?.present(bottomSheet, animated: true) - } - ) - }, - onCancel: { [weak self] in - self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") - }, - afterClosed: nil, - presenting: { modal in - dependencies[singleton: .appContext].frontMostViewController?.present(modal, animated: true) - } + openGroupServer: self?.viewModel.state.threadInfo.communityInfo?.server, + openGroupPublicKey: self?.viewModel.state.threadInfo.communityInfo?.publicKey ) } - ), - dataManager: dependencies[singleton: .imageDataManager] + }, + onProBadgeTapped: { [weak self, dependencies] in + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + .generic(renew: (dependencies[singleton: .sessionProManager].currentUserCurrentProState.status == .expired)), + dismissType: .single, + afterClosed: { [weak self] in + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { modal in + dependencies[singleton: .appContext].frontMostViewController?.present(modal, animated: true) + } + ) + }, + using: dependencies ) - ) - self?.present(userProfileModal, animated: true, completion: nil) + else { return } + + await MainActor.run { [weak self] in + guard let self else { return } + + let userProfileModal: ModalHostingViewController = ModalHostingViewController( + modal: UserProfileModal( + info: info, + dataManager: viewModel.dependencies[singleton: .imageDataManager] + ) + ) + present(userProfileModal, animated: true, completion: nil) + } } } @@ -1729,57 +1665,38 @@ extension ConversationVC: with sessionId: String, openGroupServer: String?, openGroupPublicKey: String? - ) { - guard viewModel.threadData.threadCanWrite == true else { return } - // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) - guard (try? SessionId.Prefix(from: sessionId)) != .blinded25 else { return } - guard (try? SessionId.Prefix(from: sessionId)) == .blinded15 else { - viewModel.dependencies[singleton: .storage].write { [dependencies = viewModel.dependencies] db in - try SessionThread.upsert( - db, - id: sessionId, - variant: .contact, - values: SessionThread.TargetValues( - creationDateTimestamp: .useExistingOrSetTo( - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ), - shouldBeVisible: .useLibSession, - isDraft: .useExistingOrSetTo(true) - ), - using: dependencies - ) - } - - let conversationVC: ConversationVC = ConversationVC( - threadId: sessionId, - threadVariant: .contact, - using: viewModel.dependencies - ) - - self.navigationController?.pushViewController(conversationVC, animated: true) - return - } - - // If the sessionId is blinded then check if there is an existing un-blinded thread with the contact - // and use that, otherwise just use the blinded id - guard let openGroupServer: String = openGroupServer, let openGroupPublicKey: String = openGroupPublicKey else { - return - } + ) async { + guard viewModel.state.threadInfo.canWrite else { return } - let targetThreadId: String? = viewModel.dependencies[singleton: .storage].write { [dependencies = viewModel.dependencies] db in - let lookup: BlindedIdLookup = try BlindedIdLookup - .fetchOrCreate( - db, - blindedId: sessionId, - openGroupServer: openGroupServer, - openGroupPublicKey: openGroupPublicKey, - isCheckingForOutbox: false, - using: dependencies - ) + let maybeThreadInfo: ConversationInfoViewModel? = try? await viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in + let targetId: String - return try SessionThread.upsert( + switch try? SessionId.Prefix(from: sessionId) { + case .blinded15, .blinded25: + /// If the sessionId is blinded then check if there is an existing un-blinded thread with the contact and use that, + /// otherwise just use the blinded id + guard + let openGroupServer: String = openGroupServer, + let openGroupPublicKey: String = openGroupPublicKey + else { throw StorageError.objectNotFound } + + let lookup: BlindedIdLookup = try BlindedIdLookup.fetchOrCreate( + db, + blindedId: sessionId, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, + isCheckingForOutbox: false, + using: dependencies + ) + + targetId = (lookup.sessionId ?? lookup.blindedId) + + default: targetId = sessionId + } + + try SessionThread.upsert( db, - id: (lookup.sessionId ?? lookup.blindedId), + id: targetId, variant: .contact, values: SessionThread.TargetValues( creationDateTimestamp: .useExistingOrSetTo( @@ -1789,28 +1706,52 @@ extension ConversationVC: isDraft: .useExistingOrSetTo(true) ), using: dependencies - ).id + ) + + return try ConversationViewModel.fetchConversationInfo( + db, + threadId: sessionId, + using: dependencies + ) } - guard let threadId: String = targetThreadId else { return } - - let conversationVC: ConversationVC = ConversationVC( - threadId: threadId, - threadVariant: .contact, - using: viewModel.dependencies - ) - self.navigationController?.pushViewController(conversationVC, animated: true) + await MainActor.run { [dependencies = viewModel.dependencies] in + guard let threadInfo: ConversationInfoViewModel = maybeThreadInfo else { + self.navigationController?.present( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "theError".localized(), + body: .text("errorUnknown".localized()), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ), + animated: true, + completion: nil + ) + return + } + + self.navigationController?.pushViewController( + ConversationVC( + threadInfo: threadInfo, + focusedInteractionInfo: nil, + using: dependencies + ), + animated: true + ) + } } func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?) { guard - cellViewModel.reactionInfo?.isEmpty == false && - ( - self.viewModel.threadData.threadVariant == .legacyGroup || - self.viewModel.threadData.threadVariant == .group || - self.viewModel.threadData.threadVariant == .community - ), - let allMessages: [MessageViewModel] = self.viewModel.interactionData + !cellViewModel.reactionInfo.isEmpty && + [ + SessionThread.Variant.legacyGroup, + SessionThread.Variant.group, + SessionThread.Variant.community + ].contains(self.viewModel.state.threadVariant), + let allMessages: [MessageViewModel] = self.sections .first(where: { $0.model == .messages })? .elements else { return } @@ -1823,12 +1764,7 @@ extension ConversationVC: allMessages, selectedReaction: selectedReaction, initialLoad: true, - shouldShowClearAllButton: viewModel.dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( - publicKey: self.viewModel.threadData.currentUserSessionId, - for: self.viewModel.threadData.openGroupRoomToken, - on: self.viewModel.threadData.openGroupServer, - currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) - ) + shouldShowClearAllButton: viewModel.state.isUserModeratorOrAdmin ) reactionListSheet.modalPresentationStyle = .overFullScreen present(reactionListSheet, animated: true, completion: nil) @@ -1839,9 +1775,9 @@ extension ConversationVC: func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool) { guard - let messageSectionIndex: Int = self.viewModel.interactionData + let messageSectionIndex: Int = self.sections .firstIndex(where: { $0.model == .messages }), - let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex] + let targetMessageIndex = self.sections[messageSectionIndex] .elements .firstIndex(where: { $0.id == cellViewModel.id }) else { return } @@ -1881,13 +1817,17 @@ extension ConversationVC: } func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones) { - react(cellViewModel, with: emoji.rawValue, remove: false) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.react(cellViewModel, with: emoji.rawValue, remove: false) + } } func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones) { - guard viewModel.threadData.threadVariant != .legacyGroup else { return } + guard viewModel.state.threadVariant != .legacyGroup else { return } - react(cellViewModel, with: emoji.rawValue, remove: true) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.react(cellViewModel, with: emoji.rawValue, remove: true) + } } func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) { @@ -1908,7 +1848,9 @@ extension ConversationVC: cancelStyle: .alert_text, onConfirm: { [weak self] modal in // Call clear reaction event - self?.clearAllReactions(cellViewModel, for: emoji) + Task.detached(priority: .userInitiated) { + await self?.clearAllReactions(cellViewModel, for: emoji) + } modal.dismiss(animated: true) } ) @@ -1917,90 +1859,108 @@ extension ConversationVC: present(modal, animated: true, completion: nil) } - func clearAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) { + func clearAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) async { guard cellViewModel.threadVariant == .community, - let roomToken: String = viewModel.threadData.openGroupRoomToken, - let server: String = viewModel.threadData.openGroupServer, - let publicKey: String = viewModel.threadData.openGroupPublicKey, - let capabilities: Set = viewModel.threadData.openGroupCapabilities, + let communityInfo: ConversationInfoViewModel.CommunityInfo = viewModel.state.threadInfo.communityInfo, let openGroupServerMessageId: Int64 = cellViewModel.openGroupServerMessageId else { return } - let pendingChange: OpenGroupManager.PendingChange = viewModel.dependencies[singleton: .openGroupManager] - .addPendingReaction( - emoji: emoji, - id: openGroupServerMessageId, - in: roomToken, - on: server, - type: .removeAll - ) - - Result { - try Network.SOGS.preparedReactionDeleteAll( + do { + let pendingChange: CommunityManager.PendingChange = await viewModel.dependencies[singleton: .communityManager] + .addPendingReaction( + emoji: emoji, + id: openGroupServerMessageId, + in: communityInfo.roomToken, + on: communityInfo.server, + type: .removeAll + ) + let request = try Network.SOGS.preparedReactionDeleteAll( emoji: emoji, id: openGroupServerMessageId, - roomToken: roomToken, - authMethod: Authentication.community( + roomToken: communityInfo.roomToken, + authMethod: Authentication.Community( info: LibSession.OpenGroupCapabilityInfo( - roomToken: roomToken, - server: server, - publicKey: publicKey, - capabilities: capabilities + roomToken: communityInfo.roomToken, + server: communityInfo.server, + publicKey: communityInfo.publicKey, + capabilities: communityInfo.capabilities ) ), using: viewModel.dependencies ) - } - .publisher - .flatMap { [dependencies = viewModel.dependencies] in $0.send(using: dependencies) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: viewModel.dependencies) - .sinkUntilComplete( - receiveCompletion: { [dependencies = viewModel.dependencies] _ in - dependencies[singleton: .storage].writeAsync { db in - _ = try Reaction - .filter(Reaction.Columns.interactionId == cellViewModel.id) - .filter(Reaction.Columns.emoji == emoji) - .deleteAll(db) + + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SOGS.ReactionRemoveAllResponse = try await request + .send(using: viewModel.dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + await viewModel.dependencies[singleton: .communityManager].updatePendingChange( + pendingChange, + seqNo: response.seqNo + ) + + try await viewModel.dependencies[singleton: .storage].writeAsync { db in + let rowIds: [Int64] = try Reaction + .select(Column.rowID) + .filter(Reaction.Columns.interactionId == cellViewModel.id) + .filter(Reaction.Columns.emoji == emoji) + .asRequest(of: Int64.self) + .fetchAll(db) + + _ = try Reaction + .filter(Reaction.Columns.interactionId == cellViewModel.id) + .filter(Reaction.Columns.emoji == emoji) + .deleteAll(db) + + rowIds.forEach { + db.addReactionEvent( + id: $0, + messageId: cellViewModel.id, + change: .removed(emoji) + ) } - }, - receiveValue: { [dependencies = viewModel.dependencies] _, response in - dependencies[singleton: .openGroupManager].updatePendingChange( - pendingChange, - seqNo: response.seqNo - ) } - ) + } + catch { + // FIXME: Should probably handle this error + } } - func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) { + func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) async { guard - self.viewModel.threadData.threadIsMessageRequest != true && ( + self.viewModel.state.reactionsSupported && + !self.viewModel.state.threadInfo.isMessageRequest && ( cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing ) else { return } // Perform local rate limiting (don't allow more than 20 reactions within 60 seconds) - let threadId: String = self.viewModel.threadData.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant - let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken + let threadId: String = self.viewModel.state.threadId + let threadVariant: SessionThread.Variant = self.viewModel.state.threadVariant + let communityInfo: ConversationInfoViewModel.CommunityInfo? = self.viewModel.state.threadInfo.communityInfo + let authMethod: AuthenticationMethod = self.viewModel.state.authMethod.value let sentTimestampMs: Int64 = viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let recentReactionTimestamps: [Int64] = viewModel.dependencies[cache: .general].recentReactionTimestamps + let currentUserSessionIds: Set = viewModel.state.threadInfo.currentUserSessionIds guard recentReactionTimestamps.count < 20 || (sentTimestampMs - (recentReactionTimestamps.first ?? sentTimestampMs)) > (60 * 1000) else { - let toastController: ToastController = ToastController( - text: "emojiReactsCoolDown".localized(), - background: .backgroundSecondary - ) - toastController.presentToastView( - fromBottomOfView: self.view, - inset: (snInputView.bounds.height + Values.largeSpacing), - duration: .milliseconds(2500) - ) + await MainActor.run { + let toastController: ToastController = ToastController( + text: "emojiReactsCoolDown".localized(), + background: .backgroundSecondary + ) + toastController.presentToastView( + fromBottomOfView: self.view, + inset: (snInputView.bounds.height + Values.largeSpacing), + duration: .milliseconds(2500) + ) + } return } @@ -2010,164 +1970,156 @@ extension ConversationVC: .appending(sentTimestampMs) } - typealias OpenGroupInfo = ( - pendingReaction: Reaction?, - pendingChange: OpenGroupManager.PendingChange, - preparedRequest: Network.PreparedRequest - ) - /// Perform the sending logic, we generate the pending reaction first in a deferred future closure to prevent the OpenGroup /// cache from blocking either the main thread or the database write thread - Deferred { [dependencies = viewModel.dependencies] in - Future { resolver in + var pendingReaction: Reaction? + var pendingChange: CommunityManager.PendingChange? + + do { + // Create the pending change if we have open group info + let threadShouldBeVisible: Bool? = self.viewModel.state.threadInfo.shouldBeVisible + + if threadVariant == .community { guard - threadVariant == .community, let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, - let openGroupServer: String = cellViewModel.threadOpenGroupServer, - let openGroupPublicKey: String = cellViewModel.threadOpenGroupPublicKey - else { return resolver(Result.success(nil)) } - - // Create the pending change if we have open group info - return resolver(Result.success( - dependencies[singleton: .openGroupManager].addPendingReaction( - emoji: emoji, - id: serverMessageId, - in: openGroupServer, - on: openGroupPublicKey, - type: (remove ? .remove : .add) - ) - )) + let communityInfo: ConversationInfoViewModel.CommunityInfo = communityInfo + else { throw MessageError.invalidMessage("Missing community info for adding reaction") } + + pendingChange = await viewModel.dependencies[singleton: .communityManager].addPendingReaction( + emoji: emoji, + id: serverMessageId, + in: communityInfo.server, + on: communityInfo.publicKey, + type: (remove ? .remove : .add) + ) } - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: viewModel.dependencies) - .flatMapStorageWritePublisher(using: viewModel.dependencies) { [weak self, dependencies = viewModel.dependencies] db, pendingChange -> (OpenGroupManager.PendingChange?, Reaction?, Message.Destination, AuthenticationMethod) in - // Update the thread to be visible (if it isn't already) - if self?.viewModel.threadData.threadShouldBeVisible == false { - try SessionThread.updateVisibility( + + let destination: Message.Destination = try await viewModel.dependencies[singleton: .storage].writeAsync { [state = viewModel.state, dependencies = viewModel.dependencies] db in + // Update the thread to be visible (if it isn't already) + try SessionThread.update( db, - threadId: cellViewModel.threadId, - isVisible: true, + id: cellViewModel.threadId, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true) + ), using: dependencies ) - } - - let pendingReaction: Reaction? = { - guard !remove else { - return try? Reaction + + // Get the pending reaction + if remove { + pendingReaction = try? Reaction .filter(Reaction.Columns.interactionId == cellViewModel.id) - // TODO: [Database Relocation] Stop `currentUserSessionIds` from being nullable - .filter((cellViewModel.currentUserSessionIds ?? []).contains(Reaction.Columns.authorId)) + .filter(currentUserSessionIds.contains(Reaction.Columns.authorId)) .filter(Reaction.Columns.emoji == emoji) .fetchOne(db) } + else { + let sortId: Int64 = Reaction.getSortId( + db, + interactionId: cellViewModel.id, + emoji: emoji + ) + + pendingReaction = Reaction( + interactionId: cellViewModel.id, + serverHash: nil, + timestampMs: sentTimestampMs, + authorId: state.userSessionId.hexString, + emoji: emoji, + count: 1, + sortId: sortId + ) + } - let sortId: Int64 = Reaction.getSortId( - db, - interactionId: cellViewModel.id, - emoji: emoji - ) - - return Reaction( - interactionId: cellViewModel.id, - serverHash: nil, - timestampMs: sentTimestampMs, - authorId: cellViewModel.currentUserSessionId, - emoji: emoji, - count: 1, - sortId: sortId - ) - }() - - // Update the database - if remove { - try Reaction - .filter(Reaction.Columns.interactionId == cellViewModel.id) - // TODO: [Database Relocation] Stop `currentUserSessionIds` from being nullable - .filter((cellViewModel.currentUserSessionIds ?? []).contains(Reaction.Columns.authorId)) - .filter(Reaction.Columns.emoji == emoji) - .deleteAll(db) - } - else { - try pendingReaction?.insert(db) - - // Add it to the recent list - Emoji.addRecent(db, emoji: emoji) - } - - switch threadVariant { - case .community: - guard - let openGroupServer: String = cellViewModel.threadOpenGroupServer, - dependencies[singleton: .openGroupManager].doesOpenGroupSupport(db, capability: .reactions, on: openGroupServer) - else { throw MessageSenderError.invalidMessage } + // Update the database + if remove { + let maybeRowId: Int64? = try Reaction + .select(Column.rowID) + .filter(Reaction.Columns.interactionId == cellViewModel.id) + .filter(currentUserSessionIds.contains(Reaction.Columns.authorId)) + .filter(Reaction.Columns.emoji == emoji) + .asRequest(of: Int64.self) + .fetchOne(db) + + try Reaction + .filter(Reaction.Columns.interactionId == cellViewModel.id) + .filter(currentUserSessionIds.contains(Reaction.Columns.authorId)) + .filter(Reaction.Columns.emoji == emoji) + .deleteAll(db) - default: break + if let rowId: Int64 = maybeRowId { + db.addReactionEvent( + id: rowId, + messageId: cellViewModel.id, + change: .removed(emoji) + ) + } + } + else { + try pendingReaction?.insert(db) + db.addReactionEvent( + id: db.lastInsertedRowID, + messageId: cellViewModel.id, + change: .added(emoji) + ) + + // Add it to the recent list + Emoji.addRecent(db, emoji: emoji) + } + + return try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant) } - return ( - pendingChange, - pendingReaction, - try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), - try Authentication.with(db, threadId: threadId, threadVariant: threadVariant, using: dependencies) - ) - } - .tryFlatMap { [dependencies = viewModel.dependencies] pendingChange, pendingReaction, destination, authMethod in switch threadVariant { case .community: guard let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, - let openGroupServer: String = cellViewModel.threadOpenGroupServer, - let openGroupRoom: String = openGroupRoom, - let pendingChange: OpenGroupManager.PendingChange = pendingChange - else { throw MessageSenderError.invalidMessage } + let communityInfo: ConversationInfoViewModel.CommunityInfo = communityInfo + else { throw MessageError.invalidMessage("Missing community info for adding reaction") } + guard !authMethod.isInvalid else { + throw MessageError.invalidMessage("Invalid auth method for adding reaction") + } - let preparedRequest: Network.PreparedRequest = try { - guard !remove else { - return try Network.SOGS - .preparedReactionDelete( - emoji: emoji, - id: serverMessageId, - roomToken: openGroupRoom, - authMethod: authMethod, - using: dependencies - ) - .map { _, response in response.seqNo } - } - - return try Network.SOGS + let request: Network.PreparedRequest + + if remove { + request = try Network.SOGS + .preparedReactionDelete( + emoji: emoji, + id: serverMessageId, + roomToken: communityInfo.roomToken, + authMethod: authMethod, + using: viewModel.dependencies + ) + .map { _, response in response.seqNo } + } + else { + request = try Network.SOGS .preparedReactionAdd( emoji: emoji, id: serverMessageId, - roomToken: openGroupRoom, + roomToken: communityInfo.roomToken, authMethod: authMethod, - using: dependencies + using: viewModel.dependencies ) .map { _, response in response.seqNo } - }() + } - return preparedRequest - .handleEvents( - receiveOutput: { _, seqNo in - dependencies[singleton: .openGroupManager].updatePendingChange( - pendingChange, - seqNo: seqNo - ) - }, - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure: - dependencies[singleton: .openGroupManager].removePendingChange(pendingChange) - - self?.handleReactionSentFailure(pendingReaction, remove: remove) - } - } + // FIXME: Make this async/await when the refactored networking is merged + let seqNo: Int64? = try await request + .send(using: viewModel.dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + if let pendingChange: CommunityManager.PendingChange = pendingChange { + await viewModel.dependencies[singleton: .communityManager].updatePendingChange( + pendingChange, + seqNo: seqNo ) - .map { _, _ in () } - .send(using: dependencies) + } default: - return try MessageSender.preparedSend( + let request: Network.PreparedRequest = try MessageSender.preparedSend( message: VisibleMessage( sentTimestampMs: UInt64(sentTimestampMs), text: nil, @@ -2175,7 +2127,7 @@ extension ConversationVC: timestamp: UInt64(cellViewModel.timestampMs), publicKey: { guard cellViewModel.variant == .standardIncoming else { - return cellViewModel.currentUserSessionId + return viewModel.state.userSessionId.hexString } return cellViewModel.authorId @@ -2189,19 +2141,29 @@ extension ConversationVC: interactionId: cellViewModel.id, attachments: nil, authMethod: authMethod, - onEvent: MessageSender.standardEventHandling(using: dependencies), - using: dependencies + onEvent: MessageSender.standardEventHandling(using: viewModel.dependencies), + using: viewModel.dependencies ) - .map { _, _ in () } - .send(using: dependencies) + // FIXME: Make this async/await when the refactored networking is merged + _ = try await request + .send(using: viewModel.dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() } } - .sinkUntilComplete() + catch { + if let pendingChange: CommunityManager.PendingChange = pendingChange { + await viewModel.dependencies[singleton: .communityManager].removePendingChange(pendingChange) + } + + await handleReactionSentFailure(pendingReaction, remove: remove) + } } - func handleReactionSentFailure(_ pendingReaction: Reaction?, remove: Bool) { + func handleReactionSentFailure(_ pendingReaction: Reaction?, remove: Bool) async { guard let pendingReaction = pendingReaction else { return } - viewModel.dependencies[singleton: .storage].writeAsync { db in + + try? await viewModel.dependencies[singleton: .storage].writeAsync { db in // Reverse the database if remove { try pendingReaction.insert(db) @@ -2265,7 +2227,7 @@ extension ConversationVC: dependencies[singleton: .storage] .writePublisher { db in - dependencies[singleton: .openGroupManager].add( + dependencies[singleton: .communityManager].add( db, roomToken: room, server: server, @@ -2275,7 +2237,7 @@ extension ConversationVC: ) } .flatMap { successfullyAddedGroup in - dependencies[singleton: .openGroupManager].performInitialRequestsAfterAdd( + dependencies[singleton: .communityManager].performInitialRequestsAfterAdd( queue: DispatchQueue.global(qos: .userInitiated), successfullyAddedGroup: successfullyAddedGroup, roomToken: room, @@ -2294,7 +2256,7 @@ extension ConversationVC: // the next launch so remove it (the user will be left on the previous // screen so can re-trigger the join) dependencies[singleton: .storage].writeAsync { db in - try dependencies[singleton: .openGroupManager].delete( + try dependencies[singleton: .communityManager].delete( db, openGroupId: OpenGroup.idFor(roomToken: room, server: server), skipLibSessionUpdate: false @@ -2327,34 +2289,30 @@ extension ConversationVC: func info(_ cellViewModel: MessageViewModel) { let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( for: cellViewModel, - in: self.viewModel.threadData, + threadInfo: self.viewModel.state.threadInfo, + authMethod: self.viewModel.state.authMethod.value, + reactionsSupported: self.viewModel.state.reactionsSupported, + recentReactionEmoji: self.viewModel.state.recentReactionEmoji, + isUserModeratorOrAdmin: self.viewModel.state.isUserModeratorOrAdmin, forMessageInfoScreen: true, delegate: self, using: viewModel.dependencies ) ?? [] - // FIXME: This is an interim solution until the `ConversationViewModel` queries are refactored to use the new observation system - var finalCellViewModel: MessageViewModel = cellViewModel - - if - viewModel.threadData.currentUserSessionIds?.contains(cellViewModel.authorId) == true && - cellViewModel.authorId != viewModel.threadData.currentUserSessionId - { - finalCellViewModel = finalCellViewModel.with( - profile: .set(to: viewModel.dependencies.mutate(cache: .libSession) { $0.profile }) - ) - } - let messageInfoViewController = MessageInfoViewController( actions: actions, - messageViewModel: finalCellViewModel, - threadCanWrite: (viewModel.threadData.threadCanWrite == true), + messageViewModel: cellViewModel, + threadCanWrite: viewModel.state.threadInfo.canWrite, + openGroupServer: viewModel.state.threadInfo.communityInfo?.server, + openGroupPublicKey: viewModel.state.threadInfo.communityInfo?.publicKey, onStartThread: { [weak self] in - self?.startThread( - with: cellViewModel.authorId, - openGroupServer: cellViewModel.threadOpenGroupServer, - openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey - ) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.startThread( + with: cellViewModel.authorId, + openGroupServer: self?.viewModel.state.threadInfo.communityInfo?.server, + openGroupPublicKey: self?.viewModel.state.threadInfo.communityInfo?.publicKey + ) + } }, using: viewModel.dependencies ) @@ -2364,10 +2322,11 @@ extension ConversationVC: } @MainActor func retry(_ cellViewModel: MessageViewModel, completion: (@MainActor () -> Void)?) { - guard cellViewModel.id != MessageViewModel.optimisticUpdateId else { + guard cellViewModel.optimisticMessageId == nil else { guard - let optimisticMessageId: UUID = cellViewModel.optimisticMessageId, - let optimisticMessageData: ConversationViewModel.OptimisticMessageData = self.viewModel.optimisticMessageData(for: optimisticMessageId) + let optimisticMessageId: Int64 = cellViewModel.optimisticMessageId, + let optimisticMessageData: ConversationViewModel.OptimisticMessageData = self.viewModel.state + .optimisticallyInsertedMessages[optimisticMessageId] else { // Show an error for the retry let modal: ConfirmationModal = ConfirmationModal( @@ -2398,8 +2357,8 @@ extension ConversationVC: viewModel.dependencies[singleton: .storage].writeAsync { [weak self, dependencies = viewModel.dependencies] db in guard - let threadId: String = self?.viewModel.threadData.threadId, - let threadVariant: SessionThread.Variant = self?.viewModel.threadData.threadVariant, + let threadId: String = self?.viewModel.state.threadId, + let threadVariant: SessionThread.Variant = self?.viewModel.state.threadVariant, let interaction: Interaction = try? Interaction.fetchOne(db, id: cellViewModel.id) else { return } @@ -2420,41 +2379,17 @@ extension ConversationVC: completion?() } - func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { + @MainActor func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else { return } guard - (cellViewModel.body ?? "")?.isEmpty == false || - cellViewModel.attachments?.isEmpty == false + (cellViewModel.bubbleBody ?? "")?.isEmpty == false || + !cellViewModel.attachments.isEmpty else { return } - let targetAttachment: Attachment? = ( - cellViewModel.attachments?.first ?? - cellViewModel.linkPreviewAttachment - ) - - snInputView.quoteViewModel = QuoteViewModel( - mode: .draft, - direction: (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming), - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - rowId: -1, - interactionId: nil, - authorId: cellViewModel.authorId, - showProBadge: self.viewModel.dependencies.mutate(cache: .libSession) { - $0.validateSessionProState(for: cellViewModel.authorId) - }, - timestampMs: cellViewModel.timestampMs, - quotedInteractionId: cellViewModel.id, - quotedInteractionIsDeleted: cellViewModel.variant.isDeletedMessage, - quotedText: cellViewModel.body, - quotedAttachmentInfo: targetAttachment?.quoteAttachmentInfo(using: self.viewModel.dependencies), - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: self.viewModel.threadData.threadVariant, - using: self.viewModel.dependencies - ) - ) + snInputView.quoteViewModel = viewModel.draftQuote(for: cellViewModel) // If the `MessageInfoViewController` is visible then we want to show the keyboard after // the pop transition completes (and don't want to delay triggering the completion closure) @@ -2483,17 +2418,19 @@ extension ConversationVC: case .typingIndicator, .dateHeader, .unreadMarker, .infoMessage, .call: break case .textOnlyMessage: - if cellViewModel.body == nil, let linkPreview: LinkPreview = cellViewModel.linkPreview { + if cellViewModel.bodyForCopying == nil, let linkPreview: LinkPreview = cellViewModel.linkPreview { UIPasteboard.general.string = linkPreview.url return } - - UIPasteboard.general.string = cellViewModel.body + else if let value: String = cellViewModel.bodyForCopying { + /// Don't override the pasteboard with a null value + UIPasteboard.general.string = value + } case .audio, .voiceMessage, .genericAttachment, .mediaMessage: guard - cellViewModel.attachments?.count == 1, - let attachment: Attachment = cellViewModel.attachments?.first, + cellViewModel.attachments.count == 1, + let attachment: Attachment = cellViewModel.attachments.first, attachment.isValid, ( attachment.state == .downloaded || @@ -2538,8 +2475,17 @@ extension ConversationVC: func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { /// Retrieve the deletion actions for the selected message(s) of there are any let messagesToDelete: [MessageViewModel] = [cellViewModel] + let deletionBehaviours: MessageViewModel.DeletionBehaviours - guard let deletionBehaviours: MessageViewModel.DeletionBehaviours = self.viewModel.deletionActions(for: messagesToDelete) else { + do { + guard let behaviours: MessageViewModel.DeletionBehaviours = try self.viewModel.deletionActions(for: messagesToDelete) else { + return + } + + deletionBehaviours = behaviours + } + catch { + Log.error(.conversation, "Failed to retrieve deletion actions due to error: \(error)") return } @@ -2635,7 +2581,7 @@ extension ConversationVC: } func save(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { - let validAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? []) + let validAttachments: [(Attachment, String)] = cellViewModel.attachments .filter { attachment in attachment.isValid && ( cellViewModel.cellType != .mediaMessage || @@ -2687,7 +2633,7 @@ extension ConversationVC: ) // Send a 'media saved' notification if needed - guard self?.viewModel.threadData.threadVariant == .contact, cellViewModel.variant == .standardIncoming else { + guard self?.viewModel.state.threadVariant == .contact, cellViewModel.variant == .standardIncoming else { return } @@ -2708,7 +2654,7 @@ extension ConversationVC: isSavingMedia: true, presentingViewController: self, using: viewModel.dependencies - ) { [weak self, dependencies = viewModel.dependencies] granted in + ) { [weak self, threadVariant = viewModel.state.threadVariant, dependencies = viewModel.dependencies] granted in guard granted else { return } PHPhotoLibrary.shared().performChanges( @@ -2745,7 +2691,7 @@ extension ConversationVC: } // Send a 'media saved' notification if needed - guard self?.viewModel.threadData.threadVariant == .contact, cellViewModel.variant == .standardIncoming else { + guard threadVariant == .contact, cellViewModel.variant == .standardIncoming else { return } @@ -2771,31 +2717,19 @@ extension ConversationVC: confirmTitle: "theContinue".localized(), confirmStyle: .danger, cancelStyle: .alert_text, - onConfirm: { [weak self, threadData = viewModel.threadData, dependencies = viewModel.dependencies] _ in + onConfirm: { [weak self, threadInfo = viewModel.state.threadInfo, authMethod = viewModel.state.authMethod.value, dependencies = viewModel.dependencies] _ in Result { guard cellViewModel.threadVariant == .community, - let roomToken: String = threadData.openGroupRoomToken, - let server: String = threadData.openGroupServer, - let publicKey: String = threadData.openGroupPublicKey, - let capabilities: Set = threadData.openGroupCapabilities, + let roomToken: String = threadInfo.communityInfo?.roomToken, + !authMethod.isInvalid, cellViewModel.openGroupServerMessageId != nil else { throw CryptoError.invalidAuthentication } - return ( - roomToken, - Authentication.community( - info: LibSession.OpenGroupCapabilityInfo( - roomToken: roomToken, - server: server, - publicKey: publicKey, - capabilities: capabilities - ) - ) - ) + return roomToken } .publisher - .tryFlatMap { (roomToken: String, authMethod: AuthenticationMethod) in + .tryFlatMap { roomToken in try Network.SOGS.preparedUserBan( sessionId: cellViewModel.authorId, from: [roomToken], @@ -2827,7 +2761,7 @@ extension ConversationVC: } ) }, - afterClosed: { [weak self] in + afterClosed: { completion?() } ) @@ -2846,31 +2780,18 @@ extension ConversationVC: confirmTitle: "theContinue".localized(), confirmStyle: .danger, cancelStyle: .alert_text, - onConfirm: { [weak self, threadData = viewModel.threadData, dependencies = viewModel.dependencies] _ in + onConfirm: { [weak self, threadInfo = viewModel.state.threadInfo, authMethod = viewModel.state.authMethod.value, dependencies = viewModel.dependencies] _ in Result { guard cellViewModel.threadVariant == .community, - let roomToken: String = threadData.openGroupRoomToken, - let server: String = threadData.openGroupServer, - let publicKey: String = threadData.openGroupPublicKey, - let capabilities: Set = threadData.openGroupCapabilities, - let openGroupServerMessageId: Int64 = cellViewModel.openGroupServerMessageId + let roomToken: String = threadInfo.communityInfo?.roomToken, + !authMethod.isInvalid else { throw CryptoError.invalidAuthentication } - return ( - roomToken, - Authentication.community( - info: LibSession.OpenGroupCapabilityInfo( - roomToken: roomToken, - server: server, - publicKey: publicKey, - capabilities: capabilities - ) - ) - ) + return roomToken } .publisher - .tryFlatMap { (roomToken: String, authMethod: AuthenticationMethod) in + .tryFlatMap { roomToken in try Network.SOGS.preparedUserBanAndDeleteAllMessages( sessionId: cellViewModel.authorId, roomToken: roomToken, @@ -2936,8 +2857,7 @@ extension ConversationVC: let url: URL = URL(fileURLWithPath: directory).appendingPathComponent(fileName) // Set up audio session - let isConfigured = (SessionEnvironment.shared?.audioSession.startAudioActivity(recordVoiceMessageActivity) == true) - guard isConfigured else { + guard viewModel.dependencies[singleton: .audioSession].startAudioActivity(recordVoiceMessageActivity) else { return cancelVoiceMessageRecording() } @@ -2973,7 +2893,6 @@ extension ConversationVC: let successfullyPrepared: Bool = audioRecorder.prepareToRecord() let startedRecording: Bool = (successfullyPrepared && audioRecorder.record()) - guard successfullyPrepared && startedRecording else { Log.error(.conversation, (successfullyPrepared ? "Couldn't record audio." : "Couldn't prepare audio recorder.")) @@ -3053,17 +2972,17 @@ extension ConversationVC: func stopVoiceMessageRecording() { audioRecorder?.stop() - SessionEnvironment.shared?.audioSession.endAudioActivity(recordVoiceMessageActivity) + viewModel.dependencies[singleton: .audioSession].endAudioActivity(recordVoiceMessageActivity) } // MARK: - Data Extraction Notifications func sendDataExtraction(kind: DataExtractionNotification.Kind) { // Only send screenshot notifications to one-to-one conversations - guard self.viewModel.threadData.threadVariant == .contact else { return } + guard self.viewModel.state.threadVariant == .contact else { return } - let threadId: String = self.viewModel.threadData.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant + let threadId: String = self.viewModel.state.threadId + let threadVariant: SessionThread.Variant = self.viewModel.state.threadVariant viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in try MessageSender.send( @@ -3123,6 +3042,22 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate { // MARK: - Message Request Actions extension ConversationVC { + @MainActor internal func removeMessageRequestsFromBackStackIfNeeded() { + /// Remove the `SessionTableViewController` from the nav hierarchy if present + if + let viewControllers: [UIViewController] = self.navigationController?.viewControllers, + let messageRequestsIndex = viewControllers + .firstIndex(where: { viewCon -> Bool in + (viewCon as? SessionViewModelAccessible)?.viewModelType == MessageRequestsViewModel.self + }), + messageRequestsIndex > 0 + { + var newViewControllers = viewControllers + newViewControllers.remove(at: messageRequestsIndex) + self.navigationController?.viewControllers = newViewControllers + } + } + fileprivate func approveMessageRequestIfNeeded( for threadId: String, threadVariant: SessionThread.Variant, @@ -3130,22 +3065,6 @@ extension ConversationVC { isDraft: Bool, timestampMs: Int64 ) async { - let updateNavigationBackStack: @MainActor () -> Void = { [weak self] in - /// Remove the `SessionTableViewController` from the nav hierarchy if present - if - let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, - let messageRequestsIndex = viewControllers - .firstIndex(where: { viewCon -> Bool in - (viewCon as? SessionViewModelAccessible)?.viewModelType == MessageRequestsViewModel.self - }), - messageRequestsIndex > 0 - { - var newViewControllers = viewControllers - newViewControllers.remove(at: messageRequestsIndex) - self?.navigationController?.viewControllers = newViewControllers - } - } - switch threadVariant { case .contact: /// If the contact doesn't exist then we should create it so we can store the `isApproved` state (it'll be updated @@ -3207,7 +3126,7 @@ extension ConversationVC { // Update the UI await MainActor.run { - updateNavigationBackStack() + removeMessageRequestsFromBackStackIfNeeded() } return @@ -3269,7 +3188,7 @@ extension ConversationVC { // Update the UI await MainActor.run { - updateNavigationBackStack() + removeMessageRequestsFromBackStackIfNeeded() } return @@ -3282,10 +3201,10 @@ extension ConversationVC { guard let self = self else { return } await approveMessageRequestIfNeeded( - for: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, - displayName: self.viewModel.threadData.displayName, - isDraft: (self.viewModel.threadData.threadIsDraft == true), + for: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, + displayName: self.viewModel.state.threadInfo.displayName.deformatted(), + isDraft: self.viewModel.state.threadInfo.isDraft, timestampMs: viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() ) } @@ -3297,8 +3216,8 @@ extension ConversationVC { for: .trailing, indexPath: IndexPath(row: 0, section: 0), tableView: self.tableView, - threadViewModel: self.viewModel.threadData, - viewController: self, + threadInfo: self.viewModel.state.threadInfo, + viewController: self, navigatableStateHolder: nil, using: viewModel.dependencies ) @@ -3308,8 +3227,6 @@ extension ConversationVC { action.handler(action, self.view, { [weak self] didConfirm in guard didConfirm else { return } - self?.stopObservingChanges() - DispatchQueue.main.async { self?.navigationController?.popViewController(animated: true) } @@ -3322,7 +3239,7 @@ extension ConversationVC { for: .trailing, indexPath: IndexPath(row: 0, section: 0), tableView: self.tableView, - threadViewModel: self.viewModel.threadData, + threadInfo: self.viewModel.state.threadInfo, viewController: self, navigatableStateHolder: nil, using: viewModel.dependencies @@ -3333,8 +3250,6 @@ extension ConversationVC { action.handler(action, self.view, { [weak self] didConfirm in guard didConfirm else { return } - self?.stopObservingChanges() - DispatchQueue.main.async { self?.navigationController?.popViewController(animated: true) } @@ -3346,8 +3261,8 @@ extension ConversationVC { extension ConversationVC { @objc public func recreateLegacyGroupTapped() { - let threadId: String = self.viewModel.threadData.threadId - let closedGroupName: String? = self.viewModel.threadData.closedGroupName + let threadId: String = self.viewModel.state.threadId + let closedGroupName: String? = self.viewModel.state.threadInfo.groupInfo?.name let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "recreateGroup".localized(), diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index dee125893b..98923d88ee 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -2,6 +2,7 @@ import UIKit import AVKit +import Combine import GRDB import DifferenceKit import Lucide @@ -11,15 +12,18 @@ import SessionUtilitiesKit import SignalUtilitiesKit final class ConversationVC: BaseVC, LibSessionRespondingViewController, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { + private static let emptyStateLabelFont: UIFont = .systemFont(ofSize: Values.verySmallFontSize) private static let loadingHeaderHeight: CGFloat = 40 static let expandedAttachmentButtonSpacing: CGFloat = 4 internal let viewModel: ConversationViewModel - private var dataChangeObservable: DatabaseCancellable? { - didSet { oldValue?.cancel() } // Cancel the old observable if there was one - } - private var hasLoadedInitialThreadData: Bool = false - private var hasLoadedInitialInteractionData: Bool = false + private var disposables: Set = Set() + + /// Currently loaded version of the data for the `tableView`, will always match the value in the `viewModel` unless it's part way + /// through updating it's state + internal var sections: [ConversationViewModel.SectionModel] = [] + private var initialLoadComplete: Bool = false + private var currentTargetOffset: CGPoint? private var isAutoLoadingNextPage: Bool = false private var isLoadingMore: Bool = false @@ -90,12 +94,13 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa lazy var recordVoiceMessageActivity = AudioActivity( audioDescription: "Voice message", // stringlint:ignore - behavior: .playAndRecord + behavior: .playAndRecord, + using: viewModel.dependencies ) lazy var searchController: ConversationSearchController = { let result: ConversationSearchController = ConversationSearchController( - threadId: self.viewModel.threadData.threadId + threadId: self.viewModel.state.threadId ) result.uiSearchController.obscuresBackgroundDuringPresentation = false result.delegate = self @@ -182,7 +187,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa info: InfoBanner.Info( font: .systemFont(ofSize: Values.verySmallFontSize), message: "disappearingMessagesLegacy" - .put(key: "name", value: self.viewModel.threadData.displayName) + .put(key: "name", value: self.viewModel.state.threadInfo.displayName.deformatted()) .localizedFormatted(baseFont: .systemFont(ofSize: Values.verySmallFontSize)), icon: .close, tintColor: .messageBubble_outgoingText, @@ -200,8 +205,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa lazy var legacyGroupsBanner: InfoBanner = { let result: InfoBanner = InfoBanner( info: InfoBanner.Info( - font: viewModel.legacyGroupsBannerFont, - message: viewModel.legacyGroupsBannerMessage, + font: ConversationViewModel.legacyGroupsBannerFont, + message: viewModel.state.legacyGroupsBannerMessage, icon: .none, tintColor: .messageBubble_outgoingText, backgroundColor: .primary, @@ -210,7 +215,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa onTap: { [weak self] in self?.openUrl(Features.legacyGroupDepricationUrl) } ) ) - result.isHidden = (viewModel.threadData.threadVariant != .legacyGroup) + result.isHidden = (viewModel.state.threadVariant != .legacyGroup) return result }() @@ -229,8 +234,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa ) ) result.isHidden = ( - viewModel.threadData.threadVariant != .group || - viewModel.threadData.closedGroupExpired != true + viewModel.state.threadVariant != .group || + viewModel.state.threadInfo.groupInfo?.expired != true ) return result @@ -258,8 +263,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa let result: UILabel = UILabel() result.accessibilityIdentifier = "Control message" result.translatesAutoresizingMaskIntoConstraints = false - result.font = .systemFont(ofSize: Values.verySmallFontSize) - result.themeAttributedText = viewModel.emptyStateText(for: viewModel.threadData).formatted(in: result) + result.font = ConversationVC.emptyStateLabelFont + result.themeAttributedText = viewModel.state.emptyStateText.formatted(in: result) result.themeTextColor = .textSecondary result.textAlignment = .center result.lineBreakMode = .byWordWrapping @@ -299,11 +304,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa }() lazy var messageRequestFooterView: MessageRequestFooterView = MessageRequestFooterView( - threadVariant: self.viewModel.threadData.threadVariant, - canWrite: (self.viewModel.threadData.threadCanWrite == true), - threadIsMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true), - threadRequiresApproval: (self.viewModel.threadData.threadRequiresApproval == true), - closedGroupAdminProfile: self.viewModel.threadData.closedGroupAdminProfile, + threadVariant: self.viewModel.state.threadVariant, + canWrite: self.viewModel.state.threadInfo.canWrite, + threadIsMessageRequest: self.viewModel.state.threadInfo.isMessageRequest, + threadRequiresApproval: self.viewModel.state.threadInfo.requiresApproval, + closedGroupAdminProfile: self.viewModel.state.threadInfo.groupInfo?.adminProfile, onBlock: { [weak self] in self?.blockMessageRequest() }, onAccept: { [weak self] in self?.acceptMessageRequest() }, onDecline: { [weak self] in self?.declineMessageRequest() } @@ -312,8 +317,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa private lazy var legacyGroupsRecreateGroupView: UIView = { let result: UIView = UIView() result.isHidden = ( - viewModel.threadData.threadVariant != .legacyGroup || - viewModel.threadData.currentUserIsClosedGroupAdmin != true + viewModel.state.threadVariant != .legacyGroup || + viewModel.state.threadInfo.groupInfo?.currentUserRole != .admin ) result.addSubview(legacyGroupsFooterButton) @@ -346,13 +351,9 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa lazy var snInputView: InputView = InputView( delegate: self, - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: self.viewModel.initialThreadVariant, - using: self.viewModel.dependencies - ), imageDataManager: self.viewModel.dependencies[singleton: .imageDataManager], linkPreviewManager: self.viewModel.dependencies[singleton: .linkPreviewManager], - sessionProStatePublisher: self.viewModel.dependencies[singleton: .sessionProState].isSessionProActivePublisher, + sessionProManager: self.viewModel.dependencies[singleton: .sessionProManager], didLoadLinkPreview: nil ) @@ -476,23 +477,19 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - Initialization init( - threadId: String, - threadVariant: SessionThread.Variant, + threadInfo: ConversationInfoViewModel, focusedInteractionInfo: Interaction.TimestampInfo? = nil, using dependencies: Dependencies ) { self.viewModel = ConversationViewModel( - threadId: threadId, - threadVariant: threadVariant, + threadInfo: threadInfo, focusedInteractionInfo: focusedInteractionInfo, + currentUserMentionImage: MentionUtilities.generateCurrentUserMentionImage( + textColor: MessageViewModel.bodyTextColor(isOutgoing: false) /// Outgoing messages don't use the image + ), using: dependencies ) - /// Dispatch adding the database observation to a background thread - DispatchQueue.global(qos: .userInitiated).async { [weak viewModel] in - dependencies[singleton: .storage].addObserver(viewModel?.pagedDataObserver) - } - super.init(nibName: nil, bundle: nil) } @@ -500,10 +497,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa preconditionFailure("Use init(thread:) instead.") } - deinit { - NotificationCenter.default.removeObserver(self) - } - // MARK: - Lifecycle override func viewDidLoad() { @@ -515,18 +508,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // nav will be offset incorrectly during the push animation (unfortunately the profile icon still // doesn't appear until after the animation, I assume it's taking a snapshot or something, but // there isn't much we can do about that unfortunately) - updateNavBarButtons( - threadData: nil, - initialVariant: self.viewModel.initialThreadVariant, - initialIsNoteToSelf: self.viewModel.threadData.threadIsNoteToSelf, - initialIsBlocked: (self.viewModel.threadData.threadIsBlocked == true) - ) - titleView.initialSetup( - with: self.viewModel.initialThreadVariant, - isNoteToSelf: self.viewModel.threadData.threadIsNoteToSelf, - isMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true), - isSessionPro: self.viewModel.threadData.isSessionPro(using: self.viewModel.dependencies) - ) + updateNavBarButtons(threadInfo: self.viewModel.state.threadInfo) + titleView.update(with: self.viewModel.state.titleViewModel) // Constraints view.addSubview(tableView) @@ -583,33 +566,21 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Gesture view.addGestureRecognizer(tableViewTapGesture) - // Notifications - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidBecomeActive(_:)), - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidResignActive(_:)), - name: UIApplication.didEnterBackgroundNotification, object: nil - ) - self.viewModel.navigatableState.setupBindings(viewController: self, disposables: &self.viewModel.disposables) + // Bind the UI to the view model + bindViewModel() + // The first time the view loads we should mark the thread as read (in case it was manually // marked as unread) - doing this here means if we add a "mark as unread" action within the // conversation settings then we don't need to worry about the conversation getting marked as // when when the user returns back through this view controller - self.viewModel.markAsRead(target: .thread, timestampMs: nil) + Task { await self.viewModel.markThreadAsRead() } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - startObservingChanges() - /// If the view is removed and readded to the view hierarchy then `viewWillDisappear` will be called but `viewDidDisappear` /// **won't**, as a result `viewIsDisappearing` would never get set to `false` - do so here to handle this case viewIsAppearing = true @@ -671,7 +642,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // to appear to remain focussed) guard !isReplacingThread else { return } - stopObservingChanges() viewModel.updateDraft(to: mentions.update(snInputView.text)) inputAccessoryView?.resignFirstResponder() } @@ -684,348 +654,123 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa /// If the user just created this thread but didn't send a message or the conversation is marked as hidden then we want to delete the /// "shadow" thread since it's not actually in use (this is to prevent it from taking up database space or unintentionally getting synced /// via `libSession` in the future) - let threadId: String = viewModel.threadData.threadId - if ( self.navigationController == nil || self.navigationController?.viewControllers.contains(self) == false ) && - viewModel.threadData.threadIsNoteToSelf == false && - viewModel.threadData.threadIsDraft == true + !viewModel.state.threadInfo.isNoteToSelf && + viewModel.state.threadInfo.isDraft { - viewModel.dependencies[singleton: .storage].writeAsync { db in - _ = try SessionThread // Intentionally use `deleteAll` here instead of `deleteOrLeave` - .filter(id: threadId) - .deleteAll(db) - } - } - - /// Should only be `true` when the view controller is being removed from the stack - if isMovingFromParent { - DispatchQueue.global(qos: .userInitiated).async { [weak self, dependencies = viewModel.dependencies] in - dependencies[singleton: .storage].removeObserver(self?.viewModel.pagedDataObserver) + viewModel.dependencies[singleton: .storage].writeAsync { [state = viewModel.state, dependencies = viewModel.dependencies] db in + try SessionThread.deleteOrLeave( + db, + type: .deleteContactConversationAndContact, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, + using: dependencies + ) } } } - @objc func applicationDidBecomeActive(_ notification: Notification) { - /// **Note:** When returning from the background we could have received notifications but the `PagedDatabaseObserver` - /// won't have them so we need to force a re-fetch of the current data to ensure everything is up to date - DispatchQueue.global(qos: .background).async { [weak self] in - self?.viewModel.pagedDataObserver?.resume() - } - } - - @objc func applicationDidResignActive(_ notification: Notification) { - /// When going into the background we should stop listening to database changes (we will resume/reload after returning from - /// the background) - viewModel.pagedDataObserver?.suspend() - } - // MARK: - Updating - private func startObservingChanges() { - guard dataChangeObservable == nil else { return } - - dataChangeObservable = viewModel.dependencies[singleton: .storage].start( - viewModel.observableThreadData, - onError: { _ in }, - onChange: { [weak self, dependencies = viewModel.dependencies] maybeThreadData in - guard let threadData: SessionThreadViewModel = maybeThreadData else { - // If the thread data is null and the id was blinded then we just unblinded the thread - // and need to swap over to the new one - guard - let sessionId: String = self?.viewModel.threadData.threadId, - ( - (try? SessionId.Prefix(from: sessionId)) == .blinded15 || - (try? SessionId.Prefix(from: sessionId)) == .blinded25 - ), - let blindedLookup: BlindedIdLookup = dependencies[singleton: .storage].read({ db in - try BlindedIdLookup - .filter(id: sessionId) - .fetchOne(db) - }), - let unblindedId: String = blindedLookup.sessionId - else { - // If we don't have an unblinded id then something has gone very wrong so pop to the - // nearest conversation list - let maybeTargetViewController: UIViewController? = self?.navigationController? - .viewControllers - .last(where: { ($0 as? LibSessionRespondingViewController)?.isConversationList == true }) - - if let targetViewController: UIViewController = maybeTargetViewController { - self?.navigationController?.popToViewController(targetViewController, animated: true) - } - else { - self?.navigationController?.popToRootViewController(animated: true) - } - return - } - - // Stop observing changes - self?.stopObservingChanges() - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - dependencies[singleton: .storage].removeObserver(self?.viewModel.pagedDataObserver) - } - - // Swap the observing to the updated thread - let newestVisibleMessageId: Int64? = self?.fullyVisibleCellViewModels()?.last?.id - self?.viewModel.swapToThread(updatedThreadId: unblindedId, focussedMessageId: newestVisibleMessageId) - - /// Start observing changes again (on a background thread) - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - dependencies[singleton: .storage].addObserver(self?.viewModel.pagedDataObserver) - } - self?.startObservingChanges() - return + private func bindViewModel() { + viewModel.$state + .receive(on: DispatchQueue.main) + .removeDuplicates() + .sink { [weak self] state in + /// Don't animate the changes if it's the first load + if self?.initialLoadComplete == false { + return UIView.performWithoutAnimation { self?.render(state: state) } } - // The default scheduler emits changes on the main thread - self?.handleThreadUpdates(threadData) - - // Note: We want to load the interaction data into the UI after the initial thread data - // has loaded to prevent an issue where the conversation loads with the wrong offset - if self?.viewModel.onInteractionChange == nil { - self?.viewModel.onInteractionChange = { [weak self] updatedInteractionData, changeset in - self?.handleInteractionUpdates(updatedInteractionData, changeset: changeset) - } - } + self?.render(state: state) } - ) + .store(in: &disposables) } - func stopObservingChanges() { - self.dataChangeObservable?.cancel() - self.dataChangeObservable = nil - self.viewModel.onInteractionChange = nil - } - - private func handleThreadUpdates(_ updatedThreadData: SessionThreadViewModel, initialLoad: Bool = false) { - // Ensure the first load or a load when returning from a child screen runs without animations (if - // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) - guard hasLoadedInitialThreadData && hasReloadedThreadDataAfterDisappearance else { - // Need to correctly determine if it's the initial load otherwise we would be needlesly updating - // extra UI elements - let isInitialLoad: Bool = ( - !hasLoadedInitialThreadData && - hasReloadedThreadDataAfterDisappearance - ) - hasLoadedInitialThreadData = true - hasReloadedThreadDataAfterDisappearance = true - - UIView.performWithoutAnimation { - handleThreadUpdates(updatedThreadData, initialLoad: isInitialLoad) - } - return + @MainActor private func render(state: ConversationViewModel.State) { + /// If we just unblinded the contact then we should remove the message requests screen from the back stack (if it's there) + if state.wasPreviouslyBlindedContact && !state.isBlindedContact { + removeMessageRequestsFromBackStackIfNeeded() } // Update general conversation UI + titleView.update(with: state.titleViewModel) + updateNavBarButtons(threadInfo: state.threadInfo) - if - initialLoad || - viewModel.threadData.displayName != updatedThreadData.displayName || - viewModel.threadData.threadVariant != updatedThreadData.threadVariant || - viewModel.threadData.threadIsNoteToSelf != updatedThreadData.threadIsNoteToSelf || - viewModel.threadData.threadMutedUntilTimestamp != updatedThreadData.threadMutedUntilTimestamp || - viewModel.threadData.threadOnlyNotifyForMentions != updatedThreadData.threadOnlyNotifyForMentions || - viewModel.threadData.userCount != updatedThreadData.userCount || - viewModel.threadData.disappearingMessagesConfiguration != updatedThreadData.disappearingMessagesConfiguration - { - titleView.update( - with: updatedThreadData.displayName, - isNoteToSelf: updatedThreadData.threadIsNoteToSelf, - isMessageRequest: (updatedThreadData.threadIsMessageRequest == true), - isSessionPro: updatedThreadData.isSessionPro(using: viewModel.dependencies), - threadVariant: updatedThreadData.threadVariant, - mutedUntilTimestamp: updatedThreadData.threadMutedUntilTimestamp, - onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true), - userCount: updatedThreadData.userCount, - disappearingMessagesConfig: updatedThreadData.disappearingMessagesConfiguration - ) - - // Update the empty state - emptyStateLabel.themeAttributedText = viewModel - .emptyStateText(for: updatedThreadData) - .formatted(in: emptyStateLabel) - } - - if - initialLoad || - viewModel.threadData.threadVariant != updatedThreadData.threadVariant || - viewModel.threadData.threadIsNoteToSelf != updatedThreadData.threadIsNoteToSelf || - viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked || - viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest || - viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || - viewModel.threadData.profile != updatedThreadData.profile || - viewModel.threadData.additionalProfile != updatedThreadData.additionalProfile || - viewModel.threadData.threadDisplayPictureUrl != updatedThreadData.threadDisplayPictureUrl - { - updateNavBarButtons( - threadData: updatedThreadData, - initialVariant: viewModel.initialThreadVariant, - initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf, - initialIsBlocked: (viewModel.threadData.threadIsBlocked == true) - ) - } - - if - initialLoad || - viewModel.threadData.threadCanWrite != updatedThreadData.threadCanWrite || - viewModel.threadData.threadVariant != updatedThreadData.threadVariant || - viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest || - viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || - viewModel.threadData.closedGroupAdminProfile != updatedThreadData.closedGroupAdminProfile - { - UIView.animate(withDuration: 0.3) { [weak self] in - self?.messageRequestFooterView.update( - threadVariant: updatedThreadData.threadVariant, - canWrite: (updatedThreadData.threadCanWrite == true), - threadIsMessageRequest: (updatedThreadData.threadIsMessageRequest == true), - threadRequiresApproval: (updatedThreadData.threadRequiresApproval == true), - closedGroupAdminProfile: updatedThreadData.closedGroupAdminProfile - ) - } - } - - if - initialLoad || - viewModel.threadData.outdatedMemberId != updatedThreadData.outdatedMemberId || - viewModel.threadData.disappearingMessagesConfiguration != updatedThreadData.disappearingMessagesConfiguration - { - addOrRemoveOutdatedClientBanner( - outdatedMemberId: updatedThreadData.outdatedMemberId, - disappearingMessagesConfiguration: updatedThreadData.disappearingMessagesConfiguration - ) - } - - if - initialLoad || - viewModel.threadData.threadVariant != updatedThreadData.threadVariant || - viewModel.threadData.currentUserIsClosedGroupAdmin != updatedThreadData.currentUserIsClosedGroupAdmin - { - legacyGroupsBanner.isHidden = (updatedThreadData.threadVariant != .legacyGroup) - } - - if - initialLoad || - viewModel.threadData.threadVariant != updatedThreadData.threadVariant || - viewModel.threadData.closedGroupExpired != updatedThreadData.closedGroupExpired - { - expiredGroupBanner.isHidden = ( - updatedThreadData.threadVariant != .group || - updatedThreadData.closedGroupExpired != true - ) - } - - if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { - updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount) - } + addOrRemoveOutdatedClientBanner( + contactInfo: state.threadInfo.contactInfo, + disappearingMessagesConfiguration: state.threadInfo.disappearingMessagesConfiguration + ) - if initialLoad || viewModel.threadData.messageInputState != updatedThreadData.messageInputState { - snInputView.setMessageInputState(updatedThreadData.messageInputState) - } + legacyGroupsBanner.isHidden = (state.threadVariant != .legacyGroup) + expiredGroupBanner.isHidden = ( + state.threadVariant != .group || + state.threadInfo.groupInfo?.expired != true + ) + updateUnreadCountView(unreadCount: state.threadInfo.unreadCount) + snInputView.setMessageInputState(state.messageInputState) + + messageRequestFooterView.update( + threadVariant: state.threadVariant, + canWrite: state.threadInfo.canWrite, + threadIsMessageRequest: state.threadInfo.isMessageRequest, + threadRequiresApproval: state.threadInfo.requiresApproval, + closedGroupAdminProfile: state.threadInfo.groupInfo?.adminProfile + ) - // Only set the draft content on the initial load - if initialLoad, let draft: String = updatedThreadData.threadMessageDraft, !draft.isEmpty { - let (string, mentions) = MentionUtilities.getMentions( - in: draft, - currentUserSessionIds: (updatedThreadData.currentUserSessionIds ?? []), - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: updatedThreadData.threadVariant, - using: viewModel.dependencies - ) + // Only set the draft content on the initial load (once we have data) + if !initialLoadComplete, !state.threadInfo.messageDraft.isEmpty { + let (string, _) = MentionUtilities.getMentions( + in: state.threadInfo.messageDraft, + currentUserSessionIds: state.threadInfo.currentUserSessionIds, + displayNameRetriever: { [weak self] sessionId, inMessageBody in + self?.viewModel.displayName(for: sessionId, inMessageBody: inMessageBody) + } ) - snInputView.text = string - snInputView.updateNumberOfCharactersLeft(draft) - // Fetch the mention info asynchronously - if !mentions.isEmpty { - viewModel.dependencies[singleton: .storage].readAsync( - retrieve: { [openGroupManager = viewModel.dependencies[singleton: .openGroupManager]] db -> ([Profile], [GroupMember]) in - let profiles: [Profile] = try Profile - .filter(ids: mentions.map { $0.profileId }) - .fetchAll(db) - - guard - let server: String = updatedThreadData.openGroupServer, - let roomToken: String = updatedThreadData.openGroupRoomToken - else { return (profiles, []) } - - let adminModMembers: [GroupMember] = try openGroupManager.membersWhere( - db, - currentUserSessionIds: (updatedThreadData.currentUserSessionIds ?? []), - .groupIds([OpenGroup.idFor(roomToken: roomToken, server: server)]), - .publicKeys(profiles.map { $0.id }), - .roles([.moderator, .admin]) - ) - - return (profiles, adminModMembers) - }, - completion: { [weak self, dependencies = viewModel.dependencies] result in - guard - let self = self, - case .success((let profiles, let adminModMembers)) = result - else { return } - - self.mentions = self.mentions.appending( - contentsOf: MentionSelectionView.ViewModel.mentions( - profiles: profiles, - threadVariant: updatedThreadData.threadVariant, - currentUserSessionIds: (updatedThreadData.currentUserSessionIds ?? []), - adminModMembers: adminModMembers, - using: dependencies - ) - ) - } - ) - } + snInputView.text = string + snInputView.updateNumberOfCharactersLeft(state.threadInfo.messageDraft) } - // Now we have done all the needed diffs update the viewModel with the latest data - self.viewModel.updateThreadData(updatedThreadData) - } - - private func handleInteractionUpdates( - _ updatedData: [ConversationViewModel.SectionModel], - changeset: StagedChangeset<[ConversationViewModel.SectionModel]>, - initialLoad: Bool = false - ) { - // Determine if we have any messages for the empty state - let hasMessages: Bool = (updatedData - .filter { $0.model == .messages } - .first? - .elements - .isEmpty == false) - - // Ensure the first load or a load when returning from a child screen runs without - // animations (if we don't do this the cells will animate in from a frame of - // CGRect.zero or have a buggy transition) - guard self.hasLoadedInitialInteractionData else { - // Need to dispatch async to prevent this from causing glitches in the push animation - DispatchQueue.main.async { - self.viewModel.updateInteractionData(updatedData) - - // Update the empty state - self.emptyStateLabelContainer.isHidden = hasMessages - - UIView.performWithoutAnimation { - self.tableView.reloadData() - self.hasLoadedInitialInteractionData = true - self.performInitialScrollIfNeeded() - } + // Update the table content + let updatedSections: [ConversationViewModel.SectionModel] = state.sections(viewModel: viewModel) + + /// Update the empty state + /// + /// **Note:** Need to reset the fonts as it seems that the `.font` values can end up using a styled font from the attributed text + emptyStateLabel.font = ConversationVC.emptyStateLabelFont + emptyStateLabel.themeAttributedText = state.emptyStateText.formatted(in: emptyStateLabel) + emptyStateLabelContainer.isHidden = (state.viewState != .empty) + + // If this is the initial load then just do a full table refresh + guard state.viewState != .loading && initialLoadComplete else { + if state.viewState == .loaded { + sections = updatedSections + tableView.reloadData() + initialLoadComplete = true + performInitialScrollIfNeeded() /// Need to call after updating `initialLoadComplete` } return } - // Update the empty state - self.emptyStateLabelContainer.isHidden = hasMessages - // Update the ReactionListSheet (if one exists) - if let messageUpdates: [MessageViewModel] = updatedData.first(where: { $0.model == .messages })?.elements { + if let messageUpdates: [MessageViewModel] = sections.first(where: { $0.model == .messages })?.elements { self.currentReactionListSheet?.handleInteractionUpdates(messageUpdates) } + // It's not the initial load so we should get a diff and may need to animate the change + let changeset: StagedChangeset = StagedChangeset( + source: sections, + target: updatedSections + ) + + // If there were no changes then no need to make changes to the table view + if changeset.isEmpty { return } + // Store the 'sentMessageBeforeUpdate' state locally let didSendMessageBeforeUpdate: Bool = self.viewModel.sentMessageBeforeUpdate let onlyReplacedOptimisticUpdate: Bool = { @@ -1038,17 +783,21 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa let deletedModels: [MessageViewModel] = changeset[changeset.count - 2] .elementDeleted - .map { self.viewModel.interactionData[$0.section].elements[$0.element] } + .map { self.sections[$0.section].elements[$0.element] } let insertedModels: [MessageViewModel] = changeset[changeset.count - 1] .elementInserted - .map { updatedData[$0.section].elements[$0.element] } + .map { updatedSections[$0.section].elements[$0.element] } - // Make sure all the deleted models were optimistic updates, the inserted models were not - // optimistic updates and they have the same timestamps + /// Make sure all the deleted models were optimistic updates, the inserted models were not optimistic updates and they + /// have the same `receivedAtTimestampMs` values + /// + /// **Note:** When sending a message to a Community conversation we replace the `timestampMs` with the server + /// timestamp so can't use that one as the "identifier", luckily the `receivedAtTimestampMs` is set at the time of creation + /// so it can be used return ( - deletedModels.map { $0.id }.asSet() == [MessageViewModel.optimisticUpdateId] && - insertedModels.map { $0.id }.asSet() != [MessageViewModel.optimisticUpdateId] && - deletedModels.map { $0.timestampMs }.asSet() == insertedModels.map { $0.timestampMs }.asSet() + !deletedModels.contains { $0.optimisticMessageId == nil } && + !insertedModels.contains { $0.optimisticMessageId != nil } && + deletedModels.map { $0.receivedAtTimestampMs }.asSet() == insertedModels.map { $0.receivedAtTimestampMs }.asSet() ) }() let wasOnlyUpdates: Bool = ( @@ -1064,7 +813,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // but an instant update feels snappy and without the instant update there is some overlap of the read // status text change even though there shouldn't be any animations) guard !didSendMessageBeforeUpdate && !wasOnlyUpdates else { - self.viewModel.updateInteractionData(updatedData) + sections = updatedSections self.tableView.reloadData() // If we just sent a message then we want to jump to the bottom of the conversation instantly @@ -1110,17 +859,17 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa let isInsert: Bool = (numItemsInserted > 0) let wasLoadingMore: Bool = self.isLoadingMore let wasOffsetCloseToBottom: Bool = self.isCloseToBottom - let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count } + let numItemsInUpdatedData: [Int] = updatedSections.map { $0.elements.count } let didSwapAllContent: Bool = { // The dynamic headers use negative id values so by using `compactMap` and returning // null in those cases allows us to exclude them without another iteration via `filter` - let currentIds: Set = (self.viewModel.interactionData + let currentIds: Set = (self.sections .first { $0.model == .messages }? .elements .compactMap { $0.id > 0 ? $0.id : nil } .asSet()) .defaulting(to: []) - let updatedIds: Set = (updatedData + let updatedIds: Set = (updatedSections .first { $0.model == .messages }? .elements .compactMap { $0.id > 0 ? $0.id : nil } @@ -1132,43 +881,41 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa let itemChangeInfo: ItemChangeInfo = { guard isInsert, - let oldSectionIndex: Int = self.viewModel.interactionData.firstIndex(where: { $0.model == .messages }), - let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }), + let oldSectionIndex: Int = self.sections.firstIndex(where: { $0.model == .messages }), + let newSectionIndex: Int = updatedSections.firstIndex(where: { $0.model == .messages }), let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows? .filter({ $0.section == oldSectionIndex && - self.viewModel.interactionData[$0.section].elements[$0.row].cellType != .dateHeader + self.sections[$0.section].elements[$0.row].cellType != .dateHeader }) .sorted() .first else { return ItemChangeInfo() } guard - let newFirstItemIndex: Int = updatedData[newSectionIndex].elements + let newFirstItemIndex: Int = updatedSections[newSectionIndex].elements .firstIndex(where: { item -> Bool in // Since the first item is probably a `DateHeaderCell` (which would likely // be removed when inserting items above it) we check if the id matches - let messages: [MessageViewModel] = self.viewModel - .interactionData[oldSectionIndex] - .elements + let messages: [MessageViewModel] = self.sections[oldSectionIndex].elements return ( item.id == messages[safe: 0]?.id || item.id == messages[safe: 1]?.id ) }), - let newVisibleIndex: Int = updatedData[newSectionIndex].elements + let newVisibleIndex: Int = updatedSections[newSectionIndex].elements .firstIndex(where: { item in - item.id == self.viewModel.interactionData[oldSectionIndex] + item.id == self.sections[oldSectionIndex] .elements[firstVisibleIndexPath.row] .id }) else { - let oldTimestamps: [Int64] = self.viewModel.interactionData[oldSectionIndex] + let oldTimestamps: [Int64] = self.sections[oldSectionIndex] .elements .filter { $0.cellType != .dateHeader } .map { $0.timestampMs } - let newTimestamps: [Int64] = updatedData[newSectionIndex] + let newTimestamps: [Int64] = updatedSections[newSectionIndex] .elements .filter { $0.cellType != .dateHeader } .map { $0.timestampMs } @@ -1194,7 +941,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa }() guard !isInsert || (!didSwapAllContent && itemChangeInfo.isInsertAtTop) else { - self.viewModel.updateInteractionData(updatedData) + sections = updatedSections self.tableView.reloadData() // If we had a focusedInteractionInfo then scroll to it (and hide the search @@ -1264,7 +1011,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // sections/rows and then update the contentOffset self.tableView.afterNextLayoutSubviews( when: { numSections, numRowsInSections, _ -> Bool in - numSections == updatedData.count && + numSections == updatedSections.count && numRowsInSections == numItemsInUpdatedData }, then: { [weak self] in @@ -1331,24 +1078,22 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa reloadRowsAnimation: .none, interrupt: { itemChangeInfo.isInsertAtTop || $0.changeCount > ConversationViewModel.pageSize } ) { [weak self] updatedData in - self?.viewModel.updateInteractionData(updatedData) + self?.sections = updatedData } } // MARK: Updating private func performInitialScrollIfNeeded() { - guard !hasPerformedInitialScroll && hasLoadedInitialThreadData && hasLoadedInitialInteractionData else { - return - } + guard !hasPerformedInitialScroll && initialLoadComplete else { return } // Scroll to the last unread message if possible; otherwise scroll to the bottom. // When the unread message count is more than the number of view items of a page, // the screen will scroll to the bottom instead of the first unread message - if let focusedInteractionInfo: Interaction.TimestampInfo = self.viewModel.focusedInteractionInfo { + if let focusedInteractionInfo: Interaction.TimestampInfo = self.viewModel.state.focusedInteractionInfo { self.scrollToInteractionIfNeeded( with: focusedInteractionInfo, - focusBehaviour: self.viewModel.focusBehaviour, + focusBehaviour: self.viewModel.state.focusBehaviour, isAnimated: false ) } @@ -1368,7 +1113,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa private func autoLoadNextPageIfNeeded() { guard - self.hasLoadedInitialInteractionData && + self.initialLoadComplete && !self.isAutoLoadingNextPage && !self.isLoadingMore else { return } @@ -1379,7 +1124,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self?.isAutoLoadingNextPage = false // Note: We sort the headers as we want to prioritise loading newer pages over older ones - let sections: [(ConversationViewModel.Section, CGRect)] = (self?.viewModel.interactionData + let sections: [(ConversationViewModel.Section, CGRect)] = (self?.sections .enumerated() .map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) }) .defaulting(to: []) @@ -1400,23 +1145,18 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self?.isLoadingMore = true - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - // Attachments are loaded in descending order so 'loadOlder' actually corresponds with - // 'pageAfter' in this case - self?.viewModel.pagedDataObserver?.load(shouldLoadOlder ? - .pageAfter : - .pageBefore - ) + // Messages are loaded in descending order so 'loadOlder' actually corresponds with + // 'loadPageAfter' in this case + if shouldLoadOlder { + self?.viewModel.loadPageAfter() + } + else { + self?.viewModel.loadPageBefore() } } } - @MainActor func updateNavBarButtons( - threadData: SessionThreadViewModel?, - initialVariant: SessionThread.Variant, - initialIsNoteToSelf: Bool, - initialIsBlocked: Bool - ) { + @MainActor func updateNavBarButtons(threadInfo: ConversationInfoViewModel) { navigationItem.hidesBackButton = isShowingSearchUI if isShowingSearchUI { @@ -1425,14 +1165,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } else { let shouldHaveCallButton: Bool = ( - (threadData?.threadVariant ?? initialVariant) == .contact && - (threadData?.threadIsNoteToSelf ?? initialIsNoteToSelf) == false + threadInfo.variant == .contact && + !threadInfo.isNoteToSelf ) - guard - let threadData: SessionThreadViewModel = threadData, - threadData.canAccessSettings(using: viewModel.dependencies) - else { + guard threadInfo.canAccessSettings else { // Note: Adding empty buttons because without it the title alignment is busted (Note: The size was // taken from the layout inspector for the back button in Xcode navigationItem.rightBarButtonItems = [ @@ -1461,11 +1198,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa dataManager: viewModel.dependencies[singleton: .imageDataManager] ) profilePictureView.update( - publicKey: threadData.threadId, // Contact thread uses the contactId - threadVariant: threadData.threadVariant, - displayPictureUrl: threadData.threadDisplayPictureUrl, - profile: threadData.profile, - additionalProfile: threadData.additionalProfile, + publicKey: threadInfo.id, // Contact thread uses the contactId + threadVariant: threadInfo.variant, + displayPictureUrl: threadInfo.displayPictureUrl, + profile: threadInfo.profile, + additionalProfile: threadInfo.additionalProfile, using: viewModel.dependencies ) profilePictureView.customWidth = (44 - 16) // Width of the standard back button @@ -1498,10 +1235,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - General func addOrRemoveOutdatedClientBanner( - outdatedMemberId: String?, + contactInfo: ConversationInfoViewModel.ContactInfo?, disappearingMessagesConfiguration: DisappearingMessagesConfiguration? ) { - let currentDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = disappearingMessagesConfiguration ?? self.viewModel.threadData.disappearingMessagesConfiguration + let currentDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = disappearingMessagesConfiguration ?? self.viewModel.state.threadInfo.disappearingMessagesConfiguration // Do not show the banner until the new disappearing messages is enabled guard currentDisappearingMessagesConfiguration?.isEnabled == true else { self.outdatedClientBanner.isHidden = true @@ -1512,7 +1249,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa return } - guard let outdatedMemberId: String = outdatedMemberId else { + guard + let contactInfo: ConversationInfoViewModel.ContactInfo = contactInfo, + !contactInfo.isCurrentUser, + contactInfo.lastKnownClientVersion == FeatureVersion.legacyDisappearingMessages + else { UIView.animate( withDuration: 0.25, animations: { [weak self] in @@ -1531,14 +1272,9 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa return } - let profileDispalyName: String = Profile.displayName( - id: outdatedMemberId, - threadVariant: self.viewModel.threadData.threadVariant, - using: viewModel.dependencies - ) self.outdatedClientBanner.update( message: "disappearingMessagesLegacy" - .put(key: "name", value: profileDispalyName) + .put(key: "name", value: contactInfo.displayNameInMessageBody) .localizedFormatted(baseFont: self.outdatedClientBanner.font), onTap: { [weak self] in self?.removeOutdatedClientBanner() } ) @@ -1551,11 +1287,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } private func removeOutdatedClientBanner() { - guard let outdatedMemberId: String = self.viewModel.threadData.outdatedMemberId else { return } + guard let contactInfo: ConversationInfoViewModel.ContactInfo = self.viewModel.state.threadInfo.contactInfo else { return } viewModel.dependencies[singleton: .storage].writeAsync { db in try Contact - .filter(id: outdatedMemberId) + .filter(id: contactInfo.id) .updateAll(db, Contact.Columns.lastKnownClientVersion.set(to: nil)) } } @@ -1572,17 +1308,15 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - UITableViewDataSource func numberOfSections(in tableView: UITableView) -> Int { - return viewModel.interactionData.count + return sections.count } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - let section: ConversationViewModel.SectionModel = viewModel.interactionData[section] - - return section.elements.count + return sections[section].elements.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let section: ConversationViewModel.SectionModel = viewModel.interactionData[indexPath.section] + let section: ConversationViewModel.SectionModel = sections[indexPath.section] switch section.model { case .messages: @@ -1626,7 +1360,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let section: ConversationViewModel.SectionModel = viewModel.interactionData[section] + let section: ConversationViewModel.SectionModel = sections[section] switch section.model { case .loadOlder, .loadNewer: @@ -1648,7 +1382,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - UITableViewDelegate func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - let section: ConversationViewModel.SectionModel = viewModel.interactionData[section] + let section: ConversationViewModel.SectionModel = sections[section] switch section.model { case .loadOlder, .loadNewer: return ConversationVC.loadingHeaderHeight @@ -1659,55 +1393,53 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { guard self.hasPerformedInitialScroll && !self.isLoadingMore else { return } - let section: ConversationViewModel.SectionModel = self.viewModel.interactionData[section] + let section: ConversationViewModel.SectionModel = sections[section] switch section.model { - case .loadOlder, .loadNewer: + case .messages: break + case .loadOlder: self.isLoadingMore = true + self.viewModel.loadPageBefore() - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - // Messages are loaded in descending order so 'loadOlder' actually corresponds with - // 'pageAfter' in this case - self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ? - .pageAfter : - .pageBefore - ) - } - - case .messages: break + case .loadNewer: + self.isLoadingMore = true + self.viewModel.loadPageAfter() } } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + /// Don't mark anything as read until after the initial layout because we already mark the "initially focussed" message as read + guard self.didFinishInitialLayout else { return } + + self.markFullyVisibleAndOlderCellsAsRead(interactionInfo: nil) + } func scrollToBottom(isAnimated: Bool) { guard !self.isUserScrolling, - let messagesSectionIndex: Int = self.viewModel.interactionData - .firstIndex(where: { $0.model == .messages }), - !self.viewModel.interactionData[messagesSectionIndex] - .elements - .isEmpty + let messagesSectionIndex: Int = self.sections.firstIndex(where: { $0.model == .messages }), + !self.sections[messagesSectionIndex].elements.isEmpty else { return } // If the last interaction isn't loaded then scroll to the final interactionId on // the thread data - let hasNewerItems: Bool = self.viewModel.interactionData.contains(where: { $0.model == .loadNewer }) + let hasNewerItems: Bool = self.sections.contains(where: { $0.model == .loadNewer }) + let messages: [MessageViewModel] = self.sections[messagesSectionIndex].elements + let lastInteractionInfo: Interaction.TimestampInfo = { + guard + let interactionId: Int64 = self.viewModel.state.threadInfo.lastInteraction?.id, + let timestampMs: Int64 = self.viewModel.state.threadInfo.lastInteraction?.timestampMs + else { + return Interaction.TimestampInfo( + id: messages[messages.count - 1].id, + timestampMs: messages[messages.count - 1].timestampMs + ) + } + + return Interaction.TimestampInfo(id: interactionId, timestampMs: timestampMs) + }() guard !self.didFinishInitialLayout || !hasNewerItems else { - let messages: [MessageViewModel] = self.viewModel.interactionData[messagesSectionIndex].elements - let lastInteractionInfo: Interaction.TimestampInfo = { - guard - let interactionId: Int64 = self.viewModel.threadData.interactionId, - let timestampMs: Int64 = self.viewModel.threadData.interactionTimestampMs - else { - return Interaction.TimestampInfo( - id: messages[messages.count - 1].id, - timestampMs: messages[messages.count - 1].timestampMs - ) - } - - return Interaction.TimestampInfo(id: interactionId, timestampMs: timestampMs) - }() - self.scrollToInteractionIfNeeded( with: lastInteractionInfo, position: .bottom, @@ -1717,7 +1449,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } let targetIndexPath: IndexPath = IndexPath( - row: (self.viewModel.interactionData[messagesSectionIndex].elements.count - 1), + row: (sections[messagesSectionIndex].elements.count - 1), section: messagesSectionIndex ) self.tableView.scrollToRow( @@ -1726,10 +1458,12 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa animated: isAnimated ) - self.viewModel.markAsRead( - target: .threadAndInteractions(interactionsBeforeInclusive: nil), - timestampMs: nil - ) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.viewModel.markAsReadIfNeeded( + interactionInfo: lastInteractionInfo, + visibleViewModelRetriever: nil + ) + } } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { @@ -1742,12 +1476,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa func scrollViewDidScroll(_ scrollView: UIScrollView) { self.updateScrollToBottom() - - // The initial scroll can trigger this logic but we already mark the initially focused message - // as read so don't run the below until the user actually scrolls after the initial layout - guard self.didFinishInitialLayout else { return } - - self.markFullyVisibleAndOlderCellsAsRead(interactionInfo: nil) } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { @@ -1767,12 +1495,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } } - func updateUnreadCountView(unreadCount: UInt?) { - let unreadCount: Int = Int(unreadCount ?? 0) + func updateUnreadCountView(unreadCount: Int) { let fontSize: CGFloat = (unreadCount < 10000 ? Values.verySmallFontSize : 8) unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") // stringlint:ignore unreadCountLabel.font = .boldSystemFont(ofSize: fontSize) - unreadCountView.isHidden = (unreadCount == 0) + unreadCountView.isHidden = (unreadCount <= 0) } public func updateScrollToBottom(force: Bool = false) { @@ -1783,7 +1510,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // If we have a 'loadNewer' item in the interaction data then there are subsequent pages and the // 'scrollToBottom' actions should always be visible to allow the user to jump to the bottom (without // this the button will fade out as the user gets close to the bottom of the current page) - guard !self.viewModel.interactionData.contains(where: { $0.model == .loadNewer }) else { + guard !self.sections.contains(where: { $0.model == .loadNewer }) else { self.scrollButton.alpha = 1 self.unreadCountView.alpha = 1 return @@ -1855,12 +1582,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } // Nav bar buttons - updateNavBarButtons( - threadData: viewModel.threadData, - initialVariant: viewModel.initialThreadVariant, - initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf, - initialIsBlocked: (viewModel.threadData.threadIsBlocked == true) - ) + updateNavBarButtons(threadInfo: viewModel.state.threadInfo) // Hack so that the ResultsBar stays on the screen when dismissing the search field // keyboard. @@ -1895,12 +1617,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa @objc func hideSearchUI() { isShowingSearchUI = false navigationItem.titleView = titleView - updateNavBarButtons( - threadData: viewModel.threadData, - initialVariant: viewModel.initialThreadVariant, - initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf, - initialIsBlocked: (viewModel.threadData.threadIsBlocked == true) - ) + updateNavBarButtons(threadInfo: viewModel.state.threadInfo) searchController.uiSearchController.stubbableSearchBar.stubbedNextResponder = nil UIView.animate(withDuration: 0.3) { @@ -1945,9 +1662,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Ensure the target interaction has been loaded guard - let messageSectionIndex: Int = self.viewModel.interactionData - .firstIndex(where: { $0.model == .messages }), - let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex] + let messageSectionIndex: Int = self.sections.firstIndex(where: { $0.model == .messages }), + let targetMessageIndex = self.sections[messageSectionIndex] .elements .firstIndex(where: { $0.id == interactionInfo.id }) else { @@ -1957,10 +1673,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self.isLoadingMore = true self.searchController.resultsBar.startLoading() - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.viewModel.pagedDataObserver?.load(.jumpTo(id: interactionInfo.id, padding: 5)) - } + self.viewModel.jumpToPage(for: interactionInfo.id, padding: 5) return } @@ -1970,7 +1683,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa guard !self.didFinishInitialLayout && targetMessageIndex > 0 && - self.viewModel.interactionData[messageSectionIndex] + self.sections[messageSectionIndex] .elements[targetMessageIndex - 1] .cellType == .unreadMarker else { @@ -2063,7 +1776,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } case .earlier: - let targetRow: Int = min(targetIndexPath.row + 10, self.viewModel.interactionData[messageSectionIndex].elements.count - 1) + let targetRow: Int = min(targetIndexPath.row + 10, self.sections[messageSectionIndex].elements.count - 1) self.tableView.contentOffset = CGPoint(x: 0, y: self.tableView.rectForRow(at: IndexPath(row: targetRow, section: targetIndexPath.section)).midY) @@ -2076,7 +1789,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self.tableView.scrollToRow(at: targetIndexPath, at: targetPosition, animated: true) } - func fullyVisibleCellViewModels() -> [MessageViewModel]? { + @MainActor func fullyVisibleCellViewModels() -> [MessageViewModel]? { // We remove the 'Values.mediumSpacing' as that is the distance the table content appears above the input view let tableVisualTop: CGFloat = tableView.frame.minY let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing)) @@ -2084,7 +1797,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa guard let visibleIndexPaths: [IndexPath] = self.tableView.indexPathsForVisibleRows, let messagesSection: Int = visibleIndexPaths - .first(where: { self.viewModel.interactionData[$0.section].model == .messages })? + .first(where: { self.sections[$0.section].model == .messages })? .section else { return nil } @@ -2098,7 +1811,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa case is VisibleMessageCell, is CallMessageCell, is InfoMessageCell: return ( view.convert(cell.frame, from: tableView), - self.viewModel.interactionData[indexPath.section].elements[indexPath.row] + self.sections[indexPath.section].elements[indexPath.row] ) case is TypingIndicatorCell, is DateHeaderCell, is UnreadMarkerCell: @@ -2115,28 +1828,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } func markFullyVisibleAndOlderCellsAsRead(interactionInfo: Interaction.TimestampInfo?) { - // Only retrieve the `fullyVisibleCellViewModels` if the viewModel things we should mark something as read - guard self.viewModel.shouldTryMarkAsRead() else { return } - - // We want to mark messages as read on load and while we scroll, so grab the newest message and mark - // everything older as read - guard let newestCellViewModel: MessageViewModel = fullyVisibleCellViewModels()?.last else { - // If we weren't able to get any visible cells for some reason then we should fall back to - // marking the provided interactionInfo as read just in case - if let interactionInfo: Interaction.TimestampInfo = interactionInfo { - self.viewModel.markAsRead( - target: .threadAndInteractions(interactionsBeforeInclusive: interactionInfo.id), - timestampMs: interactionInfo.timestampMs - ) + Task { [weak self] in + await self?.viewModel.markAsReadIfNeeded(interactionInfo: interactionInfo) { + self?.fullyVisibleCellViewModels() } - return } - - // Mark all interactions before the newest entirely-visible one as read - self.viewModel.markAsRead( - target: .threadAndInteractions(interactionsBeforeInclusive: newestCellViewModel.id), - timestampMs: newestCellViewModel.timestampMs - ) } func highlightCellIfNeeded(interactionId: Int64, behaviour: ConversationViewModel.FocusBehaviour) { @@ -2159,6 +1855,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - LibSessionRespondingViewController func isConversation(in threadIds: [String]) -> Bool { - return threadIds.contains(self.viewModel.threadData.threadId) + return threadIds.contains(self.viewModel.state.threadId) } } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 1775337842..69254681f6 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -24,7 +24,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // MARK: - FocusBehaviour - public enum FocusBehaviour { + public enum FocusBehaviour: Sendable, Equatable, Hashable { case none case highlight } @@ -54,671 +54,908 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold case loadNewer } + // MARK: - OptimisticMessageData + + public struct OptimisticMessageData: Sendable, Equatable, Hashable { + let temporaryId: Int64 + let interaction: Interaction + let attachmentData: [Attachment]? + let linkPreviewViewModel: LinkPreviewViewModel? + let linkPreviewPreparedAttachment: PreparedAttachment? + let quoteViewModel: QuoteViewModel? + } + // MARK: - Variables public static let pageSize: Int = 50 + public static let legacyGroupsBannerFont: UIFont = .systemFont(ofSize: Values.miniFontSize) public let navigatableState: NavigatableState = NavigatableState() public var disposables: Set = Set() - private var threadId: String - public let initialThreadVariant: SessionThread.Variant + public let dependencies: Dependencies public var sentMessageBeforeUpdate: Bool = false public var lastSearchedText: String? - public let focusedInteractionInfo: Interaction.TimestampInfo? // Note: This is used for global search - public let focusBehaviour: FocusBehaviour - private let initialUnreadInteractionId: Int64? - private let markAsReadTrigger: PassthroughSubject<(SessionThreadViewModel.ReadTarget, Int64?), Never> = PassthroughSubject() - private var markAsReadPublisher: AnyPublisher? - public let dependencies: Dependencies - public var isCurrentUserSessionPro: Bool { dependencies[cache: .libSession].isSessionPro } + // FIXME: Can avoid this by making the view model an actor (but would require more work) + /// Marked as `@MainActor` just to force thread safety + @MainActor private var pendingMarkAsReadInfo: Interaction.TimestampInfo? + @MainActor private var lastMarkAsReadInfo: Interaction.TimestampInfo? + + /// This value is the current state of the view + @MainActor @Published private(set) var state: State + private var observationTask: Task? - public let legacyGroupsBannerFont: UIFont = .systemFont(ofSize: Values.miniFontSize) - public lazy var legacyGroupsBannerMessage: ThemedAttributedString = { - let localizationKey: String + // MARK: - Initialization + + @MainActor init( + threadInfo: ConversationInfoViewModel, + focusedInteractionInfo: Interaction.TimestampInfo? = nil, + currentUserMentionImage: UIImage, + using dependencies: Dependencies + ) { + self.dependencies = dependencies + self.state = State.initialState( + threadInfo: threadInfo, + focusedInteractionInfo: focusedInteractionInfo, + currentUserMentionImage: currentUserMentionImage, + using: dependencies + ) - switch threadData.currentUserIsClosedGroupAdmin == true { - case false: localizationKey = "legacyGroupAfterDeprecationMember" - case true: localizationKey = "legacyGroupAfterDeprecationAdmin" + /// Bind the state + self.observationTask = ObservationBuilder + .initialValue(self.state) + .debounce(for: .milliseconds(10)) /// Changes trigger multiple events at once so debounce them + .using(dependencies: dependencies) + .query(ConversationViewModel.queryState) + .assign { [weak self] updatedState in self?.state = updatedState } + } + + deinit { + // Stop any audio playing when leaving the screen + Task { @MainActor [audioPlayer] in + audioPlayer?.stop() } - // FIXME: Strings should be updated in Crowdin to include the {icon} - return LocalizationHelper(template: localizationKey) - .put(key: "date", value: Date(timeIntervalSince1970: 1743631200).formattedForBanner) - .localizedFormatted(baseFont: legacyGroupsBannerFont) - .appending(string: " ") // Designs have a space before the icon - .appending(Lucide.Icon.squareArrowUpRight.attributedString(for: legacyGroupsBannerFont)) - .appending(string: " ") // In case it's a RTL font - }() + observationTask?.cancel() + } - public lazy var blockedBannerMessage: String = { - let threadData: SessionThreadViewModel = self.internalThreadData - - switch threadData.threadVariant { - case .contact: - let name: String = Profile.displayName( - id: threadData.threadId, - threadVariant: threadData.threadVariant, - using: dependencies - ) + public enum ConversationViewModelEvent: Hashable { + case sendMessage(data: OptimisticMessageData) + case failedToStoreMessage(temporaryId: Int64) + case resolveOptimisticMessage(temporaryId: Int64, databaseId: Int64) + } + + // MARK: - State + + public struct State: ObservableKeyProvider { + enum ViewState: Equatable { + case loading + case empty + case loaded + } + + let viewState: ViewState + let threadInfo: ConversationInfoViewModel + let authMethod: EquatableAuthenticationMethod + let currentUserMentionImage: UIImage + let isBlindedContact: Bool + let wasPreviouslyBlindedContact: Bool + + /// Used to determine where the paged data should start loading from, and which message should be focused on initial load + let focusedInteractionInfo: Interaction.TimestampInfo? + let focusBehaviour: FocusBehaviour + let initialUnreadInteractionInfo: Interaction.TimestampInfo? + + let loadedPageInfo: PagedData.LoadedInfo + let dataCache: ConversationDataCache + let itemCache: [MessageViewModel.ID: MessageViewModel] + + let titleViewModel: ConversationTitleViewModel + let legacyGroupsBannerIsVisible: Bool + let reactionsSupported: Bool + let recentReactionEmoji: [String] + let isUserModeratorOrAdmin: Bool + let shouldShowTypingIndicator: Bool + + let optimisticallyInsertedMessages: [Int64: OptimisticMessageData] + + // Convenience + + var threadId: String { threadInfo.id } + var threadVariant: SessionThread.Variant { threadInfo.variant } + var userSessionId: SessionId { threadInfo.userSessionId } + + var emptyStateText: String { + let blocksCommunityMessageRequests: Bool = (threadInfo.profile?.blocksCommunityMessageRequests == true) + + switch (threadInfo.isNoteToSelf, threadInfo.canWrite, blocksCommunityMessageRequests, threadInfo.groupInfo?.wasKicked, threadInfo.groupInfo?.isDestroyed) { + case (true, _, _, _, _): return "noteToSelfEmpty".localized() + case (_, false, true, _, _): + return "messageRequestsTurnedOff" + .put(key: "name", value: threadInfo.displayName.deformatted()) + .localized() - return "blockBlockedDescription".localized() + case (_, _, _, _, true): + return "groupDeletedMemberDescription" + .put(key: "group_name", value: threadInfo.displayName.deformatted()) + .localized() + + case (_, _, _, true, _): + return "groupRemovedYou" + .put(key: "group_name", value: threadInfo.displayName.deformatted()) + .localized() + + case (_, false, false, _, _): + return "conversationsEmpty" + .put(key: "conversation_name", value: threadInfo.displayName.deformatted()) + .localized() - default: return "blockUnblock".localized() // Should not happen + default: + return "groupNoMessages" + .put(key: "group_name", value: threadInfo.displayName.deformatted()) + .localized() + } } - }() - - // MARK: - Initialization - // TODO: [Database Relocation] Initialise this with the thread data from the home screen (might mean we can avoid some of the `initialData` query? - init( - threadId: String, - threadVariant: SessionThread.Variant, - focusedInteractionInfo: Interaction.TimestampInfo?, - using dependencies: Dependencies - ) { - typealias InitialData = ( - userSessionId: SessionId, - initialUnreadInteractionInfo: Interaction.TimestampInfo?, - threadIsBlocked: Bool, - threadIsMessageRequest: Bool, - closedGroupAdminProfile: Profile?, - currentUserIsClosedGroupMember: Bool?, - currentUserIsClosedGroupAdmin: Bool?, - openGroupPermissions: OpenGroup.Permissions?, - threadWasMarkedUnread: Bool, - currentUserSessionIds: Set - ) - let initialData: InitialData? = dependencies[singleton: .storage].read { db -> InitialData in - let interaction: TypedTableAlias = TypedTableAlias() - let groupMember: TypedTableAlias = TypedTableAlias() - let userSessionId: SessionId = dependencies[cache: .general].sessionId + var legacyGroupsBannerMessage: ThemedAttributedString { + let localizationKey: String - // If we have a specified 'focusedInteractionInfo' then use that, otherwise retrieve the oldest - // unread interaction and start focused around that one - let initialUnreadInteractionInfo: Interaction.TimestampInfo? = try Interaction - .select(.id, .timestampMs) - .filter(interaction[.wasRead] == false) - .filter(interaction[.threadId] == threadId) - .order(interaction[.timestampMs].asc) - .asRequest(of: Interaction.TimestampInfo.self) - .fetchOne(db) - let threadIsBlocked: Bool = (threadVariant != .contact ? false : - try Contact - .filter(id: threadId) - .select(.isBlocked) - .asRequest(of: Bool.self) - .fetchOne(db) - .defaulting(to: false) - ) - let threadIsMessageRequest: Bool = try { - switch threadVariant { + switch threadInfo.groupInfo?.currentUserRole == .admin { + case false: localizationKey = "legacyGroupAfterDeprecationMember" + case true: localizationKey = "legacyGroupAfterDeprecationAdmin" + } + + // FIXME: Strings should be updated in Crowdin to include the {icon} + return LocalizationHelper(template: localizationKey) + .put(key: "date", value: Date(timeIntervalSince1970: 1743631200).formattedForBanner) + .localizedFormatted(baseFont: ConversationViewModel.legacyGroupsBannerFont) + .appending(string: " ") // Designs have a space before the icon + .appending( + Lucide.Icon.squareArrowUpRight + .attributedString(for: ConversationViewModel.legacyGroupsBannerFont) + ) + .appending(string: " ") // In case it's a RTL font + } + + public var messageInputState: InputView.InputState { + guard !threadInfo.isNoteToSelf else { return InputView.InputState(inputs: .all) } + guard !threadInfo.isBlocked else { + return InputView.InputState( + inputs: .disabled, + message: "blockBlockedDescription".localized(), + messageAccessibility: Accessibility( + identifier: "Blocked banner" + ) + ) + } + + // TODO: [BUGFIXING] Need copy for these cases + guard threadInfo.canWrite else { + switch threadInfo.variant { case .contact: - let isApproved: Bool = try Contact - .filter(id: threadId) - .select(.isApproved) - .asRequest(of: Bool.self) - .fetchOne(db) - .defaulting(to: true) - - return !isApproved + return InputView.InputState( + inputs: .disabled, + message: "You cannot send messages to this user." // TODO: [BUGFIXING] blocks community message requests or generic + ) case .group: - let isInvite: Bool = try ClosedGroup - .filter(id: threadId) - .select(.invited) - .asRequest(of: Bool.self) - .fetchOne(db) - .defaulting(to: true) + return InputView.InputState( + inputs: .disabled, + message: "You cannot send messages to this group." + ) - return !isInvite + case .legacyGroup: + return InputView.InputState( + inputs: .disabled, + message: "This group is read-only." + ) - default: return false + case .community: + return InputView.InputState( + inputs: .disabled, + message: "permissionsWriteCommunity".localized() + ) } - }() + } - let closedGroupAdminProfile: Profile? = (threadVariant != .group ? nil : - try Profile - .joining( - required: Profile.groupMembers - .filter(GroupMember.Columns.groupId == threadId) - .filter(GroupMember.Columns.role == GroupMember.Role.admin) - ) - .fetchOne(db) - ) - let currentUserIsClosedGroupAdmin: Bool? = (![.legacyGroup, .group].contains(threadVariant) ? nil : - GroupMember - .filter(groupMember[.groupId] == threadId) - .filter(groupMember[.profileId] == userSessionId.hexString) - .filter(groupMember[.role] == GroupMember.Role.admin) - .isNotEmpty(db) - ) - let currentUserIsClosedGroupMember: Bool? = { - guard [.legacyGroup, .group].contains(threadVariant) else { return nil } - guard currentUserIsClosedGroupAdmin != true else { return true } - - return GroupMember - .filter(groupMember[.groupId] == threadId) - .filter(groupMember[.profileId] == userSessionId.hexString) - .filter(groupMember[.role] == GroupMember.Role.standard) - .isNotEmpty(db) - }() - let openGroupPermissions: OpenGroup.Permissions? = (threadVariant != .community ? nil : - try OpenGroup - .filter(id: threadId) - .select(.permissions) - .asRequest(of: OpenGroup.Permissions.self) - .fetchOne(db) - ) - let threadWasMarkedUnread: Bool = (try? SessionThread - .filter(id: threadId) - .select(.markedAsUnread) - .asRequest(of: Bool.self) - .fetchOne(db)) - .defaulting(to: false) - var currentUserSessionIds: Set = Set([userSessionId.hexString]) + /// Attachments shouldn't be allowed for message requests or if uploads are disabled + let finalInputs: InputView.Input - if - threadVariant == .community, - let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo - .fetchOne(db, id: threadId) - { - currentUserSessionIds = currentUserSessionIds.inserting(SessionThread.getCurrentUserBlindedSessionId( - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded15, - openGroupCapabilityInfo: openGroupCapabilityInfo, - using: dependencies - )?.hexString) - currentUserSessionIds = currentUserSessionIds.inserting(SessionThread.getCurrentUserBlindedSessionId( - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded25, - openGroupCapabilityInfo: openGroupCapabilityInfo, - using: dependencies - )?.hexString) + switch (threadInfo.requiresApproval, threadInfo.isMessageRequest, threadInfo.canUpload) { + case (false, false, true): finalInputs = .all + default: finalInputs = [.text, .attachmentsDisabled, .voiceMessagesDisabled] } - return ( - userSessionId, - initialUnreadInteractionInfo, - threadIsBlocked, - threadIsMessageRequest, - closedGroupAdminProfile, - currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin, - openGroupPermissions, - threadWasMarkedUnread, - currentUserSessionIds + return InputView.InputState( + inputs: finalInputs ) } - self.threadId = threadId - self.initialThreadVariant = threadVariant - self.focusedInteractionInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo) - self.focusBehaviour = (focusedInteractionInfo == nil ? .none : .highlight) - self.initialUnreadInteractionId = initialData?.initialUnreadInteractionInfo?.id - self.internalThreadData = SessionThreadViewModel( - threadId: threadId, - threadVariant: threadVariant, - threadIsNoteToSelf: (initialData?.userSessionId.hexString == threadId), - threadIsMessageRequest: initialData?.threadIsMessageRequest, - threadIsBlocked: initialData?.threadIsBlocked, - closedGroupAdminProfile: initialData?.closedGroupAdminProfile, - currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: initialData?.currentUserIsClosedGroupAdmin, - openGroupPermissions: initialData?.openGroupPermissions, - threadWasMarkedUnread: initialData?.threadWasMarkedUnread, - using: dependencies - ) - .populatingPostQueryData( - recentReactionEmoji: nil, - openGroupCapabilities: nil, - currentUserSessionIds: ( - initialData?.currentUserSessionIds ?? - [dependencies[cache: .general].sessionId.hexString] - ), - wasKickedFromGroup: ( - threadVariant == .group && - dependencies.mutate(cache: .libSession) { cache in - cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) - } - ), - groupIsDestroyed: ( - threadVariant == .group && - dependencies.mutate(cache: .libSession) { cache in - cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) - } - ), - threadCanWrite: true, // Assume true - threadCanUpload: true // Assume true - ) - self.pagedDataObserver = nil - self.dependencies = dependencies - - // Note: Since this references self we need to finish initializing before setting it, we - // also want to skip the initial query and trigger it async so that the push animation - // doesn't stutter (it should load basically immediately but without this there is a - // distinct stutter) - self.pagedDataObserver = self.setupPagedObserver( - for: threadId, - userSessionId: (initialData?.userSessionId ?? dependencies[cache: .general].sessionId), - currentUserSessionIds: ( - initialData?.currentUserSessionIds ?? - [dependencies[cache: .general].sessionId.hexString] - ), - using: dependencies - ) + @MainActor public func sections(viewModel: ConversationViewModel) -> [SectionModel] { + ConversationViewModel.sections(state: self, viewModel: viewModel) + } - // Run the initial query on a background thread so we don't block the push transition - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - // If we don't have a `initialFocusedInfo` then default to `.pageBefore` (it'll query - // from a `0` offset) - switch (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo) { - case .some(let info): self?.pagedDataObserver?.load(.initialPageAround(id: info.id)) - case .none: self?.pagedDataObserver?.load(.pageBefore) + public var observedKeys: Set { + var result: Set = [ + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed), + .loadPage(ConversationViewModel.self), + .updateScreen(ConversationViewModel.self), + .conversationUpdated(threadInfo.id), + .conversationDeleted(threadInfo.id), + .profile(threadInfo.userSessionId.hexString), + .typingIndicator(threadInfo.id), + .messageCreated(threadId: threadInfo.id), + .recentReactionsUpdated + ] + + if SessionId.Prefix.isCommunityBlinded(threadInfo.id) { + result.insert(.anyContactUnblinded) } + + result.insert(contentsOf: threadInfo.observedKeys) + result.insert(contentsOf: Set(itemCache.values.flatMap { $0.observedKeys })) + + return result } - } - - deinit { - // Stop any audio playing when leaving the screen - stopAudio() - } - - // MARK: - Thread Data - - @ThreadSafe private var internalThreadData: SessionThreadViewModel - - /// This value is the current state of the view - public var threadData: SessionThreadViewModel { internalThreadData } - - /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise - /// performance https://github.com/groue/GRDB.swift#valueobservation-performance - /// - /// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static - /// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries - /// - /// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`) - /// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own - /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) - /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this - public typealias ThreadObservation = ValueObservation>>> - public lazy var observableThreadData: ThreadObservation = setupObservableThreadData(for: self.threadId) - - private func setupObservableThreadData(for threadId: String) -> ThreadObservation { - return ObservationBuilderOld - .databaseObservation(dependencies) { [weak self, dependencies] db -> SessionThreadViewModel? in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true) - let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel - .conversationQuery(threadId: threadId, userSessionId: userSessionId) - .fetchOne(db) - let openGroupCapabilities: Set? = (threadViewModel?.threadVariant != .community ? - nil : - try Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == threadViewModel?.openGroupServer?.lowercased()) - .filter(Capability.Columns.isMissing == false) - .asRequest(of: Capability.Variant.self) - .fetchSet(db) + + static func initialState( + threadInfo: ConversationInfoViewModel, + focusedInteractionInfo: Interaction.TimestampInfo?, + currentUserMentionImage: UIImage, + using dependencies: Dependencies + ) -> State { + let dataCache: ConversationDataCache = ConversationDataCache( + userSessionId: dependencies[cache: .general].sessionId, + context: ConversationDataCache.Context( + source: .messageList(threadId: threadInfo.id), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false ) + ) + + return State( + viewState: .loading, + threadInfo: threadInfo, + authMethod: EquatableAuthenticationMethod(value: Authentication.invalid), + currentUserMentionImage: currentUserMentionImage, + isBlindedContact: SessionId.Prefix.isCommunityBlinded(threadInfo.id), + wasPreviouslyBlindedContact: SessionId.Prefix.isCommunityBlinded(threadInfo.id), + focusedInteractionInfo: focusedInteractionInfo, + focusBehaviour: (focusedInteractionInfo == nil ? .none : .highlight), + initialUnreadInteractionInfo: nil, + loadedPageInfo: PagedData.LoadedInfo( + record: Interaction.self, + pageSize: ConversationViewModel.pageSize, + requiredJoinSQL: nil, + filterSQL: MessageViewModel.interactionFilterSQL(threadId: threadInfo.id), + groupSQL: nil, + orderSQL: MessageViewModel.interactionOrderSQL + ), + dataCache: dataCache, + itemCache: [:], + titleViewModel: ConversationTitleViewModel( + threadInfo: threadInfo, + dataCache: dataCache, + using: dependencies + ), + legacyGroupsBannerIsVisible: (threadInfo.variant == .legacyGroup), + reactionsSupported: ( + threadInfo.variant != .legacyGroup && + threadInfo.isMessageRequest != true + ), + recentReactionEmoji: [], + isUserModeratorOrAdmin: false, + shouldShowTypingIndicator: false, + optimisticallyInsertedMessages: [:] + ) + } + + fileprivate static func orderedIdsIncludingOptimisticMessages( + loadedPageInfo: PagedData.LoadedInfo, + optimisticMessages: [Int64: OptimisticMessageData], + dataCache: ConversationDataCache + ) -> [Int64] { + guard !optimisticMessages.isEmpty else { return loadedPageInfo.currentIds } + + /// **Note:** The sorting of `currentIds` is newest to oldest so we need to insert in the same way + var remainingPagedIds: [Int64] = loadedPageInfo.currentIds + var remainingSortedOptimisticMessages: [(Int64, OptimisticMessageData)] = optimisticMessages + .sorted { lhs, rhs in + lhs.value.interaction.timestampMs > rhs.value.interaction.timestampMs + } + var result: [Int64] = [] + + while !remainingPagedIds.isEmpty || !remainingSortedOptimisticMessages.isEmpty { + let nextPaged: Interaction? = remainingPagedIds.first.map { dataCache.interaction(for: $0) } + let nextOptimistic: OptimisticMessageData? = remainingSortedOptimisticMessages.first?.1 - return threadViewModel.map { viewModel -> SessionThreadViewModel in - let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { - guard viewModel.threadVariant == .group else { return (false, false) } - - let sessionId: SessionId = SessionId(.group, hex: viewModel.threadId) - return dependencies.mutate(cache: .libSession) { cache in - ( - cache.wasKickedFromGroup(groupSessionId: sessionId), - cache.groupIsDestroyed(groupSessionId: sessionId) - ) + switch (nextPaged, nextOptimistic) { + case (.some(let paged), .some(let optimistic)): /// Add the newest first and loop + if optimistic.interaction.timestampMs >= paged.timestampMs { + result.append(optimistic.temporaryId) + remainingSortedOptimisticMessages.removeFirst() } - }() - - return viewModel.populatingPostQueryData( - recentReactionEmoji: recentReactionEmoji, - openGroupCapabilities: openGroupCapabilities, - currentUserSessionIds: ( - self?.threadData.currentUserSessionIds ?? - [userSessionId.hexString] - ), - wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed, - threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies), - threadCanUpload: viewModel.determineInitialCanUploadFlag(using: dependencies) - ) + else { + paged.id.map { result.append($0) } + remainingPagedIds.removeFirst() + } + + case (.some, .none): /// No optimistic messages left, add the remaining paged messages + result.append(contentsOf: remainingPagedIds) + remainingPagedIds.removeAll() + + case (.none, .some): /// No paged results left, add the remaining optimistic messages + result.append(contentsOf: remainingSortedOptimisticMessages.map { $0.0 }) + remainingSortedOptimisticMessages.removeAll() + + case (.none, .none): return result /// Invalid case } } - .handleEvents(didFail: { Log.error(.conversation, "Observation failed with error: \($0)") }) - } - - public func updateThreadData(_ updatedData: SessionThreadViewModel) { - self.internalThreadData = updatedData - } - - // MARK: - Interaction Data - - private var lastInteractionIdMarkedAsRead: Int64? = nil - private var lastInteractionTimestampMsMarkedAsRead: Int64 = 0 - public private(set) var unobservedInteractionDataChanges: [SectionModel]? - public private(set) var interactionData: [SectionModel] = [] - public private(set) var reactionExpandedInteractionIds: Set = [] - public private(set) var messageExpandedInteractionIds: Set = [] - public private(set) var pagedDataObserver: PagedDatabaseObserver? - - public var onInteractionChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? { - didSet { - // When starting to observe interaction changes we want to trigger a UI update just in case the - // data was changed while we weren't observing - if let changes: [SectionModel] = self.unobservedInteractionDataChanges { - PagedData.processAndTriggerUpdates( - updatedData: changes, - currentDataRetriever: { [weak self] in self?.interactionData }, - onDataChangeRetriever: { [weak self] in self?.onInteractionChange }, - onUnobservedDataChange: { [weak self] updatedData in - self?.unobservedInteractionDataChanges = updatedData - } - ) - self.unobservedInteractionDataChanges = nil + + return result + } + + fileprivate static func interaction( + at index: Int, + orderedIds: [Int64], + optimisticMessages: [Int64: OptimisticMessageData], + dataCache: ConversationDataCache + ) -> Interaction? { + guard index >= 0, index < orderedIds.count else { return nil } + guard orderedIds[index] >= 0 else { + /// If the `id` is less than `0` then it's an optimistic message + return optimisticMessages[orderedIds[index]]?.interaction } + + return dataCache.interaction(for: orderedIds[index]) } } - public func emptyStateText(for threadData: SessionThreadViewModel) -> String { - let blocksCommunityMessageRequests: Bool = (threadData.profile?.blocksCommunityMessageRequests == true) - - switch (threadData.threadIsNoteToSelf, threadData.threadCanWrite == true, blocksCommunityMessageRequests, threadData.wasKickedFromGroup, threadData.groupIsDestroyed) { - case (true, _, _, _, _): return "noteToSelfEmpty".localized() - case (_, false, true, _, _): - return "messageRequestsTurnedOff" - .put(key: "name", value: threadData.displayName) - .localized() + @Sendable private static func queryState( + previousState: State, + events: [ObservedEvent], + isInitialQuery: Bool, + using dependencies: Dependencies + ) async -> State { + var threadId: String = previousState.threadInfo.id + var threadInfo: ConversationInfoViewModel = previousState.threadInfo + var authMethod: EquatableAuthenticationMethod = previousState.authMethod + var focusedInteractionInfo: Interaction.TimestampInfo? = previousState.focusedInteractionInfo + var initialUnreadInteractionInfo: Interaction.TimestampInfo? = previousState.initialUnreadInteractionInfo + var loadResult: PagedData.LoadResult = previousState.loadedPageInfo.asResult + var dataCache: ConversationDataCache = previousState.dataCache + var itemCache: [MessageViewModel.ID: MessageViewModel] = previousState.itemCache + var reactionsSupported: Bool = previousState.reactionsSupported + var recentReactionEmoji: [String] = previousState.recentReactionEmoji + var isUserModeratorOrAdmin: Bool = previousState.isUserModeratorOrAdmin + var shouldShowTypingIndicator: Bool = false + var optimisticallyInsertedMessages: [Int64: OptimisticMessageData] = previousState.optimisticallyInsertedMessages + + /// Store a local copy of the events so we can manipulate it based on the state changes + var eventsToProcess: [ObservedEvent] = events + var shouldFetchInitialUnreadInteractionInfo: Bool = false + var shouldFetchInitialRecentReactions: Bool = false + + /// If this is the initial query then we need to properly fetch the initial state + if isInitialQuery { + /// Insert a fake event to force the initial page load + eventsToProcess.append(ObservedEvent( + key: .loadPage(ConversationViewModelEvent.self), + value: ( + focusedInteractionInfo.map { LoadPageEvent.initialPageAround(id: $0.id) } ?? + LoadPageEvent.initial + ) + )) - case (_, _, _, _, true): - return "groupDeletedMemberDescription" - .put(key: "group_name", value: threadData.displayName) - .localized() + /// Determine reactions support + switch threadInfo.variant { + case .legacyGroup: + reactionsSupported = false + isUserModeratorOrAdmin = (threadInfo.groupInfo?.currentUserRole == .admin) - case (_, _, _, true, _): - return "groupRemovedYou" - .put(key: "group_name", value: threadData.displayName) - .localized() + case .contact: + reactionsSupported = !threadInfo.isMessageRequest + shouldShowTypingIndicator = await dependencies[singleton: .typingIndicators] + .isRecipientTyping(threadId: threadInfo.id) + + case .group: + reactionsSupported = !threadInfo.isMessageRequest + isUserModeratorOrAdmin = (threadInfo.groupInfo?.currentUserRole == .admin) - case (_, false, false, _, _): - return "conversationsEmpty" - .put(key: "conversation_name", value: threadData.displayName) - .localized() + case .community: + reactionsSupported = await dependencies[singleton: .communityManager].doesOpenGroupSupport( + capability: .reactions, + on: threadInfo.communityInfo?.server + ) + } + + /// Determine whether we need to fetch the initial unread interaction info + shouldFetchInitialUnreadInteractionInfo = (initialUnreadInteractionInfo == nil) + + /// We need to fetch the recent reactions if they are supported + shouldFetchInitialRecentReactions = reactionsSupported - default: - return "groupNoMessages" - .put(key: "group_name", value: threadData.displayName) - .localized() + /// Check if the typing indicator should be visible + shouldShowTypingIndicator = await dependencies[singleton: .typingIndicators].isRecipientTyping( + threadId: threadId + ) } - } - - private func setupPagedObserver( - for threadId: String, - userSessionId: SessionId, - currentUserSessionIds: Set, - using dependencies: Dependencies - ) -> PagedDatabaseObserver { - return PagedDatabaseObserver( - pagedTable: Interaction.self, - pageSize: ConversationViewModel.pageSize, - idColumn: .id, - observedChanges: [ - PagedData.ObservedChanges( - table: Interaction.self, - columns: Interaction.Columns - .allCases - .filter { $0 != .wasRead } - ), - PagedData.ObservedChanges( - table: Attachment.self, - columns: [.state], - joinToPagedType: { - let interaction: TypedTableAlias = TypedTableAlias() - let linkPreview: TypedTableAlias = TypedTableAlias() - let linkPreviewAttachment: TypedTableAlias = TypedTableAlias() - - return SQL(""" - LEFT JOIN \(LinkPreview.self) ON ( - \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral()) - ) - LEFT JOIN \(linkPreviewAttachment) ON \(linkPreviewAttachment[.id]) = \(linkPreview[.attachmentId]) - """ + + /// If there are no events we want to process then just return the current state + guard isInitialQuery || !eventsToProcess.isEmpty else { return previousState } + + /// Split the events between those that need database access and those that don't + var changes: EventChangeset = eventsToProcess.split(by: { $0.handlingStrategy }) + var loadPageEvent: LoadPageEvent? = changes.latestGeneric(.loadPage, as: LoadPageEvent.self) + + /// Need to handle a potential "unblinding" event first since it changes the `threadId` (and then we reload the messages + /// based on the initial paged data query just in case - there isn't a perfect solution to capture the current messages plus any + /// others that may have been added by the merge so do the best we can) + if let event: ContactEvent = changes.latest(.anyContactUnblinded, as: ContactEvent.self) { + switch event.change { + case .unblinded(let blindedId, let unblindedId): + /// Need to handle a potential "unblinding" event first since it changes the `threadId` (and then + /// we reload the messages based on the initial paged data query just in case - there isn't a perfect + /// solution to capture the current messages plus any others that may have been added by the + /// merge so do the best we can) + guard blindedId == threadId else { break } + + threadId = unblindedId + loadResult = loadResult.info + .with(filterSQL: MessageViewModel.interactionFilterSQL(threadId: unblindedId)) + .asResult + loadPageEvent = .initial + eventsToProcess = eventsToProcess + .filter { $0.key.generic != .loadPage } + .appending( + ObservedEvent( + key: .loadPage(ConversationViewModel.self), + value: LoadPageEvent.initial + ) ) - }() - ), - PagedData.ObservedChanges( - table: Contact.self, - columns: [.isTrusted], - joinToPagedType: { - let interaction: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - - return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])") - }() - ), - PagedData.ObservedChanges( - table: Profile.self, - columns: [.displayPictureUrl], - joinToPagedType: { - let interaction: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - - return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])") - }() - ), - PagedData.ObservedChanges( - table: DisappearingMessagesConfiguration.self, - columns: [ .isEnabled, .type, .durationSeconds ], - joinToPagedType: { - let interaction: TypedTableAlias = TypedTableAlias() - let disappearingMessagesConfiguration: TypedTableAlias = TypedTableAlias() - - return SQL("LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfiguration[.threadId]) = \(interaction[.threadId])") - }() + changes = eventsToProcess.split(by: { $0.handlingStrategy }) + + default: break + } + } + + /// Update the context + dataCache.withContext( + source: .messageList(threadId: threadId), + requireFullRefresh: ( + isInitialQuery || + threadInfo.id != threadId || + changes.containsAny( + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed) ) - ], - filterSQL: MessageViewModel.filterSQL(threadId: threadId), - groupSQL: MessageViewModel.groupSQL, - orderSQL: MessageViewModel.orderSQL, - dataQuery: MessageViewModel.baseQuery( - userSessionId: userSessionId, - currentUserSessionIds: currentUserSessionIds, - orderSQL: MessageViewModel.orderSQL, - groupSQL: MessageViewModel.groupSQL ), - associatedRecords: [ - AssociatedRecord( - trackedAgainst: Attachment.self, - observedChanges: [ - PagedData.ObservedChanges( - table: Attachment.self, - columns: [.state] + requireAuthMethodFetch: authMethod.value.isInvalid, + requiresInitialUnreadInteractionInfo: shouldFetchInitialUnreadInteractionInfo, + requireRecentReactionEmojiUpdate: ( + shouldFetchInitialRecentReactions || + changes.contains(.recentReactionsUpdated) + ) + ) + + /// Handle thread specific changes first (as this could include a conversation being unblinded) + switch threadInfo.variant { + case .community: + /// Handle community changes (users could change to mods which would need all of their interaction data updated) + changes.forEach(.communityUpdated, as: CommunityEvent.self) { event in + switch event.change { + case .receivedInitialMessages: + /// If we already have a `loadPageEvent` then that takes prescedence, otherwise we should load + /// the initial page once we've received the initial messages for a community + guard loadPageEvent == nil else { break } + + loadPageEvent = .initial + + case .role, .moderatorsAndAdmins, .capabilities, .permissions: break + } + } + + default: break + } + + /// Then process cache updates + dataCache = await ConversationDataHelper.applyNonDatabaseEvents( + changes, + currentCache: dataCache, + using: dependencies + ) + + /// Then determine the fetch requirements + let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements( + for: changes, + currentCache: dataCache, + itemCache: itemCache, + loadPageEvent: loadPageEvent + ) + + /// Peform any database changes + if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch { + do { + try await dependencies[singleton: .storage].readAsync { db in + /// Fetch the `authMethod` if needed + /// + /// **Note:** It's possible that we won't be able to fetch the `authMethod` (eg. if a group was destroyed or + /// the user was kicked from a group), in that case just fail silently (it's an expected behaviour - won't be able to + /// send requests anymore) + if fetchRequirements.requireAuthMethodFetch { + // TODO: [Database Relocation] Should be able to remove the database requirement now we have the CommunityManager + let maybeAuthMethod: AuthenticationMethod? = try? Authentication.with( + db, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, + using: dependencies ) - ], - dataQuery: MessageViewModel.AttachmentInteractionInfo.baseQuery, - joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL, - associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure() - ), - AssociatedRecord( - trackedAgainst: Reaction.self, - observedChanges: [ - PagedData.ObservedChanges( - table: Reaction.self, - columns: [.count] + + authMethod = EquatableAuthenticationMethod( + value: (maybeAuthMethod ?? Authentication.invalid) ) - ], - dataQuery: MessageViewModel.ReactionInfo.baseQuery, - joinToPagedType: MessageViewModel.ReactionInfo.joinToViewModelQuerySQL, - associateData: MessageViewModel.ReactionInfo.createAssociateDataClosure() - ), - AssociatedRecord( - trackedAgainst: ThreadTypingIndicator.self, - observedChanges: [ - PagedData.ObservedChanges( - table: ThreadTypingIndicator.self, - events: [.insert, .delete], - columns: [] + } + + /// Fetch the `initialUnreadInteractionInfo` if needed + if fetchRequirements.requiresInitialUnreadInteractionInfo { + initialUnreadInteractionInfo = try Interaction + .select(.id, .timestampMs) + .filter(Interaction.Columns.wasRead == false) + .filter(Interaction.Columns.threadId == threadId) + .order(Interaction.Columns.timestampMs.asc) + .asRequest(of: Interaction.TimestampInfo.self) + .fetchOne(db) + } + + if fetchRequirements.requireRecentReactionEmojiUpdate { + recentReactionEmoji = try Emoji.getRecent(db, withDefaultEmoji: true) + } + + /// If we don't have an initial `focusedInteractionInfo` (as determined by the `loadPageEvent.target` + /// being `initial`) then we should default to loading data around the `initialUnreadInteractionInfo` + /// and focusing on it + if + loadPageEvent?.target == .initial, + let initialUnreadInteractionInfo: Interaction.TimestampInfo = initialUnreadInteractionInfo + { + loadPageEvent = .initialPageAround(id: initialUnreadInteractionInfo.id) + focusedInteractionInfo = initialUnreadInteractionInfo + } + + /// Fetch any required data from the cache + (loadResult, dataCache) = try ConversationDataHelper.fetchFromDatabase( + db, + requirements: fetchRequirements, + currentCache: dataCache, + loadResult: loadResult, + loadPageEvent: loadPageEvent, + using: dependencies + ) + } + } catch { + let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") + Log.critical(.conversation, "Failed to fetch state for events [\(eventList)], due to error: \(error)") + } + } + else if !changes.databaseEvents.isEmpty { + Log.warn(.conversation, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") + } + + /// Peform any `libSession` changes + if fetchRequirements.needsAnyFetch { + do { + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) + } + catch { + Log.warn(.conversation, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") + } + } + + /// Update the typing indicator state if needed + changes.forEach(.typingIndicator, as: TypingIndicatorEvent.self) { event in + shouldShowTypingIndicator = (event.change == .started) + } + + /// Handle optimistic messages + changes.forEach(.updateScreen, as: ConversationViewModelEvent.self) { event in + switch event { + case .sendMessage(let data): + optimisticallyInsertedMessages[data.temporaryId] = data + + if let attachments: [Attachment] = data.attachmentData { + dataCache.insert(attachments: attachments) + dataCache.insert( + attachmentMap: [ + data.temporaryId: Set(attachments.enumerated().map { index, attachment in + InteractionAttachment( + albumIndex: index, + interactionId: data.temporaryId, + attachmentId: attachment.id + ) + }) + ] ) - ], - dataQuery: MessageViewModel.TypingIndicatorInfo.baseQuery, - joinToPagedType: MessageViewModel.TypingIndicatorInfo.joinToViewModelQuerySQL, - associateData: MessageViewModel.TypingIndicatorInfo.createAssociateDataClosure() - ), - AssociatedRecord( - trackedAgainst: Quote.self, - observedChanges: [ - PagedData.ObservedChanges( - table: Interaction.self, - columns: [.variant] + } + + if let viewModel: LinkPreviewViewModel = data.linkPreviewViewModel { + dataCache.insert(linkPreviews: [ + LinkPreview( + url: viewModel.urlString, + title: viewModel.title, + attachmentId: nil, /// Can't save to db optimistically + using: dependencies + ) + ]) + } + + case .failedToStoreMessage(let temporaryId): + guard let data: OptimisticMessageData = optimisticallyInsertedMessages[temporaryId] else { + break + } + + optimisticallyInsertedMessages[temporaryId] = OptimisticMessageData( + temporaryId: temporaryId, + interaction: data.interaction.with( + state: .failed, + mostRecentFailureText: "shareExtensionDatabaseError".localized() ), - PagedData.ObservedChanges( - table: Attachment.self, - columns: [.state] - ) - ], - dataQuery: QuoteViewModel.baseQuery( - userSessionId: userSessionId, - currentUserSessionIds: currentUserSessionIds - ), - joinToPagedType: QuoteViewModel.joinToViewModelQuerySQL(), - retrieveRowIdsForReferencedRowIds: QuoteViewModel.createReferencedRowIdsRetriever(), - associateData: QuoteViewModel.createAssociateDataClosure() - ) - ], - onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in - self?.resolveOptimisticUpdates(with: updatedData) + attachmentData: data.attachmentData, + linkPreviewViewModel: data.linkPreviewViewModel, + linkPreviewPreparedAttachment: data.linkPreviewPreparedAttachment, + quoteViewModel: data.quoteViewModel + ) - PagedData.processAndTriggerUpdates( - updatedData: self?.process( - data: updatedData, - for: updatedPageInfo, - optimisticMessages: (self?.optimisticallyInsertedMessages.values) - .map { $0.map { $0.messageViewModel } }, - initialUnreadInteractionId: self?.initialUnreadInteractionId - ), - currentDataRetriever: { self?.interactionData }, - onDataChangeRetriever: { self?.onInteractionChange }, - onUnobservedDataChange: { updatedData in - self?.unobservedInteractionDataChanges = updatedData + case .resolveOptimisticMessage(let temporaryId, let databaseId): + guard dataCache.interaction(for: databaseId) != nil else { + Log.warn(.conversation, "Attempted to resolve an optimistic message but it was missing from the cache") + return } + + optimisticallyInsertedMessages.removeValue(forKey: temporaryId) + dataCache.removeAttachmentMap(for: temporaryId) + itemCache.removeValue(forKey: temporaryId) + } + } + + /// Update the `threadInfo` with the latest `dataCache` + if let thread: SessionThread = dataCache.thread(for: threadId) { + threadInfo = ConversationInfoViewModel( + thread: thread, + dataCache: dataCache, + using: dependencies + ) + } + + /// Update the flag indicating whether reactions are supproted + switch threadInfo.variant { + case .legacyGroup: reactionsSupported = false + case .contact, .group: reactionsSupported = !threadInfo.isMessageRequest + case .community: + reactionsSupported = (threadInfo.communityInfo?.capabilities.contains(.reactions) == true) + isUserModeratorOrAdmin = !dataCache.communityModAdminIds(for: threadId).isDisjoint( + with: dataCache.currentUserSessionIds(for: threadId) ) - }, - using: dependencies + } + + /// Generating the `MessageViewModel` requires both the "preview" and "next" messages that will appear on + /// the screen in order to be generated correctly so we need to iterate over the interactions again - additionally since + /// modifying interactions could impact this clustering behaviour (or ever other cached content), and we add messages + /// optimistically, it's simplest to just fully regenerate the entire `itemCache` and rely on diffing to prevent incorrect changes + let orderedIds: [Int64] = State.orderedIdsIncludingOptimisticMessages( + loadedPageInfo: loadResult.info, + optimisticMessages: optimisticallyInsertedMessages, + dataCache: dataCache + ) + + itemCache = orderedIds.enumerated().reduce(into: [:]) { result, next in + let optimisticMessageId: Int64? + let interaction: Interaction + let reactionInfo: [MessageViewModel.ReactionInfo]? + let maybeUnresolvedQuotedInfo: MessageViewModel.MaybeUnresolvedQuotedInfo? + + /// Source the interaction data from the appropriate location + switch next.element { + case ..<0: /// If the `id` is less than `0` then it's an optimistic message + guard let data: OptimisticMessageData = optimisticallyInsertedMessages[next.element] else { + return + } + + optimisticMessageId = data.temporaryId + interaction = data.interaction + reactionInfo = nil /// Can't react to an optimistic message + maybeUnresolvedQuotedInfo = data.quoteViewModel.map { model -> MessageViewModel.MaybeUnresolvedQuotedInfo? in + guard let interactionId: Int64 = model.quotedInfo?.interactionId else { return nil } + + return MessageViewModel.MaybeUnresolvedQuotedInfo( + foundQuotedInteractionId: interactionId, + resolvedQuotedInteraction: dataCache.interaction(for: interactionId) + ) + } + + default: + guard let targetInteraction: Interaction = dataCache.interaction(for: next.element) else { + return + } + + optimisticMessageId = nil + interaction = targetInteraction + + let reactions: [Reaction] = dataCache.reactions(for: next.element) + + if !reactions.isEmpty { + reactionInfo = reactions.map { reaction in + /// If the reactor is the current user then use the proper profile from the cache (instead of a random + /// blinded one) + let targetId: String = (threadInfo.currentUserSessionIds.contains(reaction.authorId) ? + previousState.userSessionId.hexString : + reaction.authorId + ) + + return MessageViewModel.ReactionInfo( + reaction: reaction, + profile: dataCache.profile(for: targetId) + ) + } + } + else { + reactionInfo = nil + } + + maybeUnresolvedQuotedInfo = dataCache.quoteInfo(for: next.element).map { info in + MessageViewModel.MaybeUnresolvedQuotedInfo( + foundQuotedInteractionId: info.foundQuotedInteractionId, + resolvedQuotedInteraction: info.foundQuotedInteractionId.map { + dataCache.interaction(for: $0) + } + ) + } + } + + result[next.element] = MessageViewModel( + optimisticMessageId: optimisticMessageId, + interaction: interaction, + reactionInfo: reactionInfo, + maybeUnresolvedQuotedInfo: maybeUnresolvedQuotedInfo, + userSessionId: previousState.userSessionId, + threadInfo: threadInfo, + dataCache: dataCache, + previousInteraction: State.interaction( + at: next.offset + 1, /// Order is inverted so `previousInteraction` is the next element + orderedIds: orderedIds, + optimisticMessages: optimisticallyInsertedMessages, + dataCache: dataCache + ), + nextInteraction: State.interaction( + at: next.offset - 1, /// Order is inverted so `nextInteraction` is the previous element + orderedIds: orderedIds, + optimisticMessages: optimisticallyInsertedMessages, + dataCache: dataCache + ), + isLast: ( + /// Order is inverted so we need to check the start of the list + next.offset == 0 && + !loadResult.info.hasPrevPage + ), + isLastOutgoing: ( + /// Order is inverted so we need to check the start of the list + next.element == orderedIds + .prefix(next.offset + 1) /// Want to include the value for `index` in the result + .enumerated() + .compactMap { prefixIndex, _ in + State.interaction( + at: prefixIndex, + orderedIds: orderedIds, + optimisticMessages: optimisticallyInsertedMessages, + dataCache: dataCache + ) + } + .first(where: { threadInfo.currentUserSessionIds.contains($0.authorId) })? + .id + ), + currentUserMentionImage: previousState.currentUserMentionImage, + using: dependencies + ) + } + + return State( + viewState: (loadResult.info.totalCount == 0 ? .empty : .loaded), + threadInfo: threadInfo, + authMethod: authMethod, + currentUserMentionImage: previousState.currentUserMentionImage, + isBlindedContact: SessionId.Prefix.isCommunityBlinded(threadId), + wasPreviouslyBlindedContact: SessionId.Prefix.isCommunityBlinded(previousState.threadId), + focusedInteractionInfo: focusedInteractionInfo, + focusBehaviour: previousState.focusBehaviour, + initialUnreadInteractionInfo: initialUnreadInteractionInfo, + loadedPageInfo: loadResult.info, + dataCache: dataCache, + itemCache: itemCache, + titleViewModel: ConversationTitleViewModel( + threadInfo: threadInfo, + dataCache: dataCache, + using: dependencies + ), + legacyGroupsBannerIsVisible: previousState.legacyGroupsBannerIsVisible, + reactionsSupported: reactionsSupported, + recentReactionEmoji: recentReactionEmoji, + isUserModeratorOrAdmin: isUserModeratorOrAdmin, + shouldShowTypingIndicator: shouldShowTypingIndicator, + optimisticallyInsertedMessages: optimisticallyInsertedMessages ) } - private func process( - data: [MessageViewModel], - for pageInfo: PagedData.PageInfo, - optimisticMessages: [MessageViewModel]?, - initialUnreadInteractionId: Int64? - ) -> [SectionModel] { - let threadData: SessionThreadViewModel = self.internalThreadData - let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true }) - let sortedData: [MessageViewModel] = data - .filter { $0.id != MessageViewModel.optimisticUpdateId } // Remove old optimistic updates - .appending(contentsOf: (optimisticMessages ?? [])) // Insert latest optimistic updates - .filter { !$0.cellType.isPostProcessed } // Remove headers and other - .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } - let threadIsTrusted: Bool = data.contains(where: { $0.threadIsTrusted }) - - // TODO: [Database Relocation] Source profile data via a separate query for efficiency - var currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - - // We load messages from newest to oldest so having a pageOffset larger than zero means - // there are newer pages to load + private static func sections(state: State, viewModel: ConversationViewModel) -> [SectionModel] { + let orderedIds: [Int64] = State.orderedIdsIncludingOptimisticMessages( + loadedPageInfo: state.loadedPageInfo, + optimisticMessages: state.optimisticallyInsertedMessages, + dataCache: state.dataCache + ) + + /// Messages are fetched in decending order (so the message at index `0` is the most recent message), we then render the + /// messages in the reverse order (so the most recent appears at the bottom of the screen) so as a result the `loadOlder` + /// section is based on `hasNextPage` and vice-versa return [ - (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? + (!state.loadedPageInfo.currentIds.isEmpty && state.loadedPageInfo.hasNextPage ? [SectionModel(section: .loadOlder)] : [] ), [ SectionModel( section: .messages, - elements: sortedData - .enumerated() - .map { index, cellViewModel -> MessageViewModel in - cellViewModel.withClusteringChanges( - prevModel: (index > 0 ? sortedData[index - 1] : nil), - nextModel: (index < (sortedData.count - 1) ? sortedData[index + 1] : nil), - isLast: ( - // The database query sorts by timestampMs descending so the "last" - // interaction will actually have a 'pageOffset' of '0' even though - // it's the last element in the 'sortedData' array - index == (sortedData.count - 1) && - pageInfo.pageOffset == 0 - ), - isLastOutgoing: ( - cellViewModel.id == sortedData - .filter { (threadData.currentUserSessionIds ?? []).contains($0.authorId) } - .last? - .id - ), - currentUserSessionIds: (threadData.currentUserSessionIds ?? []), - currentUserProfile: currentUserProfile, - threadIsTrusted: threadIsTrusted, - using: dependencies - ) - } - .reduce([]) { result, message in - let updatedResult: [MessageViewModel] = result - .appending(initialUnreadInteractionId == nil || message.id != initialUnreadInteractionId ? - nil : + elements: orderedIds + .reversed() /// Interactions are loaded from newest to oldest, but we want the newest at the bottom so reverse the result + .compactMap { state.itemCache[$0] } + .reduce(into: []) { result, next in + /// Insert the unread indicator above the first unread message + if next.id == state.initialUnreadInteractionInfo?.id { + result.append( MessageViewModel( - timestampMs: message.timestampMs, - cellType: .unreadMarker + cellType: .unreadMarker, + timestampMs: next.timestampMs ) - ) - - guard message.shouldShowDateHeader else { - return updatedResult.appending(message) + ) } - return updatedResult - .appending( + /// If we should have a date header above this message then add it + if next.shouldShowDateHeader { + result.append( MessageViewModel( - timestampMs: message.timestampMs, - cellType: .dateHeader + cellType: .dateHeader, + timestampMs: next.timestampMs ) ) - .appending(message) + } + + /// Since we've added whatever was needed before the message we can now add it to the result + result.append(next) } - .appending(typingIndicator) + .appending(!state.shouldShowTypingIndicator ? nil : + MessageViewModel.typingIndicator + ) ) ], - (!data.isEmpty && pageInfo.pageOffset > 0 ? + (!state.loadedPageInfo.currentIds.isEmpty && state.loadedPageInfo.hasPrevPage ? [SectionModel(section: .loadNewer)] : [] ) ].flatMap { $0 } } - public func updateInteractionData(_ updatedData: [SectionModel]) { - self.interactionData = updatedData - } - - // MARK: - Optimistic Message Handling + // MARK: - Interaction Data - public typealias OptimisticMessageData = ( - id: UUID, - messageViewModel: MessageViewModel, - interaction: Interaction, - attachmentData: [Attachment]?, - linkPreviewViewModel: LinkPreviewViewModel?, - linkPreviewPreparedAttachment: PreparedAttachment?, - quoteViewModel: QuoteViewModel? - ) + @MainActor public private(set) var reactionExpandedInteractionIds: Set = [] + @MainActor public private(set) var messageExpandedInteractionIds: Set = [] - @ThreadSafeObject private var optimisticallyInsertedMessages: [UUID: OptimisticMessageData] = [:] - @ThreadSafeObject private var optimisticMessageAssociatedInteractionIds: [Int64: UUID] = [:] + // MARK: - Optimistic Message Handling public func optimisticallyAppendOutgoingMessage( text: String?, @@ -726,27 +963,48 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold attachments: [PendingAttachment]?, linkPreviewViewModel: LinkPreviewViewModel?, quoteViewModel: QuoteViewModel? - ) async -> OptimisticMessageData { + ) async throws -> OptimisticMessageData { // Generate the optimistic data - let optimisticMessageId: UUID = UUID() - let threadData: SessionThreadViewModel = self.internalThreadData - let currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + let optimisticMessageId: Int64 = (-Int64.max + sentTimestampMs) /// Unique but avoids collisions with messages + let currentState: State = await self.state + let proMessageFeatures: SessionPro.MessageFeatures = try { + let result: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].messageFeatures( + for: (text ?? "") + ) + + switch result.status { + case .success: return result.features + case .utfDecodingError: + Log.warn(.messageSender, "Failed to extract features for message, falling back to manual handling") + guard (text ?? "").utf16.count > SessionPro.CharacterLimit else { + return .none + } + + return .largerCharacterLimit + + case .exceedsCharacterLimit: throw MessageError.messageTooLarge + } + }() + let proProfileFeatures: SessionPro.ProfileFeatures = dependencies[singleton: .sessionProManager] + .currentUserCurrentProState + .profileFeatures let interaction: Interaction = Interaction( - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - authorId: (threadData.currentUserSessionIds ?? []) + threadId: currentState.threadId, + threadVariant: currentState.threadVariant, + authorId: currentState.threadInfo.currentUserSessionIds .first { $0.hasPrefix(SessionId.Prefix.blinded15.rawValue) } - .defaulting(to: threadData.currentUserSessionId), + .defaulting(to: currentState.userSessionId.hexString), variant: .standardOutgoing, body: text, timestampMs: sentTimestampMs, hasMention: Interaction.isUserMentioned( - publicKeysToCheck: (threadData.currentUserSessionIds ?? []), + publicKeysToCheck: currentState.threadInfo.currentUserSessionIds, body: text ), - expiresInSeconds: threadData.disappearingMessagesConfiguration?.expiresInSeconds(), + expiresInSeconds: currentState.threadInfo.disappearingMessagesConfiguration?.expiresInSeconds(), linkPreviewUrl: linkPreviewViewModel?.urlString, - isProMessage: (text.defaulting(to: "").utf16.count > LibSession.CharacterLimit), + proMessageFeatures: proMessageFeatures, + proProfileFeatures: proProfileFeatures, using: dependencies ) var optimisticAttachments: [Attachment]? @@ -767,324 +1025,295 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) } - // Generate the actual 'MessageViewModel' - let messageViewModel: MessageViewModel = MessageViewModel( - optimisticMessageId: optimisticMessageId, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - threadExpirationType: threadData.disappearingMessagesConfiguration?.type, - threadExpirationTimer: threadData.disappearingMessagesConfiguration?.durationSeconds, - threadOpenGroupServer: threadData.openGroupServer, - threadOpenGroupPublicKey: threadData.openGroupPublicKey, - threadContactNameInternal: threadData.threadContactName(), - timestampMs: interaction.timestampMs, - receivedAtTimestampMs: interaction.receivedAtTimestampMs, - authorId: interaction.authorId, - authorNameInternal: currentUserProfile.displayName(), - body: interaction.body, - expiresStartedAtMs: interaction.expiresStartedAtMs, - expiresInSeconds: interaction.expiresInSeconds, - isProMessage: interaction.isProMessage, - isSenderModeratorOrAdmin: { - switch threadData.threadVariant { - case .group, .legacyGroup: - return (threadData.currentUserIsClosedGroupAdmin == true) - - case .community: - return dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( - publicKey: threadData.currentUserSessionId, - for: threadData.openGroupRoomToken, - on: threadData.openGroupServer, - currentUserSessionIds: (threadData.currentUserSessionIds ?? []) - ) - - default: return false - } - }(), - currentUserProfile: currentUserProfile, - quoteViewModel: quoteViewModel,//MessageViewModel.QuotedInfo(replyModel: quoteModel), - linkPreview: linkPreviewViewModel.map { draft in - LinkPreview( - url: draft.urlString, - title: draft.title, - attachmentId: nil, // Can't save to db optimistically - using: dependencies - ) - }, - linkPreviewAttachment: linkPreviewPreparedAttachment?.attachment, - attachments: optimisticAttachments - ) - let optimisticData: OptimisticMessageData = ( - optimisticMessageId, - messageViewModel, - interaction, - optimisticAttachments, - linkPreviewViewModel, - linkPreviewPreparedAttachment, - quoteViewModel + let optimisticData: OptimisticMessageData = OptimisticMessageData( + temporaryId: optimisticMessageId, + interaction: interaction, + attachmentData: optimisticAttachments, + linkPreviewViewModel: linkPreviewViewModel, + linkPreviewPreparedAttachment: linkPreviewPreparedAttachment, + quoteViewModel: quoteViewModel ) - _optimisticallyInsertedMessages.performUpdate { $0.setting(optimisticMessageId, optimisticData) } - forceUpdateDataIfPossible() + await dependencies.notify( + key: .updateScreen(ConversationViewModel.self), + value: ConversationViewModelEvent.sendMessage(data: optimisticData) + ) return optimisticData } - public func failedToStoreOptimisticOutgoingMessage(id: UUID, error: Error) { - _optimisticallyInsertedMessages.performUpdate { - $0.setting( - id, - $0[id].map { - ( - $0.id, - $0.messageViewModel.with( - state: .set(to: .failed), - mostRecentFailureText: .set(to: "shareExtensionDatabaseError".localized()) - ), - $0.interaction, - $0.attachmentData, - $0.linkPreviewViewModel, - $0.linkPreviewPreparedAttachment, - $0.quoteViewModel - ) - } - ) - } - - forceUpdateDataIfPossible() + public func failedToStoreOptimisticOutgoingMessage(id: Int64, error: Error) async { + await dependencies.notify( + key: .updateScreen(ConversationViewModel.self), + value: ConversationViewModelEvent.failedToStoreMessage(temporaryId: id) + ) } /// Record an association between an `optimisticMessageId` and a specific `interactionId` - public func associate(optimisticMessageId: UUID, to interactionId: Int64?) { + public func associate(_ db: ObservingDatabase, optimisticMessageId: Int64, to interactionId: Int64?) { guard let interactionId: Int64 = interactionId else { return } - _optimisticMessageAssociatedInteractionIds.performUpdate { - $0.setting(interactionId, optimisticMessageId) - } + db.addEvent( + ConversationViewModelEvent.resolveOptimisticMessage( + temporaryId: optimisticMessageId, + databaseId: interactionId + ), + forKey: .updateScreen(ConversationViewModel.self) + ) } - public func optimisticMessageData(for optimisticMessageId: UUID) -> OptimisticMessageData? { - return optimisticallyInsertedMessages[optimisticMessageId] - } + // MARK: - Profiles - /// Remove any optimisticUpdate entries which have an associated interactionId in the provided data - private func resolveOptimisticUpdates(with data: [MessageViewModel]) { - let interactionIds: [Int64] = data.map { $0.id } - let idsToRemove: [UUID] = _optimisticMessageAssociatedInteractionIds - .performUpdateAndMap { associatedIds in - var updatedAssociatedIds: [Int64: UUID] = associatedIds - let result: [UUID] = interactionIds.compactMap { updatedAssociatedIds.removeValue(forKey: $0) } - return (updatedAssociatedIds, result) - } - _optimisticallyInsertedMessages.performUpdate { $0.removingValues(forKeys: idsToRemove) } - } - - private func forceUpdateDataIfPossible() { - // Ensure this is on the main thread as we access properties that could be accessed on other threads - guard Thread.isMainThread else { - return DispatchQueue.main.async { [weak self] in self?.forceUpdateDataIfPossible() } - } - - // If we can't get the current page data then don't bother trying to update (it's not going to work) - guard let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo else { return } - - /// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above - let currentData: [SectionModel] = (unobservedInteractionDataChanges ?? interactionData) - - PagedData.processAndTriggerUpdates( - updatedData: process( - data: (currentData.first(where: { $0.model == .messages })?.elements ?? []), - for: currentPageInfo, - optimisticMessages: optimisticallyInsertedMessages.values.map { $0.messageViewModel }, - initialUnreadInteractionId: initialUnreadInteractionId - ), - currentDataRetriever: { [weak self] in self?.interactionData }, - onDataChangeRetriever: { [weak self] in self?.onInteractionChange }, - onUnobservedDataChange: { [weak self] updatedData in - self?.unobservedInteractionDataChanges = updatedData - } + @MainActor public func displayName(for sessionId: String, inMessageBody: Bool) -> String? { + return state.dataCache.profile(for: sessionId)?.displayName( + includeSessionIdSuffix: (state.threadVariant == .community && inMessageBody) ) } - // MARK: - Mentions - public func mentions(for query: String = "") async throws -> [MentionSelectionView.ViewModel] { - let userSessionId: SessionId = dependencies[cache: .general].sessionId + let state: State = await self.state return try await MentionSelectionView.ViewModel.mentions( for: query, - threadId: self.internalThreadData.threadId, - threadVariant: self.internalThreadData.threadVariant, - currentUserSessionIds: (threadData.currentUserSessionIds ?? [userSessionId.hexString]), - communityInfo: self.internalThreadData.openGroupServer.map { server in - self.internalThreadData.openGroupRoomToken.map { (server: server, roomToken: $0) } + threadId: state.threadId, + threadVariant: state.threadVariant, + currentUserSessionIds: state.threadInfo.currentUserSessionIds, + communityInfo: state.threadInfo.communityInfo.map { info in + (server: info.server, roomToken: info.roomToken) }, using: dependencies ) } + @MainActor public func draftQuote(for viewModel: MessageViewModel) -> QuoteViewModel { + let targetAttachment: Attachment? = ( + viewModel.attachments.first ?? + viewModel.linkPreviewAttachment + ) + + return QuoteViewModel( + mode: .draft, + direction: (viewModel.variant == .standardOutgoing ? .outgoing : .incoming), + quotedInfo: QuoteViewModel.QuotedInfo( + interactionId: viewModel.id, + authorId: viewModel.authorId, + authorName: viewModel.authorName(), + timestampMs: viewModel.timestampMs, + body: viewModel.bubbleBody, + attachmentInfo: targetAttachment?.quoteAttachmentInfo(using: dependencies) + ), + showProBadge: viewModel.profile.proFeatures.contains(.proBadge), /// Quote pro badge is profile data + currentUserSessionIds: viewModel.currentUserSessionIds, + displayNameRetriever: state.dataCache.displayNameRetriever( + for: viewModel.threadId, + includeSessionIdSuffixWhenInMessageBody: (viewModel.threadVariant == .community) + ), + currentUserMentionImage: viewModel.currentUserMentionImage + ) + } + // MARK: - Functions - public func updateDraft(to draft: String) { + @MainActor func loadPageBefore() { + /// We render the messages in the reverse order from the way we fetch them (see `sections`) so as a result when loading + /// the "page before" we _actually_ need to load the `nextPage` + dependencies.notifyAsync( + key: .loadPage(ConversationViewModel.self), + value: LoadPageEvent.nextPage(lastIndex: state.loadedPageInfo.lastIndex) + ) + } + + @MainActor public func loadPageAfter() { + /// We render the messages in the reverse order from the way we fetch them (see `sections`) so as a result when loading + /// the "page after" we _actually_ need to load the `previousPage` + dependencies.notifyAsync( + key: .loadPage(ConversationViewModel.self), + value: LoadPageEvent.previousPage(firstIndex: state.loadedPageInfo.firstIndex) + ) + } + + @MainActor public func jumpToPage(for id: Int64, padding: Int) { + dependencies.notifyAsync( + key: .loadPage(ConversationViewModel.self), + value: LoadPageEvent.jumpTo(id: id, padding: padding) + ) + } + + @MainActor public func updateDraft(to draft: String) { /// Kick off an async process to save the `draft` message to the conversation (don't want to block the UI while doing this, /// worst case the `draft` just won't be saved) - dependencies[singleton: .storage] - .readPublisher { [threadId] db in - try SessionThread - .select(.messageDraft) - .filter(id: threadId) - .asRequest(of: String.self) - .fetchOne(db) - } - .filter { existingDraft -> Bool in draft != existingDraft } - .flatMapStorageWritePublisher(using: dependencies) { [threadId] db, _ in - try SessionThread - .filter(id: threadId) - .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) - } - .sinkUntilComplete() + Task.detached(priority: .userInitiated) { [threadInfo = state.threadInfo, dependencies] in + do { try await threadInfo.updateDraft(draft, using: dependencies) } + catch { Log.error(.conversation, "Failed to update draft due to error: \(error)") } + } } - /// This method indicates whether the client should try to mark the thread or it's messages as read (it's an optimisation for fully read - /// conversations so we can avoid iterating through the visible conversation cells every scroll) - public func shouldTryMarkAsRead() -> Bool { - return ( - (threadData.threadUnreadCount ?? 0) > 0 || - threadData.threadWasMarkedUnread == true - ) + public func markThreadAsRead() async { + let threadInfo: ConversationInfoViewModel = await state.threadInfo + try? await threadInfo.markAsRead(target: .thread, using: dependencies) } /// This method marks a thread as read and depending on the target may also update the interactions within a thread as read - public func markAsRead( - target: SessionThreadViewModel.ReadTarget, - timestampMs: Int64? - ) { + public func markAsReadIfNeeded( + interactionInfo: Interaction.TimestampInfo?, + visibleViewModelRetriever: ((@MainActor () -> [MessageViewModel]?))? + ) async { /// Since this method now gets triggered when scrolling we want to try to optimise it and avoid busying the database /// write queue when it isn't needed, in order to do this we: + /// - Only retrieve the visible message view models if the state suggests there is something that can be marked as read /// - Throttle the updates to 100ms (quick enough that users shouldn't notice, but will help the DB when the user flings the list) /// - Only mark interactions as read if they have newer `timestampMs` or `id` values (ie. were sent later or were more-recent /// entries in the database), **Note:** Old messages will be marked as read upon insertion so shouldn't be an issue /// /// The `ThreadViewModel.markAsRead` method also tries to avoid marking as read if a conversation is already fully read - if markAsReadPublisher == nil { - markAsReadPublisher = markAsReadTrigger - .throttle(for: .milliseconds(100), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true) - .handleEvents( - receiveOutput: { [weak self, dependencies] target, timestampMs in - let threadData: SessionThreadViewModel? = self?.internalThreadData - - switch target { - case .thread: threadData?.markAsRead(target: target, using: dependencies) - case .threadAndInteractions(let interactionId): - guard - timestampMs == nil || - (self?.lastInteractionTimestampMsMarkedAsRead ?? 0) < (timestampMs ?? 0) || - (self?.lastInteractionIdMarkedAsRead ?? 0) < (interactionId ?? 0) - else { - threadData?.markAsRead(target: .thread, using: dependencies) - return - } - - // If we were given a timestamp then update the 'lastInteractionTimestampMsMarkedAsRead' - // to avoid needless updates - if let timestampMs: Int64 = timestampMs { - self?.lastInteractionTimestampMsMarkedAsRead = timestampMs - } - - self?.lastInteractionIdMarkedAsRead = (interactionId ?? threadData?.interactionId) - threadData?.markAsRead(target: target, using: dependencies) - } - } + let needsToMarkAsRead: Bool = await MainActor.run { + guard + state.threadInfo.unreadCount > 0 || + state.threadInfo.wasMarkedUnread + else { return false } + + /// We want to mark messages as read while we scroll, so grab the "newest" visible message and mark everything older as read + let targetInfo: Interaction.TimestampInfo + + if let newestCellViewModel: MessageViewModel = visibleViewModelRetriever?()?.last { + targetInfo = Interaction.TimestampInfo( + id: newestCellViewModel.id, + timestampMs: newestCellViewModel.timestampMs ) - .map { _ in () } - .eraseToAnyPublisher() + } + else if let interactionInfo: Interaction.TimestampInfo = interactionInfo { + /// If we weren't able to get any visible cells for some reason then we should fall back to marking the provided + /// `interactionInfo` as read just in case + targetInfo = interactionInfo + } + else { + /// If we can't get any interaction info then there is nothing to mark as read + return false + } + + /// If we previously marked something as read and it's "newer" than the target info then it should already be read so no + /// need to do anything + if + let oldValue: Interaction.TimestampInfo = lastMarkAsReadInfo, ( + targetInfo.id < oldValue.id || + targetInfo.timestampMs < oldValue.timestampMs + ) + { + return false + } - markAsReadPublisher?.sinkUntilComplete() + /// If we already have pending info to mark as read then no need to trigger another update + if let pendingValue: Interaction.TimestampInfo = pendingMarkAsReadInfo { + /// If the target info is "newer" than the pending info then we should update the pending info so the "newer" value ends + /// up getting marked as read + if targetInfo.id > pendingValue.id || targetInfo.timestampMs > pendingValue.timestampMs { + pendingMarkAsReadInfo = targetInfo + } + + return false + } + + /// If we got here then we do need to mark the target info as read + pendingMarkAsReadInfo = targetInfo + return true } - markAsReadTrigger.send((target, timestampMs)) - } - - public func swapToThread(updatedThreadId: String, focussedMessageId: Int64?) { - self.threadId = updatedThreadId - self.observableThreadData = self.setupObservableThreadData(for: updatedThreadId) - self.pagedDataObserver = self.setupPagedObserver( - for: updatedThreadId, - userSessionId: dependencies[cache: .general].sessionId, - currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString], + /// Only continue if we need to + guard needsToMarkAsRead else { return } + + do { try await Task.sleep(for: .milliseconds(100)) } + catch { return } + + /// Get the latest values + let (threadInfo, pendingInfo): (ConversationInfoViewModel, Interaction.TimestampInfo?) = await MainActor.run { + let result: (ConversationInfoViewModel, Interaction.TimestampInfo?) = ( + state.threadInfo, + pendingMarkAsReadInfo + ) + + /// Immediately clear the pending info so we can mark something else as read while waiting in this message to be marked + /// as read + pendingMarkAsReadInfo = nil + + return result + } + + guard let info: Interaction.TimestampInfo = pendingInfo else { return } + + try? await threadInfo.markAsRead( + target: .threadAndInteractions(interactionsBeforeInclusive: info.id), using: dependencies ) + } + + @MainActor public func trustContact() { + guard state.threadVariant == .contact else { return } - // Try load everything up to the initial visible message, fallback to just the initial page of messages - // if we don't have one - switch focussedMessageId { - case .some(let id): self.pagedDataObserver?.load(.initialPageAround(id: id)) - case .none: self.pagedDataObserver?.load(.pageBefore) + Task.detached(priority: .userInitiated) { [threadId = state.threadId, dependencies] in + try? await dependencies[singleton: .storage].writeAsync { db in + try Contact + .filter(id: threadId) + .updateAll(db, Contact.Columns.isTrusted.set(to: true)) + db.addContactEvent(id: threadId, change: .isTrusted(true)) + + // Start downloading any pending attachments for this contact (UI will automatically be + // updated due to the database observation) + try Attachment + .stateInfo(authorId: threadId, state: .pendingDownload) + .fetchAll(db) + .forEach { attachmentDownloadInfo in + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .attachmentDownload, + threadId: threadId, + interactionId: attachmentDownloadInfo.interactionId, + details: AttachmentDownloadJob.Details( + attachmentId: attachmentDownloadInfo.attachmentId + ) + ), + canStartJob: true + ) + } + } } } - public func trustContact() { - guard self.internalThreadData.threadVariant == .contact else { return } - - dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in - try Contact - .filter(id: threadId) - .updateAll(db, Contact.Columns.isTrusted.set(to: true)) - db.addContactEvent(id: threadId, change: .isTrusted(true)) - - // Start downloading any pending attachments for this contact (UI will automatically be - // updated due to the database observation) - try Attachment - .stateInfo(authorId: threadId, state: .pendingDownload) - .fetchAll(db) - .forEach { attachmentDownloadInfo in - dependencies[singleton: .jobRunner].add( + @MainActor public func unblockContact() { + guard state.threadVariant == .contact else { return } + + Task.detached(priority: .userInitiated) { [threadId = state.threadId, dependencies] in + try? await dependencies[singleton: .storage].writeAsync { db in + try Contact + .filter(id: threadId) + .updateAllAndConfig( db, - job: Job( - variant: .attachmentDownload, - threadId: threadId, - interactionId: attachmentDownloadInfo.interactionId, - details: AttachmentDownloadJob.Details( - attachmentId: attachmentDownloadInfo.attachmentId - ) - ), - canStartJob: true + Contact.Columns.isBlocked.set(to: false), + using: dependencies ) - } - } - } - - public func unblockContact() { - guard self.internalThreadData.threadVariant == .contact else { return } - - dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in - try Contact - .filter(id: threadId) - .updateAllAndConfig( - db, - Contact.Columns.isBlocked.set(to: false), - using: dependencies - ) - db.addContactEvent(id: threadId, change: .isBlocked(false)) + db.addContactEvent(id: threadId, change: .isBlocked(false)) + } } } - public func expandReactions(for interactionId: Int64) { + @MainActor public func expandReactions(for interactionId: Int64) { reactionExpandedInteractionIds.insert(interactionId) } - public func collapseReactions(for interactionId: Int64) { + @MainActor public func collapseReactions(for interactionId: Int64) { reactionExpandedInteractionIds.remove(interactionId) } - public func expandMessage(for interactionId: Int64) { + @MainActor public func expandMessage(for interactionId: Int64) { messageExpandedInteractionIds.insert(interactionId) } - public func deletionActions(for cellViewModels: [MessageViewModel]) -> MessageViewModel.DeletionBehaviours? { - return MessageViewModel.DeletionBehaviours.deletionActions( + @MainActor public func deletionActions(for cellViewModels: [MessageViewModel]) throws -> MessageViewModel.DeletionBehaviours? { + return try MessageViewModel.DeletionBehaviours.deletionActions( for: cellViewModels, - with: self.internalThreadData, + threadInfo: state.threadInfo, + authMethod: state.authMethod.value, + isUserModeratorOrAdmin: state.isUserModeratorOrAdmin, using: dependencies ) } @@ -1114,26 +1343,24 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } } - @ThreadSafeObject private var audioPlayer: OWSAudioPlayer? = nil - @ThreadSafe private var currentPlayingInteraction: Int64? = nil - @ThreadSafeObject private var playbackInfo: [Int64: PlaybackInfo] = [:] + @MainActor private var audioPlayer: OWSAudioPlayer? = nil + @MainActor private var currentPlayingInteraction: Int64? = nil + @MainActor private var playbackInfo: [Int64: PlaybackInfo] = [:] - public func playbackInfo(for viewModel: MessageViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? { + @MainActor public func playbackInfo(for viewModel: MessageViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? { // Use the existing info if it already exists (update it's callback if provided as that means // the cell was reloaded) if let currentPlaybackInfo: PlaybackInfo = playbackInfo[viewModel.id] { let updatedPlaybackInfo: PlaybackInfo = currentPlaybackInfo .with(updateCallback: updateCallback) - - _playbackInfo.performUpdate { $0.setting(viewModel.id, updatedPlaybackInfo) } - + playbackInfo[viewModel.id] = updatedPlaybackInfo return updatedPlaybackInfo } // Validate the item is a valid audio item guard let updateCallback: ((PlaybackInfo?, Error?) -> ()) = updateCallback, - let attachment: Attachment = viewModel.attachments?.first, + let attachment: Attachment = viewModel.attachments.first, attachment.isAudio, attachment.isValid, let path: String = try? dependencies[singleton: .attachmentManager].path(for: attachment.downloadUrl), @@ -1150,20 +1377,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) // Cache the info - _playbackInfo.performUpdate { $0.setting(viewModel.id, newPlaybackInfo) } + playbackInfo[viewModel.id] = newPlaybackInfo return newPlaybackInfo } - public func playOrPauseAudio(for viewModel: MessageViewModel) { - /// Ensure the `OWSAudioPlayer` logic is run on the main thread as it calls `MainAppContext.ensureSleepBlocking` - /// must run on the main thread (also there is no guarantee that `AVAudioPlayer` is thread safe so better safe than sorry) - guard Thread.isMainThread else { - return DispatchQueue.main.sync { [weak self] in self?.playOrPauseAudio(for: viewModel) } - } - + @MainActor public func playOrPauseAudio(for viewModel: MessageViewModel) { guard - let attachment: Attachment = viewModel.attachments?.first, + let attachment: Attachment = viewModel.attachments.first, let filePath: String = try? dependencies[singleton: .attachmentManager].path(for: attachment.downloadUrl), dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { return } @@ -1177,23 +1398,21 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold playbackRate: 1 ) - _audioPlayer.perform { - $0?.playbackRate = 1 - - switch currentPlaybackInfo?.state { - case .playing: $0?.pause() - default: $0?.play() - } + audioPlayer?.playbackRate = 1 + + switch currentPlaybackInfo?.state { + case .playing: audioPlayer?.pause() + default: audioPlayer?.play() } // Update the state and then update the UI with the updated state - _playbackInfo.performUpdate { $0.setting(viewModel.id, updatedPlaybackInfo) } + playbackInfo[viewModel.id] = updatedPlaybackInfo updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) return } // First stop any existing audio - _audioPlayer.perform { $0?.stop() } + audioPlayer?.stop() // Then setup the state for the new audio currentPlayingInteraction = viewModel.id @@ -1202,26 +1421,21 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer // gets deallocated it triggers state changes which cause UI bugs when auto-playing - _audioPlayer.perform { $0?.delegate = nil } - _audioPlayer.set(to: nil) + audioPlayer?.delegate = nil + audioPlayer = nil let newAudioPlayer: OWSAudioPlayer = OWSAudioPlayer( mediaUrl: URL(fileURLWithPath: filePath), audioBehavior: .audioMessagePlayback, - delegate: self + delegate: self, + using: dependencies ) newAudioPlayer.play() - newAudioPlayer.setCurrentTime(currentPlaybackTime ?? 0) - _audioPlayer.set(to: newAudioPlayer) + newAudioPlayer.currentTime = (currentPlaybackTime ?? 0) + audioPlayer = newAudioPlayer } - public func speedUpAudio(for viewModel: MessageViewModel) { - /// Ensure the `OWSAudioPlayer` logic is run on the main thread as it calls `MainAppContext.ensureSleepBlocking` - /// must run on the main thread (also there is no guarantee that `AVAudioPlayer` is thread safe so better safe than sorry) - guard Thread.isMainThread else { - return DispatchQueue.main.sync { [weak self] in self?.speedUpAudio(for: viewModel) } - } - + @MainActor public func speedUpAudio(for viewModel: MessageViewModel) { // If we aren't playing the specified item then just start playing it guard viewModel.id == currentPlayingInteraction else { playOrPauseAudio(for: viewModel) @@ -1232,63 +1446,57 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .with(playbackRate: 1.5) // Speed up the audio player - _audioPlayer.perform { $0?.playbackRate = 1.5 } - - _playbackInfo.performUpdate { $0.setting(viewModel.id, updatedPlaybackInfo) } + audioPlayer?.playbackRate = 1.5 + playbackInfo[viewModel.id] = updatedPlaybackInfo updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) } - public func stopAudioIfNeeded(for viewModel: MessageViewModel) { + @MainActor public func stopAudioIfNeeded(for viewModel: MessageViewModel) { guard viewModel.id == currentPlayingInteraction else { return } stopAudio() } - public func stopAudio() { - /// Ensure the `OWSAudioPlayer` logic is run on the main thread as it calls `MainAppContext.ensureSleepBlocking` - /// must run on the main thread (also there is no guarantee that `AVAudioPlayer` is thread safe so better safe than sorry) - guard Thread.isMainThread else { - return DispatchQueue.main.sync { [weak self] in self?.stopAudio() } - } - - _audioPlayer.perform { $0?.stop() } + @MainActor public func stopAudio() { + audioPlayer?.stop() currentPlayingInteraction = nil // Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer // gets deallocated it triggers state changes which cause UI bugs when auto-playing - _audioPlayer.perform { $0?.delegate = nil } - _audioPlayer.set(to: nil) + audioPlayer?.delegate = nil + audioPlayer = nil } // MARK: - OWSAudioPlayerDelegate - public func audioPlaybackState() -> AudioPlaybackState { - guard let interactionId: Int64 = currentPlayingInteraction else { return .stopped } - - return (playbackInfo[interactionId]?.state ?? .stopped) - } - - public func setAudioPlaybackState(_ state: AudioPlaybackState) { - guard let interactionId: Int64 = currentPlayingInteraction else { return } - - let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[interactionId]? - .with(state: state) - - _playbackInfo.performUpdate { $0.setting(interactionId, updatedPlaybackInfo) } - updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + @MainActor public var audioPlaybackState: AudioPlaybackState { + get { + guard let interactionId: Int64 = currentPlayingInteraction else { return .stopped } + + return (playbackInfo[interactionId]?.state ?? .stopped) + } + set { + guard let interactionId: Int64 = currentPlayingInteraction else { return } + + let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[interactionId]? + .with(state: newValue) + + playbackInfo[interactionId] = updatedPlaybackInfo + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + } } - public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) { + @MainActor public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) { guard let interactionId: Int64 = currentPlayingInteraction else { return } let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[interactionId]? .with(progress: TimeInterval(progress)) - _playbackInfo.performUpdate { $0.setting(interactionId, updatedPlaybackInfo) } + playbackInfo[interactionId] = updatedPlaybackInfo updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) } - public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) { + @MainActor public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) { guard let interactionId: Int64 = currentPlayingInteraction else { return } guard successfully else { return } @@ -1300,28 +1508,28 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) // Safe the changes and send one final update to the UI - _playbackInfo.performUpdate { $0.setting(interactionId, updatedPlaybackInfo) } + playbackInfo[interactionId] = updatedPlaybackInfo updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) // Clear out the currently playing record stopAudio() - // If the next interaction is another voice message then autoplay it + /// If the next interaction is another voice message then autoplay it + /// + /// **Note:** Order is inverted so the next item has an earlier index guard - let messageSection: SectionModel = self.interactionData - .first(where: { $0.model == .messages }), - let currentIndex: Int = messageSection.elements - .firstIndex(where: { $0.id == interactionId }), - currentIndex < (messageSection.elements.count - 1), - messageSection.elements[currentIndex + 1].cellType == .voiceMessage, + let currentIndex: Int = state.loadedPageInfo.currentIds + .firstIndex(where: { $0 == interactionId }), + currentIndex > 0, + let nextItem: MessageViewModel = state.itemCache[state.loadedPageInfo.currentIds[currentIndex - 1]], + nextItem.cellType == .voiceMessage, dependencies.mutate(cache: .libSession, { $0.get(.shouldAutoPlayConsecutiveAudioMessages) }) else { return } - let nextItem: MessageViewModel = messageSection.elements[currentIndex + 1] playOrPauseAudio(for: nextItem) } - public func showInvalidAudioFileAlert() { + @MainActor public func showInvalidAudioFileAlert() { guard let interactionId: Int64 = currentPlayingInteraction else { return } let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[interactionId]? @@ -1332,7 +1540,119 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) stopAudio() - _playbackInfo.performUpdate { $0.setting(interactionId, updatedPlaybackInfo) } + playbackInfo[interactionId] = updatedPlaybackInfo updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, AttachmentError.invalidData) } } + +// MARK: - Convenience + +private extension ObservedEvent { + var handlingStrategy: EventHandlingStrategy { + let threadInfoStrategy: EventHandlingStrategy? = ConversationInfoViewModel.handlingStrategy(for: self) + let messageStrategy: EventHandlingStrategy? = MessageViewModel.handlingStrategy(for: self) + let localStrategy: EventHandlingStrategy = { + switch (key, key.generic) { + case (_, .loadPage): return .databaseQuery + case (.anyMessageCreatedInAnyConversation, _): return .databaseQuery + case (.anyContactBlockedStatusChanged, _): return .databaseQuery + case (.anyContactUnblinded, _): return [.databaseQuery, .directCacheUpdate] + case (.recentReactionsUpdated, _): return .databaseQuery + case (_, .conversationUpdated), (_, .conversationDeleted): return .databaseQuery + case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery + case (_, .attachmentCreated), (_, .attachmentUpdated), (_, .attachmentDeleted): return .databaseQuery + case (_, .reactionsChanged): return .databaseQuery + case (_, .communityUpdated): return [.databaseQuery, .directCacheUpdate] + case (_, .contact): return [.databaseQuery, .directCacheUpdate] + case (_, .profile): return [.databaseQuery, .directCacheUpdate] + case (_, .typingIndicator): return .directCacheUpdate + default: return .directCacheUpdate + } + }() + + return localStrategy + .union(threadInfoStrategy ?? .none) + .union(messageStrategy ?? .none) + } +} + +private extension ConversationTitleViewModel { + init( + threadInfo: ConversationInfoViewModel, + dataCache: ConversationDataCache, + using dependencies: Dependencies + ) { + self.threadVariant = threadInfo.variant + self.displayName = threadInfo.displayName.deformatted() + self.isNoteToSelf = threadInfo.isNoteToSelf + self.isMessageRequest = threadInfo.isMessageRequest + self.showProBadge = (dataCache.profile(for: threadInfo.id)?.proFeatures.contains(.proBadge) == true) + self.isMuted = (dependencies.dateNow.timeIntervalSince1970 <= (threadInfo.mutedUntilTimestamp ?? 0)) + self.onlyNotifyForMentions = threadInfo.onlyNotifyForMentions + self.userCount = threadInfo.userCount + self.disappearingMessagesConfig = threadInfo.disappearingMessagesConfiguration + } +} + +public extension ConversationViewModel { + static func fetchConversationInfo( + threadId: String, + using dependencies: Dependencies + ) async throws -> ConversationInfoViewModel { + return try await dependencies[singleton: .storage].readAsync { [dependencies] db in + try ConversationViewModel.fetchConversationInfo( + db, + threadId: threadId, + using: dependencies + ) + } + } + + static func fetchConversationInfo( + _ db: ObservingDatabase, + threadId: String, + using dependencies: Dependencies + ) throws -> ConversationInfoViewModel { + var dataCache: ConversationDataCache = ConversationDataCache( + userSessionId: dependencies[cache: .general].sessionId, + context: ConversationDataCache.Context( + source: .messageList(threadId: threadId), + requireFullRefresh: true, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ) + let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.FetchRequirements( + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false, + threadIdsNeedingFetch: [threadId] + ) + + dataCache = try ConversationDataHelper.fetchFromDatabase( + db, + requirements: fetchRequirements, + currentCache: dataCache, + using: dependencies + ) + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) + + guard let thread: SessionThread = dataCache.thread(for: threadId) else { + Log.error(.conversation, "Unable to fetch conversation info for thread: \(threadId).") + throw StorageError.objectNotFound + } + + return ConversationInfoViewModel( + thread: thread, + dataCache: dataCache, + using: dependencies + ) + } +} diff --git a/Session/Conversations/Message Cells/CallMessageCell.swift b/Session/Conversations/Message Cells/CallMessageCell.swift index 3dc029571f..33b9be4e90 100644 --- a/Session/Conversations/Message Cells/CallMessageCell.swift +++ b/Session/Conversations/Message Cells/CallMessageCell.swift @@ -173,7 +173,7 @@ final class CallMessageCell: MessageCell { ) infoImageView.isHidden = !shouldShowInfoIcon - label.text = cellViewModel.body + label.text = cellViewModel.bubbleBody // Timer if diff --git a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift index d70572f72b..bb340f3c9a 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift @@ -32,7 +32,7 @@ final class MediaPlaceholderView: UIView { let (iconName, attachmentDescription): (String, String) = { guard cellViewModel.variant == .standardIncoming, - let attachment: Attachment = cellViewModel.attachments?.first + let attachment: Attachment = cellViewModel.attachments.first else { return ( "actionsheet_document_black", // stringlint:ignore diff --git a/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift b/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift index 2c12b12fa8..7459a0dbaa 100644 --- a/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift +++ b/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift @@ -144,8 +144,8 @@ struct LinkPreview_SwiftUI_Previews: PreviewProvider { LinkPreviewView_SwiftUI( viewModel: LinkPreviewViewModel( state: .draft, - urlString: "https://github.com/oxen-io", - title: "Github - oxen-io/session-ios: A private messenger for iOS.", + urlString: "https://github.com/session-foundation", + title: "Github - session-foundation/session-ios: A private messenger for iOS.", imageSource: .image("AppIcon", UIImage(named: "AppIcon")) ), dataManager: ImageDataManager(), @@ -156,7 +156,7 @@ struct LinkPreview_SwiftUI_Previews: PreviewProvider { LinkPreviewView_SwiftUI( viewModel: LinkPreviewViewModel( state: .loading, - urlString: "https://github.com/oxen-io" + urlString: "https://github.com/session-foundation" ), dataManager: ImageDataManager(), isOutgoing: true diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index 91869e19d3..73b065a2e2 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -7,6 +7,7 @@ import SessionUtilitiesKit final class InfoMessageCell: MessageCell { private static let iconSize: CGFloat = 12 + private static let font: UIFont = .systemFont(ofSize: Values.verySmallFontSize) public static let inset = Values.mediumSpacing private var isHandlingLongPress: Bool = false @@ -33,7 +34,7 @@ final class InfoMessageCell: MessageCell { private lazy var label: UILabel = { let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.verySmallFontSize) + result.font = InfoMessageCell.font result.themeTextColor = .textSecondary result.textAlignment = .center result.lineBreakMode = .byWordWrapping @@ -44,7 +45,7 @@ final class InfoMessageCell: MessageCell { private lazy var actionLabel: UILabel = { let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.verySmallFontSize) + result.font = InfoMessageCell.font result.themeTextColor = .primary result.textAlignment = .center result.numberOfLines = 1 @@ -80,6 +81,13 @@ final class InfoMessageCell: MessageCell { // MARK: - Updating + override func prepareForReuse() { + super.prepareForReuse() + + label.font = InfoMessageCell.font + actionLabel.font = InfoMessageCell.font + } + override func update( with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?, @@ -118,9 +126,9 @@ final class InfoMessageCell: MessageCell { iconImageView.themeTintColor = .textSecondary } - self.label.themeAttributedText = cellViewModel.body?.formatted(in: self.label) + self.label.themeAttributedText = cellViewModel.bubbleBody?.formatted(in: self.label) - if cellViewModel.canDoFollowingSetting() { + if cellViewModel.canFollowDisappearingMessagesSetting { self.actionLabel.isHidden = false self.actionLabel.text = "disappearingMessagesFollowSetting".localized() } @@ -178,7 +186,10 @@ final class InfoMessageCell: MessageCell { override func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { guard let cellViewModel: MessageViewModel = self.viewModel else { return } - if cellViewModel.variant == .infoDisappearingMessagesUpdate && cellViewModel.canDoFollowingSetting() { + if + cellViewModel.variant == .infoDisappearingMessagesUpdate && + cellViewModel.canFollowDisappearingMessagesSetting + { delegate?.handleItemTapped(cellViewModel, cell: self, cellLocation: gestureRecognizer.location(in: self)) } } diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index a4de018ff1..61cacf1bee 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 8eabdab1d0..37ae301d4e 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -347,8 +347,7 @@ final class VisibleMessageCell: MessageCell { let profileShouldBeVisible: Bool = ( isGroupThread && cellViewModel.canHaveProfile && - cellViewModel.shouldShowProfile && - cellViewModel.profile != nil + cellViewModel.shouldShowDisplayPicture ) profilePictureView.isHidden = !cellViewModel.canHaveProfile profilePictureView.alpha = (profileShouldBeVisible ? 1 : 0) @@ -383,11 +382,14 @@ final class VisibleMessageCell: MessageCell { bubbleView.isAccessibilityElement = true // Author label - authorLabel.isHidden = (cellViewModel.senderName == nil) - authorLabel.text = cellViewModel.authorNameSuppressedId - authorLabel.extraText = cellViewModel.authorName.replacingOccurrences(of: cellViewModel.authorNameSuppressedId, with: "").trimmingCharacters(in: .whitespacesAndNewlines) + authorLabel.isHidden = !cellViewModel.shouldShowAuthorName + authorLabel.text = cellViewModel.authorName() + authorLabel.extraText = (cellViewModel.threadVariant == .community ? + "(\(cellViewModel.authorId.truncated()))" : /// Show a truncated authorId in Community conversations // stringlint:ignore + nil + ) authorLabel.themeTextColor = .textPrimary - authorLabel.isProBadgeHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: cellViewModel.authorId) } + authorLabel.isProBadgeHidden = !cellViewModel.profile.proFeatures.contains(.proBadge) // Flip horizontally for RTL languages replyIconImageView.transform = CGAffineTransform.identity @@ -405,7 +407,7 @@ final class VisibleMessageCell: MessageCell { } // Reaction view - reactionContainerView.isHidden = (cellViewModel.reactionInfo?.isEmpty != false) + reactionContainerView.isHidden = cellViewModel.reactionInfo.isEmpty populateReaction( for: cellViewModel, maxWidth: VisibleMessageCell.getMaxWidth( @@ -420,24 +422,27 @@ final class VisibleMessageCell: MessageCell { let (image, statusText, tintColor) = cellViewModel.state.statusIconInfo( variant: cellViewModel.variant, hasBeenReadByRecipient: cellViewModel.hasBeenReadByRecipient, - hasAttachments: (cellViewModel.attachments?.isEmpty == false) + hasAttachments: !cellViewModel.attachments.isEmpty ) - messageStatusLabel.text = statusText - messageStatusLabel.themeTextColor = tintColor - messageStatusImageView.image = image - messageStatusLabel.accessibilityIdentifier = "Message sent status: \(statusText ?? "invalid")" - messageStatusImageView.themeTintColor = tintColor - messageStatusStackView.isHidden = ( + let expectedMessageStatusHiddenState: Bool = ( (cellViewModel.expiresInSeconds ?? 0) == 0 && ( !cellViewModel.variant.isOutgoing || cellViewModel.variant.isDeletedMessage || cellViewModel.variant == .infoCall || + cellViewModel.state == .localOnly || ( cellViewModel.state == .sent && !cellViewModel.isLastOutgoing ) ) ) + messageStatusLabel.text = statusText + messageStatusLabel.themeTextColor = tintColor + messageStatusLabel.accessibilityIdentifier = "Message sent status: \(statusText ?? "invalid")" + messageStatusLabel.isHidden = (statusText ?? "").isEmpty + messageStatusImageView.image = image + messageStatusImageView.themeTintColor = tintColor + messageStatusStackView.isHidden = expectedMessageStatusHiddenState // Timer if @@ -457,7 +462,7 @@ final class VisibleMessageCell: MessageCell { } else { timerView.isHidden = true - messageStatusImageView.isHidden = false + messageStatusImageView.isHidden = expectedMessageStatusHiddenState } // Hide the underBubbleStackView if all of it's content is hidden @@ -488,10 +493,7 @@ final class VisibleMessageCell: MessageCell { tableSize: CGSize, using dependencies: Dependencies ) { - let bodyLabelTextColor: ThemeValue = (cellViewModel.variant.isOutgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) + let bodyLabelTextColor: ThemeValue = cellViewModel.bodyTextColor snContentView.alignment = (cellViewModel.variant.isOutgoing ? .trailing : .leading) for subview in snContentView.arrangedSubviews { @@ -572,8 +574,7 @@ final class VisibleMessageCell: MessageCell { for: cellViewModel, with: maxWidth, textColor: bodyLabelTextColor, - searchText: lastSearchText, - using: dependencies + searchText: lastSearchText ) bodyTappableLabelContainer.addSubview(bodyTappableInfo.label) @@ -625,21 +626,7 @@ final class VisibleMessageCell: MessageCell { if let quoteViewModel: QuoteViewModel = cellViewModel.quoteViewModel { let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( - viewModel: quoteViewModel.with( - direction: (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming), - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - showProBadge: dependencies.mutate(cache: .libSession) { - $0.validateSessionProState(for: quoteViewModel.authorId) - }, - thumbnailSource: .thumbnailFrom( - quoteViewModel: quoteViewModel, - using: dependencies - ), - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: cellViewModel.threadVariant, - using: dependencies - ) - ), + viewModel: quoteViewModel, dataManager: dependencies[singleton: .imageDataManager] ) self.quoteView = quoteView @@ -652,8 +639,7 @@ final class VisibleMessageCell: MessageCell { for: cellViewModel, with: maxWidth, textColor: bodyLabelTextColor, - searchText: lastSearchText, - using: dependencies + searchText: lastSearchText ) self.bodyLabel = bodyTappableLabel self.bodyLabelHeight = height @@ -713,7 +699,7 @@ final class VisibleMessageCell: MessageCell { ) let lineHeight: CGFloat = UIFont.systemFont(ofSize: VisibleMessageCell.getFontSize(for: cellViewModel)).lineHeight - switch (cellViewModel.quoteViewModel, cellViewModel.body) { + switch (cellViewModel.quoteViewModel, cellViewModel.bubbleBody) { /// Both quote and body case (.some(let quoteViewModel), .some(let body)) where !body.isEmpty: // Stack view @@ -724,21 +710,7 @@ final class VisibleMessageCell: MessageCell { // Quote view let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( - viewModel: quoteViewModel.with( - direction: (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming), - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - showProBadge: dependencies.mutate(cache: .libSession) { - $0.validateSessionProState(for: quoteViewModel.authorId) - }, - thumbnailSource: .thumbnailFrom( - quoteViewModel: quoteViewModel, - using: dependencies - ), - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: cellViewModel.threadVariant, - using: dependencies - ) - ), + viewModel: quoteViewModel, dataManager: dependencies[singleton: .imageDataManager] ) self.quoteView = quoteView @@ -750,8 +722,7 @@ final class VisibleMessageCell: MessageCell { for: cellViewModel, with: maxWidth, textColor: bodyLabelTextColor, - searchText: lastSearchText, - using: dependencies + searchText: lastSearchText ) self.bodyLabel = bodyTappableLabel self.bodyLabelHeight = height @@ -783,8 +754,7 @@ final class VisibleMessageCell: MessageCell { for: cellViewModel, with: maxWidth, textColor: bodyLabelTextColor, - searchText: lastSearchText, - using: dependencies + searchText: lastSearchText ) self.bodyLabel = bodyTappableLabel @@ -809,21 +779,7 @@ final class VisibleMessageCell: MessageCell { /// Just quote case (.some(let quoteViewModel), _): let quoteView: QuoteView = QuoteView( - viewModel: quoteViewModel.with( - direction: (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming), - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - showProBadge: dependencies.mutate(cache: .libSession) { - $0.validateSessionProState(for: quoteViewModel.authorId) - }, - thumbnailSource: .thumbnailFrom( - quoteViewModel: quoteViewModel, - using: dependencies - ), - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: cellViewModel.threadVariant, - using: dependencies - ) - ), + viewModel: quoteViewModel, dataManager: dependencies[singleton: .imageDataManager] ) self.quoteView = quoteView @@ -853,9 +809,7 @@ final class VisibleMessageCell: MessageCell { cellWidth: tableSize.width ) let albumView = MediaAlbumView( - items: (cellViewModel.attachments? - .filter { $0.isVisualMedia }) - .defaulting(to: []), + items: cellViewModel.attachments.filter { $0.isVisualMedia }, isOutgoing: cellViewModel.variant.isOutgoing, maxMessageWidth: maxMessageWidth, using: dependencies @@ -869,7 +823,7 @@ final class VisibleMessageCell: MessageCell { snContentView.addArrangedSubview(albumView) case .voiceMessage: - guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else { + guard let attachment: Attachment = cellViewModel.attachments.first(where: { $0.isAudio }) else { return } @@ -885,7 +839,7 @@ final class VisibleMessageCell: MessageCell { addViewWrappingInBubbleIfNeeded(voiceMessageView) case .audio, .genericAttachment: - guard let attachment: Attachment = cellViewModel.attachments?.first else { preconditionFailure() } + guard let attachment: Attachment = cellViewModel.attachments.first else { return } // Document view let documentView = DocumentView(attachment: attachment, textColor: bodyLabelTextColor) @@ -899,13 +853,13 @@ final class VisibleMessageCell: MessageCell { maxWidth: CGFloat, showExpandedReactions: Bool ) { - let reactions: OrderedDictionary = (cellViewModel.reactionInfo ?? []) + let reactions: OrderedDictionary = cellViewModel.reactionInfo .reduce(into: OrderedDictionary()) { result, reactionInfo in guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else { return } - let isSelfSend: Bool = (cellViewModel.currentUserSessionIds ?? []).contains(reactionInfo.reaction.authorId) + let isSelfSend: Bool = cellViewModel.currentUserSessionIds.contains(reactionInfo.reaction.authorId) if let value: ReactionViewModel = result.value(forKey: emoji) { result.replace( @@ -965,7 +919,7 @@ final class VisibleMessageCell: MessageCell { switch cellViewModel.cellType { case .voiceMessage: - guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else { + guard let attachment: Attachment = cellViewModel.attachments.first(where: { $0.isAudio }) else { return } @@ -1087,11 +1041,11 @@ final class VisibleMessageCell: MessageCell { let location = gestureRecognizer.location(in: self) let tappedAuthorName: Bool = ( authorLabel.bounds.contains(authorLabel.convert(location, from: self)) && - !(cellViewModel.senderName ?? "").isEmpty + !cellViewModel.authorName().isEmpty ) let tappedProfilePicture: Bool = ( profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)) && - cellViewModel.shouldShowProfile + cellViewModel.shouldShowDisplayPicture ) if tappedAuthorName || tappedProfilePicture { @@ -1232,9 +1186,9 @@ final class VisibleMessageCell: MessageCell { private static func getFontSize(for cellViewModel: MessageViewModel) -> CGFloat { let baselineFontSize = Values.mediumFontSize - guard cellViewModel.containsOnlyEmoji == true else { return baselineFontSize } + guard cellViewModel.containsOnlyEmoji else { return baselineFontSize } - switch (cellViewModel.glyphCount ?? 0) { + switch cellViewModel.glyphCount { case 1: return baselineFontSize + 30 case 2: return baselineFontSize + 24 case 3, 4, 5: return baselineFontSize + 18 @@ -1247,10 +1201,7 @@ final class VisibleMessageCell: MessageCell { } private func getSize(for cellViewModel: MessageViewModel, tableSize: CGSize) -> CGSize { - guard let mediaAttachments: [Attachment] = cellViewModel.attachments?.filter({ $0.isVisualMedia }) else { - preconditionFailure() - } - + let mediaAttachments: [Attachment] = cellViewModel.attachments.filter({ $0.isVisualMedia }) let maxMessageWidth = VisibleMessageCell.getMaxWidth( for: cellViewModel, cellWidth: tableSize.width @@ -1323,27 +1274,24 @@ final class VisibleMessageCell: MessageCell { static func getBodyAttributedText( for cellViewModel: MessageViewModel, textColor: ThemeValue, - searchText: String?, - using dependencies: Dependencies + searchText: String? ) -> ThemedAttributedString? { guard - let body: String = cellViewModel.body, + let body: String = cellViewModel.bubbleBody, !body.isEmpty else { return nil } let isOutgoing: Bool = (cellViewModel.variant == .standardOutgoing) - let attributedText: ThemedAttributedString = MentionUtilities.highlightMentions( - in: body, - threadVariant: cellViewModel.threadVariant, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - location: (isOutgoing ? .outgoingMessage : .incomingMessage), - textColor: textColor, - attributes: [ - .themeForegroundColor: textColor, - .font: UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)) - ], - using: dependencies - ) + let attributedText: ThemedAttributedString = body + .formatted( + baseFont: .systemFont(ofSize: getFontSize(for: cellViewModel)), + attributes: [.themeForegroundColor: textColor], + mentionColor: MentionUtilities.mentionColor( + textColor: textColor, + location: (isOutgoing ? .outgoingMessage : .incomingMessage) + ), + currentUserMentionImage: cellViewModel.currentUserMentionImage + ) // Custom handle links let links: [URL: NSRange] = { @@ -1355,23 +1303,25 @@ final class VisibleMessageCell: MessageCell { // NSAttributedString and NSRange are both based on UTF-16 encoded lengths, so // in order to avoid strings which contain emojis breaking strings which end // with URLs we need to use the 'String.utf16.count' value when creating the range + let rawString: String = attributedText.string + return detector .matches( - in: attributedText.string, + in: rawString, options: [], - range: NSRange(location: 0, length: attributedText.string.utf16.count) + range: NSRange(location: 0, length: rawString.utf16.count) ) .reduce(into: [:]) { result, match in guard let matchUrl: URL = match.url, - let originalRange: Range = Range(match.range, in: attributedText.string) + let originalRange: Range = Range(match.range, in: rawString) else { return } /// If the URL entered didn't have a scheme it will default to 'http', we want to catch this and /// set the scheme to 'https' instead as we don't load previews for 'http' so this will result /// in more previews actually getting loaded without forcing the user to enter 'https://' before /// every URL they enter - let originalString: String = String(attributedText.string[originalRange]) + let originalString: String = String(rawString[originalRange]) guard matchUrl.absoluteString != "http://\(originalString)" else { guard let httpsUrl: URL = URL(string: "https://\(originalString)") else { @@ -1401,46 +1351,15 @@ final class VisibleMessageCell: MessageCell { // If there is a valid search term then highlight each part that matched if let searchText = searchText, searchText.count >= ConversationSearchController.minimumSearchTextLength { - let normalizedBody: String = attributedText.string.lowercased() + let ranges: [NSRange] = GlobalSearch.ranges( + for: searchText, + in: attributedText.string + ) - SessionThreadViewModel.searchTermParts(searchText) - .map { part -> String in - guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } - - let partRange = (part.index(after: part.startIndex).. = { - let term: String = String(normalizedBody[range]) - - // If the matched term doesn't actually match the "part" value then it means - // we've matched a term after a non-alphanumeric character so need to shift - // the range over by 1 - guard term.starts(with: part.lowercased()) else { - return (normalizedBody.index(after: range.lowerBound).. (label: LinkHighlightingLabel, height: CGFloat) { let attributedText: ThemedAttributedString? = VisibleMessageCell.getBodyAttributedText( for: cellViewModel, textColor: textColor, - searchText: searchText, - using: dependencies + searchText: searchText ) let result: LinkHighlightingLabel = LinkHighlightingLabel() result.setContentHugging(.vertical, to: .required) diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift index ff45656e18..7482436628 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift @@ -20,8 +20,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga private let threadId: String private let threadVariant: SessionThread.Variant private var isNoteToSelf: Bool - private let currentUserIsClosedGroupMember: Bool? - private let currentUserIsClosedGroupAdmin: Bool? + private let currentUserRole: GroupMember.Role? private let originalConfig: DisappearingMessagesConfiguration private var configSubject: CurrentValueSubject @@ -30,8 +29,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga init( threadId: String, threadVariant: SessionThread.Variant, - currentUserIsClosedGroupMember: Bool?, - currentUserIsClosedGroupAdmin: Bool?, + currentUserRole: GroupMember.Role?, config: DisappearingMessagesConfiguration, using dependencies: Dependencies ) { @@ -39,8 +37,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga self.threadId = threadId self.threadVariant = threadVariant self.isNoteToSelf = (threadId == dependencies[cache: .general].sessionId.hexString) - self.currentUserIsClosedGroupMember = currentUserIsClosedGroupMember - self.currentUserIsClosedGroupAdmin = currentUserIsClosedGroupAdmin + self.currentUserRole = currentUserRole self.originalConfig = config self.configSubject = CurrentValueSubject(config) } @@ -271,7 +268,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga ), isEnabled: ( isNoteToSelf || - currentUserIsClosedGroupAdmin == true + currentUserRole == .admin ), accessibility: Accessibility( identifier: "Disable disappearing messages (Off option)", @@ -306,7 +303,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga identifier: "\(title) - Radio" ) ), - isEnabled: (isNoteToSelf || currentUserIsClosedGroupAdmin == true), + isEnabled: (isNoteToSelf || currentUserRole == .admin), accessibility: Accessibility( identifier: "Time option", label: "Time option" @@ -357,7 +354,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga // Update the local state try updatedConfig.upserted(db) - let currentOffsetTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let currentOffsetTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let interactionId = try updatedConfig .upserted(db) .insertControlMessage( @@ -396,8 +393,15 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga threadVariant: threadVariant, using: dependencies ) + + /// Notify of update + db.addConversationEvent( + id: threadId, + variant: threadVariant, + type: .updated(.disappearingMessageConfiguration(updatedConfig)) + ) } } } -extension String: Differentiable {} +extension String: @retroactive Differentiable {} diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index e0666d1e93..5b168f0e45 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -13,14 +13,20 @@ import SignalUtilitiesKit import SessionUtilitiesKit import SessionNetworkingKit +// MARK: - Log.Category + +public extension Log.Category { + static let threadSettingsViewModel: Log.Category = .create("ThreadSettingsViewModel", defaultLevel: .warn) +} + +// MARK: - ThreadSettingsViewModel + class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource { public let dependencies: Dependencies public let navigatableState: NavigatableState = NavigatableState() public let state: TableDataState = TableDataState() public let observableState: ObservableTableSourceState = ObservableTableSourceState() - private let threadId: String - private let threadVariant: SessionThread.Variant private let didTriggerSearch: () -> () private var updatedName: String? private var updatedDescription: String? @@ -32,31 +38,70 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi }, using: dependencies ) - private var profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?) - // TODO: Refactor this with SessionThreadViewModel - private var threadViewModelSubject: CurrentValueSubject + + /// This value is the current state of the view + @MainActor @Published private(set) var internalState: State + private var observationTask: Task? // MARK: - Initialization - init( - threadId: String, - threadVariant: SessionThread.Variant, + @MainActor init( + threadInfo: ConversationInfoViewModel, didTriggerSearch: @escaping () -> (), using dependencies: Dependencies ) { self.dependencies = dependencies - self.threadId = threadId - self.threadVariant = threadVariant self.didTriggerSearch = didTriggerSearch - self.threadViewModelSubject = CurrentValueSubject(nil) - self.profileImageStatus = (previous: nil, current: .normal) + self.internalState = State.initialState(threadInfo: threadInfo, using: dependencies) + self.observationTask = nil + + /// Bind the state + self.observationTask = ObservationBuilder + .initialValue(self.internalState) + .debounce(for: .milliseconds(10)) /// Changes trigger multiple events at once so debounce them + .using(dependencies: dependencies) + .query(ThreadSettingsViewModel.queryState) + .assign { [weak self] updatedState in + guard let self = self else { return } + + // FIXME: To slightly reduce the size of the changes this new observation mechanism is currently wired into the old SessionTableViewController observation mechanism, we should refactor it so everything uses the new mechanism + self.internalState = updatedState + self.pendingTableDataSubject.send(updatedState.sections(viewModel: self)) + } } // MARK: - Config - enum ProfileImageStatus: Equatable { - case normal - case expanded - case qrCode + + enum ProfileImageStatus: Equatable, Hashable { + case image(expanded: Bool) + case qrCode(expanded: Bool) + + var isQRCode: Bool { + switch self { + case .image: return false + case .qrCode: return true + } + } + + var isExpanded: Bool { + switch self { + case .image(let expanded), .qrCode(let expanded): return expanded + } + } + + func toggleState() -> ProfileImageStatus { + switch self { + case .image(let expanded): return .qrCode(expanded: expanded) + case .qrCode(let expanded): return .image(expanded: expanded) + } + } + + func toggleExpansion() -> ProfileImageStatus { + switch self { + case .image(let expanded): return .image(expanded: !expanded) + case .qrCode(let expanded): return .qrCode(expanded: !expanded) + } + } } enum NavItem: Equatable { @@ -121,20 +166,75 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case debugDeleteAttachmentsBeforeNow } - lazy var rightNavItems: AnyPublisher<[SessionNavItem], Never> = threadViewModelSubject - .map { [weak self] threadViewModel -> [SessionNavItem] in - guard let threadViewModel: SessionThreadViewModel = threadViewModel else { return [] } - - let currentUserIsClosedGroupAdmin: Bool = ( - [.legacyGroup, .group].contains(threadViewModel.threadVariant) && - threadViewModel.currentUserIsClosedGroupAdmin == true + public struct ThreadSettingsViewModelEvent: Hashable { + let profileImageStatus: ProfileImageStatus + } + + // MARK: - State + + public struct State: ObservableKeyProvider { + let profileImageStatus: ProfileImageStatus + + let threadInfo: ConversationInfoViewModel + let dataCache: ConversationDataCache + + @MainActor public func sections(viewModel: ThreadSettingsViewModel) -> [SectionModel] { + ThreadSettingsViewModel.sections(state: self, viewModel: viewModel) + } + + public var observedKeys: Set { + var result: Set = [ + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed), + .updateScreen(ThreadSettingsViewModel.self), + .conversationUpdated(threadInfo.id), + .conversationDeleted(threadInfo.id), + .profile(threadInfo.userSessionId.hexString), + .typingIndicator(threadInfo.id), + .messageCreated(threadId: threadInfo.id), + .recentReactionsUpdated + ] + + if SessionId.Prefix.isCommunityBlinded(threadInfo.id) { + result.insert(.anyContactUnblinded) + } + + result.insert(contentsOf: threadInfo.observedKeys) + + return result + } + + static func initialState( + threadInfo: ConversationInfoViewModel, + using dependencies: Dependencies + ) -> State { + let dataCache: ConversationDataCache = ConversationDataCache( + userSessionId: dependencies[cache: .general].sessionId, + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: threadInfo.id), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) ) + return State( + profileImageStatus: .image(expanded: false), + threadInfo: threadInfo, + dataCache: dataCache + ) + } + } + + lazy var rightNavItems: AnyPublisher<[SessionNavItem], Never> = $internalState + .map { [weak self] state -> [SessionNavItem] in let canEditDisplayName: Bool = ( - threadViewModel.threadIsNoteToSelf != true && + !state.threadInfo.isNoteToSelf && ( - threadViewModel.threadVariant == .contact || - currentUserIsClosedGroupAdmin + state.threadInfo.variant == .contact || + state.threadInfo.groupInfo?.currentUserRole == .admin ) ) @@ -148,12 +248,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi style: .plain, accessibilityIdentifier: "Edit Nickname", action: { [weak self] in - guard - let info: ConfirmationModal.Info = self?.updateDisplayNameModal( - threadViewModel: threadViewModel, - currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin - ) - else { return } + guard let info: ConfirmationModal.Info = self?.updateDisplayNameModal(state: state) else { + return + } self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) } @@ -164,76 +261,122 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Content - private struct State: Equatable { - let threadViewModel: SessionThreadViewModel? - let disappearingMessagesConfig: DisappearingMessagesConfiguration - } - - var title: String { - switch threadVariant { + @MainActor var title: String { + switch internalState.threadInfo.variant { case .contact: return "sessionSettings".localized() case .legacyGroup, .group, .community: return "deleteAfterGroupPR1GroupSettings".localized() } } - lazy var observation: TargetObservation = ObservationBuilderOld - .databaseObservation(self) { [ weak self, dependencies, threadId = self.threadId] db -> State in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - var threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel - .conversationSettingsQuery(threadId: threadId, userSessionId: userSessionId) - .fetchOne(db) - let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration - .fetchOne(db, id: threadId) - .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) - - self?.threadViewModelSubject.send(threadViewModel) - - return State( - threadViewModel: threadViewModel, - disappearingMessagesConfig: disappearingMessagesConfig + @Sendable private static func queryState( + previousState: State, + events: [ObservedEvent], + isInitialQuery: Bool, + using dependencies: Dependencies + ) async -> State { + var profileImageStatus: ProfileImageStatus = previousState.profileImageStatus + var threadInfo: ConversationInfoViewModel = previousState.threadInfo + var dataCache: ConversationDataCache = previousState.dataCache + + /// If there are no events we want to process then just return the current state + guard isInitialQuery || !events.isEmpty else { return previousState } + + /// Split the events between those that need database access and those that don't + let changes: EventChangeset = events.split(by: { $0.handlingStrategy }) + + /// Update the context + dataCache.withContext( + source: .conversationSettings(threadId: threadInfo.id), + requireFullRefresh: ( + isInitialQuery || + changes.containsAny( + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed) + ) ) + ) + + /// Process cache updates first + dataCache = await ConversationDataHelper.applyNonDatabaseEvents( + changes, + currentCache: dataCache, + using: dependencies + ) + + /// Then determine the fetch requirements + let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements( + for: changes, + currentCache: dataCache, + itemCache: [threadInfo.id: threadInfo], + loadPageEvent: nil + ) + + /// Peform any database changes + if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch { + do { + try await dependencies[singleton: .storage].readAsync { db in + /// Fetch any required data from the cache + dataCache = try ConversationDataHelper.fetchFromDatabase( + db, + requirements: fetchRequirements, + currentCache: dataCache, + using: dependencies + ) + } + } catch { + let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") + Log.critical(.threadSettingsViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)") + } } - .compactMap { [weak self] current -> [SectionModel]? in - self?.content( - current, - profileImageStatus: self?.profileImageStatus - ) + else if !changes.databaseEvents.isEmpty { + Log.warn(.threadSettingsViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") } - - private func content(_ current: State, profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?)?) -> [SectionModel] { - // If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted - // so dismiss the screen - guard let threadViewModel: SessionThreadViewModel = current.threadViewModel else { - self.dismissScreen(type: .popToRoot) - return [] + + /// Peform any `libSession` changes + if fetchRequirements.needsAnyFetch { + do { + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) + } + catch { + Log.warn(.threadSettingsViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") + } } - let isGroup: Bool = ( - threadViewModel.threadVariant == .legacyGroup || - threadViewModel.threadVariant == .group - ) - let currentUserKickedFromGroup: Bool = ( - isGroup && - threadViewModel.currentUserIsClosedGroupMember != true - ) + if let updatedValue: ThreadSettingsViewModelEvent = changes.latestGeneric(.updateScreen, as: ThreadSettingsViewModelEvent.self) { + profileImageStatus = updatedValue.profileImageStatus + } - let currentUserIsClosedGroupMember: Bool = ( - isGroup && - threadViewModel.currentUserIsClosedGroupMember == true - ) - let currentUserIsClosedGroupAdmin: Bool = ( - isGroup && - threadViewModel.currentUserIsClosedGroupAdmin == true + /// Regenerate the `threadInfo` now that the `dataCache` is updated + if let thread: SessionThread = dataCache.thread(for: threadInfo.id) { + threadInfo = ConversationInfoViewModel( + thread: thread, + dataCache: dataCache, + using: dependencies + ) + } + + /// Generate the new state + return State( + profileImageStatus: profileImageStatus, + threadInfo: threadInfo, + dataCache: dataCache ) + } + + private static func sections(state: State, viewModel: ThreadSettingsViewModel) -> [SectionModel] { + let threadDisplayName: String = state.threadInfo.displayName.deformatted() let isThreadHidden: Bool = ( - threadViewModel.threadShouldBeVisible != true && - threadViewModel.threadPinnedPriority == LibSession.hiddenPriority + !state.threadInfo.shouldBeVisible || + state.threadInfo.pinnedPriority == LibSession.hiddenPriority ) - let showThreadPubkey: Bool = ( - threadViewModel.threadVariant == .contact || ( - threadViewModel.threadVariant == .group && - dependencies[feature: .groupsShowPubkeyInConversationSettings] + state.threadInfo.variant == .contact || ( + state.threadInfo.variant == .group && + viewModel.dependencies[feature: .groupsShowPubkeyInConversationSettings] ) ) // MARK: - Conversation Info @@ -241,11 +384,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let conversationInfoSection: SectionModel = SectionModel( model: .conversationInfo, elements: [ - (profileImageStatus?.current == .qrCode ? + (state.profileImageStatus.isQRCode ? SessionCell.Info( id: .qrCode, accessory: .qrCode( - for: threadViewModel.getQRCodeString(), + for: state.threadInfo.qrCodeString, hasBackground: false, logo: "SessionWhite40", // stringlint:ignore themeStyle: ThemeManager.currentTheme.interfaceStyle @@ -255,14 +398,19 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi customPadding: SessionCell.Padding(bottom: Values.smallSpacing), backgroundStyle: .noBackground ), - onTapView: { [weak self] targetView in + onTapView: { [weak viewModel, dependencies = viewModel.dependencies] targetView in let didTapProfileIcon: Bool = !(targetView is UIImageView) if didTapProfileIcon { - self?.profileImageStatus = (previous: profileImageStatus?.current, current: profileImageStatus?.previous) - self?.forceRefresh(type: .postDatabaseQuery) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(ThreadSettingsViewModel.self), + value: ThreadSettingsViewModelEvent( + profileImageStatus: state.profileImageStatus.toggleState() + ) + ) } else { - self?.showQRCodeLightBox(for: threadViewModel) + viewModel?.showQRCodeLightBox(for: state.threadInfo) } } ) @@ -270,13 +418,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi SessionCell.Info( id: .avatar, accessory: .profile( - id: threadViewModel.id, - size: (profileImageStatus?.current == .expanded ? .expanded : .hero), - threadVariant: threadViewModel.threadVariant, - displayPictureUrl: threadViewModel.threadDisplayPictureUrl, - profile: threadViewModel.profile, - profileIcon: (threadViewModel.threadIsNoteToSelf || threadVariant == .group ? .none : .qrCode), - additionalProfile: threadViewModel.additionalProfile, + id: state.threadInfo.id, + size: (state.profileImageStatus.isExpanded ? .expanded : .hero), + threadVariant: state.threadInfo.variant, + displayPictureUrl: state.threadInfo.displayPictureUrl, + profile: state.threadInfo.profile, + profileIcon: (state.threadInfo.isNoteToSelf || state.threadInfo.variant == .group ? .none : .qrCode), + additionalProfile: state.threadInfo.additionalProfile, accessibility: nil ), styling: SessionCell.StyleInfo( @@ -287,30 +435,40 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), backgroundStyle: .noBackground ), - onTapView: { [weak self] targetView in + onTapView: { [dependencies = viewModel.dependencies] targetView in let didTapQRCodeIcon: Bool = !(targetView is ProfilePictureView) if didTapQRCodeIcon { - self?.profileImageStatus = (previous: profileImageStatus?.current, current: .qrCode) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(ThreadSettingsViewModel.self), + value: ThreadSettingsViewModelEvent( + profileImageStatus: state.profileImageStatus.toggleState() + ) + ) } else { - self?.profileImageStatus = ( - previous: profileImageStatus?.current, - current: (profileImageStatus?.current == .expanded ? .normal : .expanded) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(ThreadSettingsViewModel.self), + value: ThreadSettingsViewModelEvent( + profileImageStatus: state.profileImageStatus.toggleExpansion() + ) ) } - self?.forceRefresh(type: .postDatabaseQuery) } ) ), SessionCell.Info( id: .displayName, title: SessionCell.TextInfo( - threadViewModel.displayName, + threadDisplayName, font: .titleLarge, alignment: .center, trailingImage: { - guard !threadViewModel.threadIsNoteToSelf else { return nil } - guard (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) else { return nil } + guard + state.threadInfo.shouldShowProBadge && + !state.threadInfo.isNoteToSelf + else { return nil } return SessionProBadge.trailingImage( size: .medium, @@ -323,8 +481,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi customPadding: SessionCell.Padding( top: Values.smallSpacing, bottom: { - guard threadViewModel.threadVariant != .contact else { return Values.mediumSpacing } - guard threadViewModel.threadDescription == nil else { return Values.smallSpacing } + guard state.threadInfo.variant != .contact else { return Values.mediumSpacing } + guard state.threadInfo.conversationDescription == nil else { + return Values.smallSpacing + } return Values.largeSpacing }(), @@ -334,27 +494,27 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), accessibility: Accessibility( identifier: "Username", - label: threadViewModel.displayName + label: threadDisplayName ), - onTapView: { [weak self, threadId, dependencies] targetView in - guard targetView is SessionProBadge, !dependencies[cache: .libSession].isSessionPro else { - guard - let info: ConfirmationModal.Info = self?.updateDisplayNameModal( - threadViewModel: threadViewModel, - currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin - ) - else { return } + onTapView: { [weak viewModel, dependencies = viewModel.dependencies] targetView in + guard + targetView is SessionProBadge, + !dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro + else { + guard let info: ConfirmationModal.Info = viewModel?.updateDisplayNameModal(state: state) else { + return + } - self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + viewModel?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) return } let proCTAModalVariant: ProCTAModal.Variant = { - switch threadViewModel.threadVariant { + switch state.threadInfo.variant { case .group: return .groupLimit( - isAdmin: currentUserIsClosedGroupAdmin, - isSessionProActivated: (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }), + isAdmin: (state.threadInfo.groupInfo?.currentUserRole == .admin), + isSessionProActivated: (state.threadInfo.groupInfo?.isProGroup == true), proBadgeImage: UIView.image( for: .themedKey( SessionProBadge.Size.mini.cacheKey, @@ -364,31 +524,35 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ) default: - return .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) + return .generic( + renew: dependencies[singleton: .sessionProManager] + .currentUserCurrentProState + .status == .expired + ) } }() - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( proCTAModalVariant, onConfirm: { - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( - presenting: { bottomSheet in - self?.transitionToScreen(bottomSheet, transitionType: .present) + dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded( + presenting: { [weak viewModel] bottomSheet in + viewModel?.transitionToScreen(bottomSheet, transitionType: .present) } ) }, - presenting: { modal in - self?.transitionToScreen(modal, transitionType: .present) + presenting: { [weak viewModel] modal in + viewModel?.transitionToScreen(modal, transitionType: .present) } ) } ), - (threadViewModel.displayName == threadViewModel.contactDisplayName ? nil : + (state.threadInfo.contactInfo == nil || threadDisplayName == state.threadInfo.contactInfo?.displayName ? nil : SessionCell.Info( id: .contactName, subtitle: SessionCell.TextInfo( - "(\(threadViewModel.contactDisplayName))", // stringlint:ignore + "(\(state.threadInfo.contactInfo?.displayName ?? ""))", // stringlint:ignore font: .subtitle, alignment: .center ), @@ -403,11 +567,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - threadViewModel.threadDescription.map { threadDescription in + state.threadInfo.conversationDescription.map { conversationDescription in SessionCell.Info( id: .threadDescription, description: SessionCell.TextInfo( - threadDescription, + conversationDescription, font: .subtitle, alignment: .center, interaction: .expandable @@ -416,13 +580,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi tintColor: .textSecondary, customPadding: SessionCell.Padding( top: 0, - bottom: (threadViewModel.threadVariant != .contact ? Values.largeSpacing : nil) + bottom: (state.threadInfo.variant != .contact ? Values.largeSpacing : nil) ), backgroundStyle: .noBackground ), accessibility: Accessibility( identifier: "Description", - label: threadDescription + label: conversationDescription ) ) } @@ -432,12 +596,12 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Session Id let sessionIdSection: SectionModel = SectionModel( - model: (threadViewModel.threadIsNoteToSelf == true ? .sessionIdNoteToSelf : .sessionId), + model: (state.threadInfo.isNoteToSelf ? .sessionIdNoteToSelf : .sessionId), elements: [ SessionCell.Info( id: .sessionId, subtitle: SessionCell.TextInfo( - threadViewModel.id, + state.threadInfo.id, font: .monoLarge, alignment: .center, interaction: .copy @@ -448,7 +612,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), accessibility: Accessibility( identifier: "Session ID", - label: threadViewModel.id + label: state.threadInfo.id ) ) ] @@ -456,7 +620,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Users kicked from groups - guard !currentUserKickedFromGroup else { + guard state.threadInfo.groupInfo?.wasKicked != true else { return [ conversationInfoSection, SectionModel( @@ -475,21 +639,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi title: "groupDelete".localized(), body: .attributedText( "groupDeleteDescriptionMember" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ), confirmTitle: "delete".localized(), confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, type: .leaveGroupAsync, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, using: dependencies ) } @@ -506,11 +670,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let standardActionsSection: SectionModel = SectionModel( model: .content, elements: [ - (threadViewModel.threadVariant == .legacyGroup || threadViewModel.threadVariant == .group ? nil : + (state.threadInfo.variant == .legacyGroup || state.threadInfo.variant == .group ? nil : SessionCell.Info( id: .copyThreadId, leadingAccessory: .icon(.copy), - title: (threadViewModel.threadVariant == .community ? + title: (state.threadInfo.variant == .community ? "communityUrlCopy".localized() : "accountIDCopy".localized() ), @@ -518,24 +682,25 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "\(ThreadSettingsViewModel.self).copy_thread_id", label: "Copy Session ID" ), - onTap: { [weak self] in - switch threadViewModel.threadVariant { + onTap: { [weak viewModel] in + switch state.threadInfo.variant { case .contact, .legacyGroup, .group: - UIPasteboard.general.string = threadViewModel.threadId + UIPasteboard.general.string = state.threadInfo.id case .community: guard + let communityInfo: ConversationInfoViewModel.CommunityInfo = state.threadInfo.communityInfo, let urlString: String = LibSession.communityUrlFor( - server: threadViewModel.openGroupServer, - roomToken: threadViewModel.openGroupRoomToken, - publicKey: threadViewModel.openGroupPublicKey + server: communityInfo.server, + roomToken: communityInfo.roomToken, + publicKey: communityInfo.publicKey ) else { return } UIPasteboard.general.string = urlString } - self?.showToast( + viewModel?.showToast( text: "copied".localized(), backgroundColor: .backgroundSecondary ) @@ -551,40 +716,42 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "\(ThreadSettingsViewModel.self).search", label: "Search" ), - onTap: { [weak self] in self?.didTriggerSearch() } + onTap: { [weak viewModel] in viewModel?.didTriggerSearch() } ), ( - threadViewModel.threadVariant == .community || - threadViewModel.threadIsBlocked == true || - currentUserIsClosedGroupAdmin ? nil : + state.threadInfo.variant == .community || + state.threadInfo.isBlocked || + state.threadInfo.groupInfo?.currentUserRole == .admin ? nil : SessionCell.Info( id: .disappearingMessages, leadingAccessory: .icon(.timer), title: "disappearingMessages".localized(), subtitle: { - guard current.disappearingMessagesConfig.isEnabled else { - return "off".localized() - } + guard + let config: DisappearingMessagesConfiguration = state.threadInfo.disappearingMessagesConfiguration, + config.isEnabled + else { return "off".localized() } - return (current.disappearingMessagesConfig.type ?? .unknown) - .localizedState( - durationString: current.disappearingMessagesConfig.durationString - ) + return (config.type ?? .unknown).localizedState( + durationString: config.durationString + ) }(), accessibility: Accessibility( identifier: "Disappearing messages", label: "\(ThreadSettingsViewModel.self).disappearing_messages" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.transitionToScreen( SessionTableViewController( viewModel: ThreadDisappearingMessagesSettingsViewModel( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - currentUserIsClosedGroupMember: threadViewModel.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: threadViewModel.currentUserIsClosedGroupAdmin, - config: current.disappearingMessagesConfig, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, + currentUserRole: state.threadInfo.groupInfo?.currentUserRole, + config: ( + state.threadInfo.disappearingMessagesConfiguration ?? + DisappearingMessagesConfiguration.defaultWith(state.threadInfo.id) + ), using: dependencies ) ) @@ -593,16 +760,16 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (threadViewModel.threadIsBlocked == true ? nil : + (state.threadInfo.isBlocked ? nil : SessionCell.Info( id: .pinConversation, leadingAccessory: .icon( - (threadViewModel.threadPinnedPriority > 0 ? + (state.threadInfo.pinnedPriority > 0 ? .pinOff : .pin ) ), - title: (threadViewModel.threadPinnedPriority > 0 ? + title: (state.threadInfo.pinnedPriority > 0 ? "pinUnpinConversation".localized() : "pinConversation".localized() ), @@ -610,24 +777,26 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "\(ThreadSettingsViewModel.self).pin_conversation", label: "Pin Conversation" ), - onTap: { [weak self] in - self?.toggleConversationPinnedStatus( - currentPinnedPriority: threadViewModel.threadPinnedPriority - ) + onTap: { [weak viewModel] in + Task { + await viewModel?.toggleConversationPinnedStatus( + threadInfo: state.threadInfo + ) + } } ) ), - ((threadViewModel.threadIsNoteToSelf == true || threadViewModel.threadIsBlocked == true) ? nil : + (state.threadInfo.isNoteToSelf || state.threadInfo.isBlocked ? nil : SessionCell.Info( id: .notifications, leadingAccessory: .icon( { - if threadViewModel.threadOnlyNotifyForMentions == true { + if state.threadInfo.onlyNotifyForMentions { return .atSign } - if threadViewModel.threadMutedUntilTimestamp != nil { + if state.threadInfo.mutedUntilTimestamp != nil { return .volumeOff } @@ -636,11 +805,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), title: "sessionNotifications".localized(), subtitle: { - if threadViewModel.threadOnlyNotifyForMentions == true { + if state.threadInfo.onlyNotifyForMentions { return "notificationsMentionsOnly".localized() } - if threadViewModel.threadMutedUntilTimestamp != nil { + if state.threadInfo.mutedUntilTimestamp != nil { return "notificationsMuted".localized() } @@ -650,14 +819,14 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "\(ThreadSettingsViewModel.self).notifications", label: "Notifications" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.transitionToScreen( SessionTableViewController( viewModel: ThreadNotificationSettingsViewModel( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - threadOnlyNotifyForMentions: threadViewModel.threadOnlyNotifyForMentions, - threadMutedUntilTimestamp: threadViewModel.threadMutedUntilTimestamp, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, + threadOnlyNotifyForMentions: state.threadInfo.onlyNotifyForMentions, + threadMutedUntilTimestamp: state.threadInfo.mutedUntilTimestamp, using: dependencies ) ) @@ -666,7 +835,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (threadViewModel.threadVariant != .community ? nil : + (state.threadInfo.variant != .community ? nil : SessionCell.Info( id: .addToOpenGroup, leadingAccessory: .icon(.userRoundPlus), @@ -674,11 +843,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).add_to_open_group" ), - onTap: { [weak self] in self?.inviteUsersToCommunity(threadViewModel: threadViewModel) } + onTap: { [weak viewModel] in viewModel?.inviteUsersToCommunity(threadInfo: state.threadInfo) } ) ), - (!currentUserIsClosedGroupMember ? nil : + (state.threadInfo.groupInfo?.currentUserRole == nil ? nil : SessionCell.Info( id: .groupMembers, leadingAccessory: .icon(.usersRound), @@ -687,7 +856,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "Group members", label: "Group members" ), - onTap: { [weak self] in self?.viewMembers() } + onTap: { [weak viewModel] in viewModel?.viewMembers(state: state) } ) ), @@ -699,12 +868,12 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "\(ThreadSettingsViewModel.self).all_media", label: "All media" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.transitionToScreen( MediaGalleryViewModel.createAllMediaViewController( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - threadTitle: threadViewModel.displayName, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, + threadTitle: threadDisplayName, focusedAttachmentId: nil, using: dependencies ) @@ -717,7 +886,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Admin Actions let adminActionsSection: SectionModel? = ( - !currentUserIsClosedGroupAdmin ? nil : + state.threadInfo.groupInfo?.currentUserRole != .admin ? nil : SectionModel( model: .adminActions, elements: [ @@ -729,11 +898,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "Edit group", label: "Edit group" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.transitionToScreen( SessionTableViewController( viewModel: EditGroupViewModel( - threadId: threadViewModel.threadId, + threadId: state.threadInfo.id, using: dependencies ) ) @@ -741,7 +910,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } ), - (!dependencies[feature: .updatedGroupsAllowPromotions] ? nil : + (!viewModel.dependencies[feature: .updatedGroupsAllowPromotions] ? nil : SessionCell.Info( id: .promoteAdmins, leadingAccessory: .icon( @@ -753,8 +922,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "Promote admins", label: "Promote admins" ), - onTap: { [weak self] in - self?.promoteAdmins(currentGroupName: threadViewModel.closedGroupName) + onTap: { [weak viewModel] in + viewModel?.promoteAdmins(state: state) } ) ), @@ -764,28 +933,29 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi leadingAccessory: .icon(.timer), title: "disappearingMessages".localized(), subtitle: { - guard current.disappearingMessagesConfig.isEnabled else { - return "off".localized() - } + guard + let config: DisappearingMessagesConfiguration = state.threadInfo.disappearingMessagesConfiguration, + config.isEnabled + else { return "off".localized() } - return (current.disappearingMessagesConfig.type ?? .unknown) - .localizedState( - durationString: current.disappearingMessagesConfig.durationString - ) + return (config.type ?? .unknown) + .localizedState(durationString: config.durationString) }(), accessibility: Accessibility( identifier: "Disappearing messages", label: "\(ThreadSettingsViewModel.self).disappearing_messages" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.transitionToScreen( SessionTableViewController( viewModel: ThreadDisappearingMessagesSettingsViewModel( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - currentUserIsClosedGroupMember: threadViewModel.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: threadViewModel.currentUserIsClosedGroupAdmin, - config: current.disappearingMessagesConfig, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, + currentUserRole: state.threadInfo.groupInfo?.currentUserRole, + config: ( + state.threadInfo.disappearingMessagesConfiguration ?? + DisappearingMessagesConfiguration.defaultWith(state.threadInfo.id) + ), using: dependencies ) ) @@ -801,18 +971,16 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let destructiveActionsSection: SectionModel = SectionModel( model: .destructiveActions, elements: [ - (threadViewModel.threadIsNoteToSelf || threadViewModel.threadVariant != .contact ? nil : + (state.threadInfo.isNoteToSelf || state.threadInfo.variant != .contact ? nil : SessionCell.Info( id: .blockUser, - leadingAccessory: ( - threadViewModel.threadIsBlocked == true ? - .icon(.userRoundCheck) : - .icon(UIImage(named: "ic_user_round_ban")?.withRenderingMode(.alwaysTemplate)) + leadingAccessory: (state.threadInfo.isBlocked ? + .icon(.userRoundCheck) : + .icon(UIImage(named: "ic_user_round_ban")?.withRenderingMode(.alwaysTemplate)) ), - title: ( - threadViewModel.threadIsBlocked == true ? - "blockUnblock".localized() : - "block".localized() + title: (state.threadInfo.isBlocked ? + "blockUnblock".localized() : + "block".localized() ), styling: SessionCell.StyleInfo(tintColor: .danger), accessibility: Accessibility( @@ -820,43 +988,41 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi label: "Block" ), confirmationInfo: ConfirmationModal.Info( - title: (threadViewModel.threadIsBlocked == true ? + title: (state.threadInfo.isBlocked ? "blockUnblock".localized() : "block".localized() ), - body: (threadViewModel.threadIsBlocked == true ? + body: (state.threadInfo.isBlocked ? .attributedText( "blockUnblockName" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) : .attributedText( "blockDescription" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) ), - confirmTitle: (threadViewModel.threadIsBlocked == true ? + confirmTitle: (state.threadInfo.isBlocked ? "blockUnblock".localized() : "block".localized() ), confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self] in - let isBlocked: Bool = (threadViewModel.threadIsBlocked == true) - - self?.updateBlockedState( - from: isBlocked, - isBlocked: !isBlocked, - threadId: threadViewModel.threadId, - displayName: threadViewModel.displayName + onTap: { [weak viewModel] in + viewModel?.updateBlockedState( + from: state.threadInfo.isBlocked, + isBlocked: !state.threadInfo.isBlocked, + threadId: state.threadInfo.id, + displayName: threadDisplayName ) } ) ), - (threadViewModel.threadIsNoteToSelf != true ? nil : + (!state.threadInfo.isNoteToSelf ? nil : SessionCell.Info( id: .hideNoteToSelf, leadingAccessory: .icon(isThreadHidden ? .eye : .eyeOff), @@ -879,21 +1045,23 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: isThreadHidden ? .alert_text : .danger, cancelStyle: .alert_text ), - onTap: { [dependencies] in + onTap: { [dependencies = viewModel.dependencies] in dependencies[singleton: .storage].writeAsync { db in if isThreadHidden { - try SessionThread.updateVisibility( + try SessionThread.update( db, - threadId: threadViewModel.threadId, - isVisible: true, + id: state.threadInfo.id, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true) + ), using: dependencies ) } else { try SessionThread.deleteOrLeave( db, type: .hideContactConversation, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, using: dependencies ) } @@ -916,36 +1084,37 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmationInfo: ConfirmationModal.Info( title: "clearMessages".localized(), body: { - guard threadViewModel.threadIsNoteToSelf != true else { + guard !state.threadInfo.isNoteToSelf else { return .attributedText( "clearMessagesNoteToSelfDescriptionUpdated" .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) } - switch threadVariant { + + switch state.threadInfo.variant { case .contact: return .attributedText( "clearMessagesChatDescriptionUpdated" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) case .legacyGroup: return .attributedText( "clearMessagesGroupDescriptionUpdated" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) case .community: return .attributedText( "clearMessagesCommunityUpdated" - .put(key: "community_name", value: threadViewModel.displayName) + .put(key: "community_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) case .group: - if currentUserIsClosedGroupAdmin { + if state.threadInfo.groupInfo?.currentUserRole == .admin { return .radio( explanation: "clearMessagesGroupAdminDescriptionUpdated" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont), warning: nil, options: [ @@ -972,7 +1141,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } else { return .attributedText( "clearMessagesGroupDescriptionUpdated" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) } @@ -982,8 +1151,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text, dismissOnConfirm: false, - onConfirm: { [weak self, threadVariant, dependencies] modal in - if threadVariant == .group && currentUserIsClosedGroupAdmin { + onConfirm: { [weak viewModel, dependencies = viewModel.dependencies] modal in + if state.threadInfo.variant == .group && state.threadInfo.groupInfo?.currentUserRole == .admin { /// Determine the selected action index let selectedIndex: Int = { switch modal.info.body { @@ -1000,7 +1169,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // Don't update the group if the selected option is `Clear on this device` if selectedIndex != 0 { - self?.deleteAllMessagesBeforeNow() + viewModel?.deleteAllMessagesBeforeNow(state: state) } } @@ -1008,18 +1177,19 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi updates: { db in try Interaction.markAllAsDeleted( db, - threadId: threadViewModel.id, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, options: [.local, .noArtifacts], using: dependencies ) - }, completion: { [weak self] result in + }, + completion: { [weak viewModel] result in switch result { case .failure(let error): Log.error("Failed to clear messages due to error: \(error)") DispatchQueue.main.async { modal.dismiss(animated: true) { - self?.showToast( + viewModel?.showToast( text: "deleteMessageFailed" .putNumber(0) .localized(), @@ -1031,7 +1201,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case .success: DispatchQueue.main.async { modal.dismiss(animated: true) { - self?.showToast( + viewModel?.showToast( text: "deleteMessageDeleted" .putNumber(0) .localized(), @@ -1047,7 +1217,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (threadViewModel.threadVariant != .community ? nil : + (state.threadInfo.variant != .community ? nil : SessionCell.Info( id: .leaveCommunity, leadingAccessory: .icon(.logOut), @@ -1061,21 +1231,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi title: "communityLeave".localized(), body: .attributedText( "groupLeaveDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ), confirmTitle: "leave".localized(), confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, type: .deleteCommunityAndContent, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, using: dependencies ) } @@ -1084,42 +1254,54 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (!currentUserIsClosedGroupMember ? nil : + (state.threadInfo.groupInfo?.currentUserRole == nil ? nil : SessionCell.Info( id: .leaveGroup, - leadingAccessory: .icon(currentUserIsClosedGroupAdmin ? .trash2 : .logOut), - title: currentUserIsClosedGroupAdmin ? "groupDelete".localized() : "groupLeave".localized(), + leadingAccessory: .icon(state.threadInfo.groupInfo?.currentUserRole == .admin ? + .trash2 : + .logOut + ), + title: (state.threadInfo.groupInfo?.currentUserRole == .admin ? + "groupDelete".localized() : + "groupLeave".localized() + ), styling: SessionCell.StyleInfo(tintColor: .danger), accessibility: Accessibility( identifier: "Leave group", label: "Leave group" ), confirmationInfo: ConfirmationModal.Info( - title: currentUserIsClosedGroupAdmin ? "groupDelete".localized() : "groupLeave".localized(), - body: (currentUserIsClosedGroupAdmin ? + title: (state.threadInfo.groupInfo?.currentUserRole == .admin ? + "groupDelete".localized() : + "groupLeave".localized() + ), + body: (state.threadInfo.groupInfo?.currentUserRole == .admin ? .attributedText( "groupDeleteDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) : .attributedText( "groupLeaveDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) ), - confirmTitle: currentUserIsClosedGroupAdmin ? "delete".localized() : "leave".localized(), + confirmTitle: (state.threadInfo.groupInfo?.currentUserRole == .admin ? + "delete".localized() : + "leave".localized() + ), confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, type: .leaveGroupAsync, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, using: dependencies ) } @@ -1128,7 +1310,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (threadVariant != .contact || threadViewModel.threadIsNoteToSelf == true ? nil : + (state.threadInfo.variant != .contact || state.threadInfo.isNoteToSelf ? nil : SessionCell.Info( id: .deleteConversation, leadingAccessory: .icon(.trash2), @@ -1142,21 +1324,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi title: "conversationsDelete".localized(), body: .attributedText( "deleteConversationDescription" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ), confirmTitle: "delete".localized(), confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, type: .deleteContactConversationAndMarkHidden, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, using: dependencies ) } @@ -1165,7 +1347,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (threadVariant != .contact || threadViewModel.threadIsNoteToSelf == true ? nil : + (state.threadInfo.variant != .contact || state.threadInfo.isNoteToSelf ? nil : SessionCell.Info( id: .deleteContact, leadingAccessory: .icon( @@ -1181,7 +1363,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi title: "contactDelete".localized(), body: .attributedText( "deleteContactDescription" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont), scrollMode: .never ), @@ -1189,14 +1371,14 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, type: .deleteContactConversationAndContact, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, using: dependencies ) } @@ -1206,7 +1388,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), // FIXME: [GROUPS REBUILD] Need to build this properly in a future release - (!dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow] || threadViewModel.threadVariant != .group ? nil : + (!viewModel.dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow] || state.threadInfo.variant != .group ? nil : SessionCell.Info( id: .debugDeleteAttachmentsBeforeNow, leadingAccessory: .icon( @@ -1225,7 +1407,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self] in self?.deleteAllAttachmentsBeforeNow() } + onTap: { [weak viewModel] in viewModel?.deleteAllAttachmentsBeforeNow(state: state) } ) ) ].compactMap { $0 } @@ -1242,42 +1424,16 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Functions - private func inviteUsersToCommunity(threadViewModel: SessionThreadViewModel) { + private func inviteUsersToCommunity(threadInfo: ConversationInfoViewModel) { guard - let name: String = threadViewModel.openGroupName, - let server: String = threadViewModel.openGroupServer, - let roomToken: String = threadViewModel.openGroupRoomToken, - let publicKey: String = threadViewModel.openGroupPublicKey, + let communityInfo: ConversationInfoViewModel.CommunityInfo = threadInfo.communityInfo, let communityUrl: String = LibSession.communityUrlFor( - server: threadViewModel.openGroupServer, - roomToken: threadViewModel.openGroupRoomToken, - publicKey: threadViewModel.openGroupPublicKey + server: communityInfo.server, + roomToken: communityInfo.roomToken, + publicKey: communityInfo.publicKey ) else { return } - let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = LibSession.OpenGroupCapabilityInfo( - roomToken: roomToken, - server: server, - publicKey: publicKey, - capabilities: (threadViewModel.openGroupCapabilities ?? []) - ) - let currentUserSessionIds: Set = Set([ - dependencies[cache: .general].sessionId.hexString, - SessionThread.getCurrentUserBlindedSessionId( - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded15, - openGroupCapabilityInfo: openGroupCapabilityInfo, - using: dependencies - )?.hexString, - SessionThread.getCurrentUserBlindedSessionId( - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded25, - openGroupCapabilityInfo: openGroupCapabilityInfo, - using: dependencies - )?.hexString - ].compactMap { $0 }) let contact: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() @@ -1291,7 +1447,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi SELECT \(contact.allColumns) FROM \(contact) LEFT JOIN \(groupMember) ON ( - \(groupMember[.groupId]) = \(threadId) AND + \(groupMember[.groupId]) = \(threadInfo.id) AND \(groupMember[.profileId]) = \(contact[.id]) ) WHERE ( @@ -1299,7 +1455,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi \(contact[.isApproved]) = TRUE AND \(contact[.didApproveMe]) = TRUE AND \(contact[.isBlocked]) = FALSE AND - \(contact[.id]) NOT IN \(currentUserSessionIds) + \(contact[.id]) NOT IN \(threadInfo.currentUserSessionIds) ) """), footerTitle: "membersInviteTitle".localized(), @@ -1330,7 +1486,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi try LinkPreview( url: communityUrl, variant: .openGroupInvitation, - title: name, + title: communityInfo.name, using: dependencies ) .upsert(db) @@ -1342,7 +1498,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let interaction: Interaction = try Interaction( threadId: thread.id, threadVariant: thread.variant, - authorId: threadViewModel.currentUserSessionId, + authorId: threadInfo.userSessionId.hexString, variant: .standardOutgoing, timestampMs: sentTimestampMs, expiresInSeconds: destinationDisappearingMessagesConfiguration?.expiresInSeconds(), @@ -1385,7 +1541,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi public static func createMemberListViewController( threadId: String, - transitionToConversation: @escaping @MainActor (String) -> Void, + transitionToConversation: @escaping @MainActor (ConversationInfoViewModel?) -> Void, using dependencies: Dependencies ) -> UIViewController { return SessionTableViewController( @@ -1403,43 +1559,61 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi .filter(GroupMember.Columns.groupId == threadId) .group(GroupMember.Columns.profileId), onTap: .callback { _, memberInfo in - dependencies[singleton: .storage].writeAsync( - updates: { db in - try SessionThread.upsert( - db, - id: memberInfo.profileId, - variant: .contact, - values: SessionThread.TargetValues( - creationDateTimestamp: .useExistingOrSetTo( - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 - ), - shouldBeVisible: .useExisting, - isDraft: .useExistingOrSetTo(true) + let maybeThreadInfo: ConversationInfoViewModel? = try? await dependencies[singleton: .storage].writeAsync { db in + try SessionThread.upsert( + db, + id: memberInfo.profileId, + variant: .contact, + values: SessionThread.TargetValues( + creationDateTimestamp: .useExistingOrSetTo( + dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 ), - using: dependencies - ) - }, - completion: { _ in - Task { @MainActor in - transitionToConversation(memberInfo.profileId) - } - } - ) + shouldBeVisible: .useExisting, + isDraft: .useExistingOrSetTo(true) + ), + using: dependencies + ) + + return try ConversationViewModel.fetchConversationInfo( + db, + threadId: memberInfo.profileId, + using: dependencies + ) + } + + await MainActor.run { + transitionToConversation(maybeThreadInfo) + } }, using: dependencies ) ) } - private func viewMembers() { + private func viewMembers(state: State) { self.transitionToScreen( ThreadSettingsViewModel.createMemberListViewController( - threadId: threadId, - transitionToConversation: { [weak self, dependencies] selectedMemberId in + threadId: state.threadInfo.id, + transitionToConversation: { [weak self, dependencies] maybeThreadInfo in + guard let threadInfo: ConversationInfoViewModel = maybeThreadInfo else { + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "theError".localized(), + body: .text("errorUnknown".localized()), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ), + transitionType: .present + ) + return + } + self?.transitionToScreen( ConversationVC( - threadId: selectedMemberId, - threadVariant: .contact, + threadInfo: threadInfo, + focusedInteractionInfo: nil, using: dependencies ), transitionType: .push @@ -1450,7 +1624,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) } - private func promoteAdmins(currentGroupName: String?) { + private func promoteAdmins(state: State) { guard dependencies[feature: .updatedGroupsAllowPromotions] else { return } let groupMember: TypedTableAlias = TypedTableAlias() @@ -1472,7 +1646,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi /// Actually trigger the sending process MessageSender .promoteGroupMembers( - groupSessionId: SessionId(.group, hex: threadId), + groupSessionId: SessionId(.group, hex: state.threadInfo.id), members: memberInfo, isResend: isResend, using: dependencies @@ -1480,7 +1654,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) .receive(on: DispatchQueue.main, using: dependencies) .sinkUntilComplete( - receiveCompletion: { [threadId, dependencies] result in + receiveCompletion: { [dependencies] result in switch result { case .finished: break case .failure: @@ -1489,7 +1663,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi /// Flag the members as failed dependencies[singleton: .storage].writeAsync { db in try? GroupMember - .filter(GroupMember.Columns.groupId == threadId) + .filter(GroupMember.Columns.groupId == state.threadInfo.id) .filter(memberIds.contains(GroupMember.Columns.profileId)) .updateAllAndConfig( db, @@ -1501,7 +1675,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi /// Show a toast that the promotions failed to send viewModel?.showToast( text: GroupPromoteMemberJob.failureMessage( - groupName: (currentGroupName ?? "groupUnknown".localized()), + groupName: (state.threadInfo.groupInfo?.name ?? "groupUnknown".localized()), memberIds: memberIds, profileInfo: memberInfo.reduce(into: [:]) { result, next in result[next.id] = next.profile @@ -1526,7 +1700,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi SELECT \(groupMember.allColumns) FROM \(groupMember) WHERE ( - \(groupMember[.groupId]) == \(threadId) AND ( + \(groupMember[.groupId]) == \(state.threadInfo.id) AND ( \(groupMember[.role]) == \(GroupMember.Role.admin) OR ( \(groupMember[.role]) != \(GroupMember.Role.admin) AND @@ -1570,11 +1744,14 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } private func updateNickname( + state: State, current: String?, displayName: String ) -> ConfirmationModal.Info { /// Set `updatedName` to `current` so we can disable the "save" button when there are no changes and don't need to worry about retrieving them in the confirmation closure self.updatedName = current + let currentUserSessionId: SessionId = dependencies[cache: .general].sessionId + return ConfirmationModal.Info( title: "nicknameSet".localized(), body: .input( @@ -1609,7 +1786,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi cancelEnabled: .bool(current?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false), hasCloseButton: true, dismissOnConfirm: false, - onConfirm: { [weak self, dependencies, threadId] modal in + onConfirm: { [weak self, dependencies] modal in guard let finalNickname: String = (self?.updatedName ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -1627,9 +1804,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi updates: { db in try Profile.updateIfNeeded( db, - publicKey: threadId, + publicKey: state.threadInfo.id, nicknameUpdate: .set(to: finalNickname), - profileUpdateTimestamp: nil, + profileUpdateTimestamp: nil, /// Not set for `nickname` + currentUserSessionIds: [currentUserSessionId.hexString], /// Contact thread using: dependencies ) }, @@ -1640,15 +1818,16 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } ) }, - onCancel: { [dependencies, threadId] modal in + onCancel: { [dependencies] modal in /// Remove the nickname dependencies[singleton: .storage].writeAsync( updates: { db in try Profile.updateIfNeeded( db, - publicKey: threadId, + publicKey: state.threadInfo.id, nicknameUpdate: .set(to: nil), - profileUpdateTimestamp: nil, + profileUpdateTimestamp: nil, /// Not set for `nickname` + currentUserSessionIds: [currentUserSessionId.hexString], /// Contact thread using: dependencies ) }, @@ -1663,6 +1842,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } private func updateGroupNameAndDescription( + state: State, currentName: String, currentDescription: String?, isUpdatedGroup: Bool @@ -1722,7 +1902,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi }, cancelStyle: .danger, dismissOnConfirm: false, - onConfirm: { [weak self, dependencies, threadId] modal in + onConfirm: { [weak self, dependencies] modal in guard let finalName: String = (self?.updatedName ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -1746,7 +1926,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi /// Update the group appropriately MessageSender .updateGroup( - groupSessionId: threadId, + groupSessionId: state.threadInfo.id, name: finalName, groupDescription: finalDescription, using: dependencies @@ -1760,7 +1940,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) } - private func updateGroupDisplayPicture(currentUrl: String?) { + private func updateGroupDisplayPicture(state: State, currentUrl: String?) { guard dependencies[feature: .updatedGroupsAllowDisplayPicture] else { return } let iconName: String = "profile_placeholder" // stringlint:ignore @@ -1834,6 +2014,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case .image(.some(let source), _, _, let style, _, _, _, _, _): // FIXME: Need to add Group Pro display pic CTA self?.updateGroupDisplayPicture( + state: state, displayPictureUpdate: .groupUploadImage( source: source, cropRect: style.cropRect @@ -1857,6 +2038,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi hasSetNewProfilePicture = false } else { self?.updateGroupDisplayPicture( + state: state, displayPictureUpdate: .groupRemove, onUploadComplete: { [weak modal] in Task { @MainActor in modal?.close() } @@ -1888,6 +2070,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } private func updateGroupDisplayPicture( + state: State, displayPictureUpdate: DisplayPictureManager.Update, onUploadComplete: @escaping () -> () ) { @@ -1896,7 +2079,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi default: break } - Task.detached(priority: .userInitiated) { [weak self, threadId, dependencies] in + Task.detached(priority: .userInitiated) { [weak self, dependencies] in var targetUpdate: DisplayPictureManager.Update = displayPictureUpdate var indicator: ModalActivityIndicatorViewController? @@ -1965,7 +2148,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let existingDownloadUrl: String? = try? await dependencies[singleton: .storage].readAsync { db in try? ClosedGroup - .filter(id: threadId) + .filter(id: state.threadInfo.id) .select(.displayPictureUrl) .asRequest(of: String.self) .fetchOne(db) @@ -1973,7 +2156,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi do { try await MessageSender.updateGroup( - groupSessionId: threadId, + groupSessionId: state.threadInfo.id, displayPictureUpdate: targetUpdate, using: dependencies ) @@ -2018,96 +2201,104 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } } - private func toggleConversationPinnedStatus(currentPinnedPriority: Int32) { - let isCurrentlyPinned: Bool = (currentPinnedPriority > LibSession.visiblePriority) + private func toggleConversationPinnedStatus(threadInfo: ConversationInfoViewModel) async { + let isCurrentlyPinned: Bool = (threadInfo.pinnedPriority > LibSession.visiblePriority) + let sessionProState: SessionPro.State = await dependencies[singleton: .sessionProManager] + .state + .first(defaultValue: .invalid) - if !isCurrentlyPinned && dependencies[feature: .sessionProEnabled] && !dependencies[cache: .libSession].isSessionPro { + if sessionProState.sessionProEnabled && !isCurrentlyPinned && sessionProState.status != .active { // TODO: [Database Relocation] Retrieve the full conversation list from lib session and check the pinnedPriority that way instead of using the database - dependencies[singleton: .storage].writeAsync ( - updates: { [threadId, dependencies] db in + do { + let numPinnedConversations: Int = try await dependencies[singleton: .storage].writeAsync { [dependencies] db in let numPinnedConversations: Int = try SessionThread .filter(SessionThread.Columns.pinnedPriority > LibSession.visiblePriority) .fetchCount(db) - guard numPinnedConversations < LibSession.PinnedConversationLimit else { + guard numPinnedConversations < SessionPro.PinnedConversationLimit else { return numPinnedConversations } - // We have the space to pin the conversation, so do so - try SessionThread.updateVisibility( + /// We have the space to pin the conversation, so do so + try SessionThread.update( db, - threadId: threadId, - isVisible: true, - customPriority: (currentPinnedPriority <= LibSession.visiblePriority ? 1 : LibSession.visiblePriority), + id: threadInfo.id, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true), + pinnedPriority: .setTo(threadInfo.pinnedPriority <= LibSession.visiblePriority ? + 1 : + LibSession.visiblePriority + ) + ), using: dependencies ) return -1 - }, - completion: { [weak self, dependencies] result in - guard - let numPinnedConversations: Int = try? result.successOrThrow(), - numPinnedConversations > 0 - else { return } - - DispatchQueue.main.async { - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - variant: .morePinnedConvos( - isGrandfathered: (numPinnedConversations > LibSession.PinnedConversationLimit), - renew: dependencies[singleton: .sessionProState].isSessionProExpired - ), - dataManager: dependencies[singleton: .imageDataManager], - onConfirm: { [dependencies] in - Task { - await dependencies[singleton: .sessionProState].upgradeToPro( - plan: SessionProPlan(variant: .threeMonths), - originatingPlatform: .iOS, - completion: nil - ) - } + } + + /// If we already have too many conversations pinned then we need to show the CTA modal + guard numPinnedConversations > 0 else { return } + + _ = await MainActor.run { [weak self, dependencies] in + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + .morePinnedConvos( + isGrandfathered: (numPinnedConversations > SessionPro.PinnedConversationLimit), + renew: (sessionProState.status == .expired) + ), + onConfirm: { [weak self] in + dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded( + presenting: { [weak self] bottomSheet in + self?.transitionToScreen(bottomSheet, transitionType: .present) } ) - ) - self?.transitionToScreen(sessionProModal, transitionType: .present) - } + }, + presenting: { [weak self] modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) } - ) + } + catch {} return } // If we are unpinning then no need to check the current count, just unpin immediately - dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in - try SessionThread.updateVisibility( + try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try SessionThread.update( db, - threadId: threadId, - isVisible: true, - customPriority: (currentPinnedPriority <= LibSession.visiblePriority ? 1 : LibSession.visiblePriority), + id: threadInfo.id, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true), + pinnedPriority: .setTo(threadInfo.pinnedPriority <= LibSession.visiblePriority ? + 1 : + LibSession.visiblePriority + ) + ), using: dependencies ) } } - private func deleteAllMessagesBeforeNow() { - guard threadVariant == .group else { return } + private func deleteAllMessagesBeforeNow(state: State) { + guard state.threadInfo.variant == .group else { return } - dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in + dependencies[singleton: .storage].writeAsync { [dependencies] db in try LibSession.deleteMessagesBefore( db, - groupSessionId: SessionId(.group, hex: threadId), + groupSessionId: SessionId(.group, hex: state.threadInfo.id), timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), using: dependencies ) } } - private func deleteAllAttachmentsBeforeNow() { - guard threadVariant == .group else { return } + private func deleteAllAttachmentsBeforeNow(state: State) { + guard state.threadInfo.variant == .group else { return } - dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in + dependencies[singleton: .storage].writeAsync { [dependencies] db in try LibSession.deleteAttachmentsBefore( db, - groupSessionId: SessionId(.group, hex: threadId), + groupSessionId: SessionId(.group, hex: state.threadInfo.id), timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), using: dependencies ) @@ -2116,38 +2307,37 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Confirmation Modals - private func updateDisplayNameModal( - threadViewModel: SessionThreadViewModel, - currentUserIsClosedGroupAdmin: Bool - ) -> ConfirmationModal.Info? { - guard !threadViewModel.threadIsNoteToSelf else { return nil } + private func updateDisplayNameModal(state: State) -> ConfirmationModal.Info? { + guard !state.threadInfo.isNoteToSelf else { return nil } - switch (threadViewModel.threadVariant, currentUserIsClosedGroupAdmin) { + switch (state.threadInfo.variant, state.threadInfo.groupInfo?.currentUserRole) { case (.contact, _): return self.updateNickname( - current: threadViewModel.profile?.nickname, + state: state, + current: state.threadInfo.profile?.nickname, displayName: ( /// **Note:** We want to use the `profile` directly rather than `threadViewModel.displayName` /// as the latter would use the `nickname` here which is incorrect - threadViewModel.profile?.displayName(ignoringNickname: true) ?? - threadViewModel.threadId.truncated() + state.threadInfo.profile?.displayName(ignoreNickname: true) ?? + state.threadInfo.displayName.deformatted() ) ) - case (.group, true), (.legacyGroup, true): + case (.group, .admin), (.legacyGroup, .admin): return self.updateGroupNameAndDescription( - currentName: threadViewModel.displayName, - currentDescription: threadViewModel.threadDescription, - isUpdatedGroup: (threadViewModel.threadVariant == .group) + state: state, + currentName: state.threadInfo.displayName.deformatted(), + currentDescription: state.threadInfo.conversationDescription, + isUpdatedGroup: (state.threadInfo.variant == .group) ) - case (.community, _), (.legacyGroup, false), (.group, false): return nil + case (.community, _), (.legacyGroup, _), (.group, _): return nil } } - private func showQRCodeLightBox(for threadViewModel: SessionThreadViewModel) { + private func showQRCodeLightBox(for threadInfo: ConversationInfoViewModel) { let qrCodeImage: UIImage = QRCode.generate( - for: threadViewModel.getQRCodeString(), + for: threadInfo.qrCodeString, hasBackground: false, iconName: "SessionWhite40" // stringlint:ignore ) @@ -2186,3 +2376,40 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi self.transitionToScreen(viewController, transitionType: .present) } } + +// MARK: - Convenience + +private extension ObservedEvent { + var handlingStrategy: EventHandlingStrategy { + let threadInfoStrategy: EventHandlingStrategy? = ConversationInfoViewModel.handlingStrategy(for: self) + let localStrategy: EventHandlingStrategy = { + switch (key, key.generic) { + case (.appLifecycle(.willEnterForeground), _): return .databaseQuery + case (.databaseLifecycle(.resumed), _): return .databaseQuery + + default: return .directCacheUpdate + } + }() + + return localStrategy.union(threadInfoStrategy ?? .none) + } +} + +private extension ConversationInfoViewModel { + var qrCodeString: String { + switch self.variant { + case .contact, .legacyGroup, .group: return id + case .community: + guard + let communityInfo: CommunityInfo = self.communityInfo, + let urlString: String = LibSession.communityUrlFor( + server: communityInfo.server, + roomToken: communityInfo.roomToken, + publicKey: communityInfo.publicKey + ) + else { return "" } + + return urlString + } + } +} diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index 090b46e07a..311cbd00e2 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -1,10 +1,27 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Lucide import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit +// MARK: - ConversationTitleViewModel + +struct ConversationTitleViewModel: Sendable, Equatable { + let threadVariant: SessionThread.Variant + let displayName: String + let isNoteToSelf: Bool + let isMessageRequest: Bool + let showProBadge: Bool + let isMuted: Bool + let onlyNotifyForMentions: Bool + let userCount: Int? + let disappearingMessagesConfig: DisappearingMessagesConfiguration? +} + +// MARK: - ConversationTitleView + final class ConversationTitleView: UIView { private static let leftInset: CGFloat = 8 private static let leftInsetWithCallButton: CGFloat = 54 @@ -82,25 +99,6 @@ final class ConversationTitleView: UIView { // MARK: - Content - public func initialSetup( - with threadVariant: SessionThread.Variant, - isNoteToSelf: Bool, - isMessageRequest: Bool, - isSessionPro: Bool - ) { - self.update( - with: " ", - isNoteToSelf: isNoteToSelf, - isMessageRequest: isMessageRequest, - isSessionPro: isSessionPro, - threadVariant: threadVariant, - mutedUntilTimestamp: nil, - onlyNotifyForMentions: false, - userCount: (threadVariant != .contact ? 0 : nil), - disappearingMessagesConfig: nil - ) - } - override func layoutSubviews() { super.layoutSubviews() @@ -116,36 +114,26 @@ final class ConversationTitleView: UIView { self.oldSize = bounds.size } - @MainActor public func update( - with name: String, - isNoteToSelf: Bool, - isMessageRequest: Bool, - isSessionPro: Bool, - threadVariant: SessionThread.Variant, - mutedUntilTimestamp: TimeInterval?, - onlyNotifyForMentions: Bool, - userCount: Int?, - disappearingMessagesConfig: DisappearingMessagesConfiguration? - ) { + @MainActor public func update(with viewModel: ConversationTitleViewModel) { let shouldHaveSubtitle: Bool = ( - !isMessageRequest && ( - Date().timeIntervalSince1970 <= (mutedUntilTimestamp ?? 0) || - onlyNotifyForMentions || - userCount != nil || - disappearingMessagesConfig?.isEnabled == true + !viewModel.isMessageRequest && ( + viewModel.isMuted || + viewModel.onlyNotifyForMentions || + viewModel.userCount != nil || + viewModel.disappearingMessagesConfig?.isEnabled == true ) ) - self.titleLabel.text = name - self.titleLabel.accessibilityLabel = name + self.titleLabel.text = viewModel.displayName + self.titleLabel.accessibilityLabel = viewModel.displayName self.titleLabel.font = (shouldHaveSubtitle ? Fonts.Headings.H6 : Fonts.Headings.H5) - self.titleLabel.isProBadgeHidden = !isSessionPro + self.titleLabel.isProBadgeHidden = !viewModel.showProBadge self.labelCarouselView.isHidden = !shouldHaveSubtitle // Contact threads also have the call button to compensate for let shouldShowCallButton: Bool = ( - !isNoteToSelf && - threadVariant == .contact + !viewModel.isNoteToSelf && + viewModel.threadVariant == .contact ) self.stackViewLeadingConstraint.constant = (shouldShowCallButton ? ConversationTitleView.leftInsetWithCallButton : @@ -158,15 +146,12 @@ final class ConversationTitleView: UIView { var labelInfos: [SessionLabelCarouselView.LabelInfo] = [] - if Date().timeIntervalSince1970 <= (mutedUntilTimestamp ?? 0) { + if viewModel.isMuted { let notificationSettingsLabelString = ThemedAttributedString( - string: FullConversationCell.mutePrefix, - attributes: [ - .font: UIFont(name: "ElegantIcons", size: 8) as Any, - .themeForegroundColor: ThemeValue.textPrimary - ] + string: NotificationsUI.mutePrefix.rawValue ) .appending(string: "notificationsMuted".localized()) + .stylingNotificationPrefixesIfNeeded(fontSize: Values.miniFontSize) labelInfos.append( SessionLabelCarouselView.LabelInfo( @@ -176,20 +161,13 @@ final class ConversationTitleView: UIView { ) ) } - else if onlyNotifyForMentions { - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(named: "NotifyMentions.png")? - .withRenderingMode(.alwaysTemplate) - imageAttachment.bounds = CGRect( - x: 0, - y: -2, - width: Values.miniFontSize, - height: Values.miniFontSize + else if viewModel.onlyNotifyForMentions { + let notificationSettingsLabelString = ThemedAttributedString( + string: NotificationsUI.mentionPrefix.rawValue ) - - let notificationSettingsLabelString = ThemedAttributedString(attachment: imageAttachment) - .appending(string: " ") - .appending(string: "notificationsMentionsOnly".localized()) + .appending(string: " ") + .appending(string: "notificationsMentionsOnly".localized()) + .stylingNotificationPrefixesIfNeeded(fontSize: Values.miniFontSize) labelInfos.append( SessionLabelCarouselView.LabelInfo( @@ -200,8 +178,8 @@ final class ConversationTitleView: UIView { ) } - if let userCount: Int = userCount { - switch threadVariant { + if let userCount: Int = viewModel.userCount { + switch viewModel.threadVariant { case .contact: break case .legacyGroup, .group: @@ -228,7 +206,7 @@ final class ConversationTitleView: UIView { } } - if let config = disappearingMessagesConfig, config.isEnabled == true { + if let config = viewModel.disappearingMessagesConfig, config.isEnabled == true { let imageAttachment = NSTextAttachment() imageAttachment.image = UIImage(systemName: "timer")? .withRenderingMode(.alwaysTemplate) diff --git a/Session/Conversations/Views & Modals/ReactionListSheet.swift b/Session/Conversations/Views & Modals/ReactionListSheet.swift index 9e568bb8a2..1f772bab6c 100644 --- a/Session/Conversations/Views & Modals/ReactionListSheet.swift +++ b/Session/Conversations/Views & Modals/ReactionListSheet.swift @@ -22,7 +22,7 @@ final class ReactionListSheet: BaseVC { fileprivate let dependencies: Dependencies private let interactionId: Int64 private let onDismiss: (() -> ())? - private var messageViewModel: MessageViewModel = MessageViewModel() + private var messageViewModel: MessageViewModel? private var reactionSummaries: [ReactionSummary] = [] private var selectedReactionUserList: [MessageViewModel.ReactionInfo] = [] private var lastSelectedReactionIndex: Int = 0 @@ -201,24 +201,26 @@ final class ReactionListSheet: BaseVC { // MARK: - Content public func handleInteractionUpdates( - _ allMessages: [MessageViewModel], + _ allMessages: [MessageViewModel?], selectedReaction: EmojiWithSkinTones? = nil, updatedReactionIndex: Int? = nil, initialLoad: Bool = false, shouldShowClearAllButton: Bool = false ) { - guard let cellViewModel: MessageViewModel = allMessages.first(where: { $0.id == self.interactionId }) else { - return - } + guard + let cellViewModel: MessageViewModel = allMessages + .compactMap({ $0 }) + .first(where: { $0.id == self.interactionId }) + else { return } // If we have no more reactions (eg. the user removed the last one) then closed the list sheet - guard cellViewModel.reactionInfo?.isEmpty == false else { + guard !cellViewModel.reactionInfo.isEmpty else { close() return } // Generated the updated data - let updatedReactionInfo: OrderedDictionary = (cellViewModel.reactionInfo ?? []) + let updatedReactionInfo: OrderedDictionary = cellViewModel.reactionInfo .reduce(into: OrderedDictionary()) { result, reactionInfo in guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else { @@ -230,7 +232,7 @@ final class ReactionListSheet: BaseVC { return } - if (cellViewModel.currentUserSessionIds ?? []).contains(reactionInfo.reaction.authorId) { + if cellViewModel.currentUserSessionIds.contains(reactionInfo.reaction.authorId) { updatedValue.insert(reactionInfo, at: 0) } else { @@ -380,7 +382,10 @@ final class ReactionListSheet: BaseVC { @objc private func clearAllTapped() { clearAll() } private func clearAll() { - guard let selectedReaction: EmojiWithSkinTones = self.reactionSummaries.first(where: { $0.isSelected })?.emoji else { return } + guard + let selectedReaction: EmojiWithSkinTones = self.reactionSummaries.first(where: { $0.isSelected })?.emoji, + let messageViewModel: MessageViewModel = self.messageViewModel + else { return } delegate?.removeAllReactions(messageViewModel, for: selectedReaction.rawValue) } @@ -440,8 +445,8 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row] let authorId: String = cellViewModel.reaction.authorId let canRemoveEmoji: Bool = ( - (self.messageViewModel.currentUserSessionIds ?? []).contains(authorId) && - self.messageViewModel.threadVariant != .legacyGroup + self.messageViewModel?.currentUserSessionIds.contains(authorId) == true && + self.messageViewModel?.threadVariant != .legacyGroup ) cell.update( with: SessionCell.Info( @@ -450,7 +455,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { leadingAccessory: .profile(id: authorId, profile: cellViewModel.profile), title: ( cellViewModel.profile?.displayName() ?? - authorId.truncated(threadVariant: self.messageViewModel.threadVariant) + authorId.truncated() ), trailingAccessory: (!canRemoveEmoji ? nil : .icon( @@ -461,7 +466,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { ) ), styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), - isEnabled: (self.messageViewModel.currentUserSessionIds ?? []).contains(authorId) + isEnabled: (self.messageViewModel?.currentUserSessionIds.contains(authorId) == true) ), tableSize: tableView.bounds.size, using: dependencies @@ -482,10 +487,11 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { .first(where: { $0.isSelected })? .emoji, selectedReaction.rawValue == cellViewModel.reaction.emoji, - (self.messageViewModel.currentUserSessionIds ?? []).contains(cellViewModel.reaction.authorId) + let messageViewModel: MessageViewModel = self.messageViewModel, + messageViewModel.currentUserSessionIds.contains(cellViewModel.reaction.authorId) else { return } - delegate?.removeReact(self.messageViewModel, for: selectedReaction) + delegate?.removeReact(messageViewModel, for: selectedReaction) } } diff --git a/Session/Emoji/EmojiWithSkinTones.swift b/Session/Emoji/EmojiWithSkinTones.swift index 776de92dca..084231599a 100644 --- a/Session/Emoji/EmojiWithSkinTones.swift +++ b/Session/Emoji/EmojiWithSkinTones.swift @@ -82,6 +82,7 @@ extension Emoji { .inserting(emoji, at: 0) .prefix(6) .joined(separator: ",") + db.addEvent(.recentReactionsUpdated) } static func allSendableEmojiByCategoryWithPreferredSkinTones(_ db: ObservingDatabase) -> [Category: [EmojiWithSkinTones]] { diff --git a/Session/Home/App Review/AppReviewPromptModel.swift b/Session/Home/App Review/AppReviewPromptModel.swift index 28d8dbddd4..213ce3f434 100644 --- a/Session/Home/App Review/AppReviewPromptModel.swift +++ b/Session/Home/App Review/AppReviewPromptModel.swift @@ -2,6 +2,7 @@ import Foundation import SessionUIKit +import SessionMessagingKit import SessionUtilitiesKit struct AppReviewPromptModel { @@ -104,7 +105,7 @@ enum AppReviewPromptState { case .rateSession: /// In this case the full `platformStore` value was found to be too verbose so remove the leading `Apple ` /// to make it more succinct - let storeVaraint: String = Constants.platform_store + let storeVariant: String = Constants.PaymentProvider.appStore.store .replacingOccurrences(of: "Apple ", with: "") // stringlint:ignore return AppReviewPromptModel( @@ -113,7 +114,7 @@ enum AppReviewPromptState { .localized(), message: "rateSessionModalDescriptionUpdated" .put(key: "app_name", value: Constants.app_name) - .put(key: "storevariant", value: storeVaraint) + .put(key: "storevariant", value: storeVariant) .localized(), primaryButtonTitle: "rateUs".localized(), primaryButtonColor: .sessionButton_text, diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index a9cb4a0dd5..84078159da 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -9,6 +9,9 @@ import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit +private typealias ConversationSearchResult = GlobalSearch.ConversationSearchResult +private typealias MessageSearchResult = GlobalSearch.MessageSearchResult + // MARK: - Log.Category private extension Log.Category { @@ -18,11 +21,23 @@ private extension Log.Category { // MARK: - GlobalSearchViewController class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UITableViewDelegate, UITableViewDataSource { - fileprivate typealias SectionModel = ArraySection + fileprivate typealias SectionModel = ArraySection - fileprivate struct SearchResultData: Equatable { + fileprivate class SearchResultData: Equatable { var state: SearchResultsState var data: [SectionModel] + + init(state: SearchResultsState, data: [SectionModel]) { + self.state = state + self.data = data + } + + static func == (lhs: SearchResultData, rhs: SearchResultData) -> Bool { + return ( + lhs.state == rhs.state && + lhs.data.count == rhs.data.count + ) + } } enum SearchResultsState: Int, Differentiable { @@ -32,6 +47,7 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI } // MARK: - SearchSection + enum SearchSection: Codable, Hashable, Differentiable { case contactsAndGroups case messages @@ -42,9 +58,9 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI let isConversationList: Bool = true - func forceRefreshIfNeeded() { + @MainActor func forceRefreshIfNeeded() { // Need to do this as the 'GlobalSearchViewController' doesn't observe database changes - updateSearchResults(searchText: searchText, force: true) + updateSearchResults(searchText: searchText, currentCache: dataCache, force: true) } // MARK: - Variables @@ -64,18 +80,43 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI } } private lazy var defaultSearchResultsObservation = ValueObservation - .trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in - try SessionThreadViewModel - .defaultContactsQuery(using: dependencies) + .trackingConstantRegion { [dependencies] db -> ([ConversationSearchResult], ConversationDataCache) in + let results: [ConversationSearchResult] = try ConversationSearchResult + .defaultContactsQuery(userSessionId: dependencies[cache: .general].sessionId) .fetchAll(db) + let cache: ConversationDataCache = try ConversationDataHelper.generateCacheForDefaultContacts( + ObservingDatabase.create(db, using: dependencies), + contactIds: results.map { $0.id }, + using: dependencies + ) + + return (results, cache) + } + .map { [dependencies] results, cache in + GlobalSearch.processDefaultSearchResults( + results: results, + cache: cache, + using: dependencies + ) } - .map { GlobalSearchViewController.processDefaultSearchResults($0) } .removeDuplicates() .handleEvents(didFail: { Log.error(.cat, "Observation failed with error: \($0)") }) private var defaultDataChangeObservable: DatabaseCancellable? { didSet { oldValue?.cancel() } // Cancel the old observable if there was one } + /// Generating the search results is somewhat inefficient but since the user is typing then caching individual ViewModel values is + /// unlikely to result in any cache hits, the one case where it might is if the user backspaces and enters a new character. In that + /// case it is far simpler to just cache the full result set against the search term (while this could result in stale data, it's unlikely + /// to be an issue as users generally wouldn't sit on the search results screen and expect updates to come through). + private let searchResultCache: NSCache = { + let result: NSCache = NSCache() + result.name = "GlobalSearchResultCache" // stringlint:ignore + result.countLimit = 10 /// Last 10 result sets + + return result + }() + @ThreadSafeObject private var currentSearchCancellable: AnyCancellable? = nil private lazy var searchResultSet: SearchResultData = defaultSearchResults private var termForCurrentSearchResultSet: String = "" @@ -84,18 +125,30 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI var isLoading = false - @objc public var searchText = "" { + @MainActor public var searchText = "" { didSet { Log.assertOnMainThread() // Use a slight delay to debounce updates. refreshSearchResults() } } + @MainActor private var dataCache: ConversationDataCache // MARK: - Initialization init(using dependencies: Dependencies) { self.dependencies = dependencies + self.dataCache = ConversationDataCache( + userSessionId: dependencies[cache: .general].sessionId, + context: ConversationDataCache.Context( + source: .searchResults, + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ) super.init(nibName: nil, bundle: nil) } @@ -216,74 +269,18 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI // MARK: - Update Search Results - nonisolated private static func processDefaultSearchResults(_ contacts: [SessionThreadViewModel]) -> SearchResultData { - let nonalphabeticNameTitle: String = "#" // stringlint:ignore - - return SearchResultData( - state: .defaultContacts, - data: contacts - .sorted { lhs, rhs in lhs.displayName.lowercased() < rhs.displayName.lowercased() } - .filter { $0.isContactApproved == true } // Only show default contacts that have been approved via message request - .reduce(into: [String: SectionModel]()) { result, next in - guard !next.threadIsNoteToSelf else { - result[""] = SectionModel( - model: .groupedContacts(title: ""), - elements: [next] - ) - return - } - - let displayName = NSMutableString(string: next.displayName) - CFStringTransform(displayName, nil, kCFStringTransformToLatin, false) - CFStringTransform(displayName, nil, kCFStringTransformStripDiacritics, false) - - let initialCharacter: String = (displayName.length > 0 ? displayName.substring(to: 1) : "") - let section: String = (initialCharacter.capitalized.isSingleAlphabet ? - initialCharacter.capitalized : - nonalphabeticNameTitle - ) - - if result[section] == nil { - result[section] = SectionModel( - model: .groupedContacts(title: section), - elements: [] - ) - } - result[section]?.elements.append(next) - } - .values - .sorted { sectionModel0, sectionModel1 in - let title0: String = { - switch sectionModel0.model { - case .groupedContacts(let title): return title - default: return "" - } - }() - let title1: String = { - switch sectionModel1.model { - case .groupedContacts(let title): return title - default: return "" - } - }() - - if ![title0, title1].contains(nonalphabeticNameTitle) { - return title0 < title1 - } - - return title1 == nonalphabeticNameTitle - } - ) - } - private func refreshSearchResults() { refreshTimer?.invalidate() refreshTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 0.1, using: dependencies) { [weak self] _ in - self?.updateSearchResults(searchText: (self?.searchText ?? "")) + guard let self else { return } + + updateSearchResults(searchText: searchText, currentCache: dataCache) } } private func updateSearchResults( searchText rawSearchText: String, + currentCache: ConversationDataCache, force: Bool = false ) { let searchText = rawSearchText.stripped @@ -301,27 +298,62 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI lastSearchText = searchText _currentSearchCancellable.perform { $0?.cancel() } + /// Check for a cache hit before performing the search + if let cachedResult: SearchResultData = searchResultCache.object(forKey: searchText as NSString) { + DispatchQueue.main.async { [weak self] in + self?.termForCurrentSearchResultSet = searchText + self?.searchResultSet = cachedResult + self?.isLoading = false + self?.tableView.reloadData() + self?.refreshTimer = nil + } + return + } + + let userSessionId: SessionId = dependencies[cache: .general].sessionId _currentSearchCancellable.set(to: dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> [SectionModel] in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel - .contactsAndGroupsQuery( + .readPublisher { [dependencies] db -> ([ConversationSearchResult], [MessageSearchResult], ConversationDataCache) in + let searchPattern: FTS5Pattern = try GlobalSearch.pattern(db, searchTerm: searchText) + let conversationResults: [ConversationSearchResult] = try ConversationSearchResult + .query( userSessionId: userSessionId, - pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText), + pattern: searchPattern, searchTerm: searchText ) .fetchAll(db) - let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel - .messagesQuery( + let messageResults: [MessageSearchResult] = try MessageSearchResult + .query( userSessionId: userSessionId, - pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) + pattern: searchPattern ) .fetchAll(db) + let cache: ConversationDataCache = try ConversationDataHelper.updateCacheForSearchResults( + db, + currentCache: currentCache, + conversationResults: conversationResults, + messageResults: messageResults, + using: dependencies + ) - return [ - ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults), - ArraySection(model: .messages, elements: messageResults) - ] + return (conversationResults, messageResults, cache) + } + .tryMap { [dependencies] conversationResults, messageResults, cache -> ([SectionModel], ConversationDataCache) in + let (conversationViewModels, messageViewModels) = ConversationDataHelper.processSearchResults( + cache: cache, + searchText: searchText, + conversationResults: conversationResults, + messageResults: messageResults, + userSessionId: userSessionId, + using: dependencies + ) + + return ( + [ + ArraySection(model: .contactsAndGroups, elements: conversationViewModels), + ArraySection(model: .messages, elements: messageViewModels) + ], + cache + ) } .subscribe(on: DispatchQueue.global(qos: .default), using: dependencies) .receive(on: DispatchQueue.main, using: dependencies) @@ -335,13 +367,16 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI Log.error(.cat, "Failed to find results due to error: \(error)") } }, - receiveValue: { [weak self] sections in - self?.termForCurrentSearchResultSet = searchText - self?.searchResultSet = SearchResultData( + receiveValue: { [weak self] sections, updatedCache in + let result: SearchResultData = SearchResultData( state: (sections.map { $0.elements.count }.reduce(0, +) > 0) ? .results : .none, data: sections ) + self?.termForCurrentSearchResultSet = searchText + self?.searchResultSet = result self?.isLoading = false + self?.dataCache = updatedCache + self?.searchResultCache.setObject(result, forKey: searchText as NSString) self?.tableView.reloadData() self?.refreshTimer = nil } @@ -390,29 +425,27 @@ extension GlobalSearchViewController { tableView.deselectRow(at: indexPath, animated: false) let section: SectionModel = self.searchResultSet.data[indexPath.section] + let focusedInteractionInfo: Interaction.TimestampInfo? = { + switch section.model { + case .groupedContacts: return nil + case .contactsAndGroups, .messages: + guard + let interactionId: Int64 = section.elements[indexPath.row].targetInteraction?.id, + let timestampMs: Int64 = section.elements[indexPath.row].targetInteraction?.timestampMs + else { return nil } + + return Interaction.TimestampInfo( + id: interactionId, + timestampMs: timestampMs + ) + } + }() - switch section.model { - case .contactsAndGroups, .messages: - show( - threadId: section.elements[indexPath.row].threadId, - threadVariant: section.elements[indexPath.row].threadVariant, - focusedInteractionInfo: { - guard - let interactionId: Int64 = section.elements[indexPath.row].interactionId, - let timestampMs: Int64 = section.elements[indexPath.row].interactionTimestampMs - else { return nil } - - return Interaction.TimestampInfo( - id: interactionId, - timestampMs: timestampMs - ) - }() - ) - case .groupedContacts: - show( - threadId: section.elements[indexPath.row].threadId, - threadVariant: section.elements[indexPath.row].threadVariant - ) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.show( + viewModel: section.elements[indexPath.row], + focusedInteractionInfo: focusedInteractionInfo + ) } } @@ -422,10 +455,10 @@ extension GlobalSearchViewController { switch section.model { case .contactsAndGroups, .messages: return nil case .groupedContacts: - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let viewModel: ConversationInfoViewModel = section.elements[indexPath.row] /// No actions for `Note to Self` - guard !threadViewModel.threadIsNoteToSelf else { return nil } + guard !viewModel.isNoteToSelf else { return nil } return UIContextualAction.configuration( for: UIContextualAction.generateSwipeActions( @@ -433,7 +466,7 @@ extension GlobalSearchViewController { for: .trailing, indexPath: indexPath, tableView: tableView, - threadViewModel: threadViewModel, + threadInfo: viewModel, viewController: self, navigatableStateHolder: nil, using: dependencies @@ -443,39 +476,42 @@ extension GlobalSearchViewController { } private func show( - threadId: String, - threadVariant: SessionThread.Variant, + viewModel: ConversationInfoViewModel, focusedInteractionInfo: Interaction.TimestampInfo? = nil, animated: Bool = true - ) { - guard Thread.isMainThread else { - DispatchQueue.main.async { [weak self] in - self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo, animated: animated) - } - return - } - - // If it's a one-to-one thread then make sure the thread exists before pushing to it (in case the - // contact has been hidden) - if threadVariant == .contact { - dependencies[singleton: .storage].write { [dependencies] db in + ) async { + /// If it's a one-to-one thread then make sure the thread exists before pushing to it (in case the contact has been hidden) + if viewModel.variant == .contact { + _ = try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in try SessionThread.upsert( db, - id: threadId, - variant: threadVariant, + id: viewModel.id, + variant: viewModel.variant, values: .existingOrDefault, using: dependencies ) } } - let viewController: ConversationVC = ConversationVC( - threadId: threadId, - threadVariant: threadVariant, - focusedInteractionInfo: focusedInteractionInfo, + /// Need to fetch the "full" data for the conversation screen + let maybeThreadInfo: ConversationInfoViewModel? = try? await ConversationViewModel.fetchConversationInfo( + threadId: viewModel.id, using: dependencies ) - self.navigationController?.pushViewController(viewController, animated: true) + + guard let finalThreadInfo: ConversationInfoViewModel = maybeThreadInfo else { + Log.error("Failed to present \(viewModel.variant) conversation \(viewModel.id) due to failure to fetch viewModel") + return + } + + await MainActor.run { + let viewController: ConversationVC = ConversationVC( + threadInfo: finalThreadInfo, + focusedInteractionInfo: focusedInteractionInfo, + using: dependencies + ) + self.navigationController?.pushViewController(viewController, animated: true) + } } // MARK: - UITableViewDataSource @@ -584,3 +620,76 @@ extension GlobalSearchViewController { } } } + +// MARK: - Convenience + +private extension GlobalSearch { + static func processDefaultSearchResults( + results: [GlobalSearch.ConversationSearchResult], + cache: ConversationDataCache, + using dependencies: Dependencies + ) -> GlobalSearchViewController.SearchResultData { + let nonalphabeticNameTitle: String = "#" // stringlint:ignore + let contacts: [ConversationInfoViewModel] = ConversationDataHelper.processDefaultContacts( + cache: cache, + contactIds: results.map { $0.id }, + userSessionId: dependencies[cache: .general].sessionId, + using: dependencies + ) + + return GlobalSearchViewController.SearchResultData( + state: .defaultContacts, + data: contacts + .sorted { lhs, rhs in lhs.displayName.deformatted().lowercased() < rhs.displayName.deformatted().lowercased() } + .filter { $0.isMessageRequest == false } /// Exclude message requests from the default contacts + .reduce(into: [String: GlobalSearchViewController.SectionModel]()) { result, next in + guard !next.isNoteToSelf else { + result[""] = GlobalSearchViewController.SectionModel( + model: .groupedContacts(title: ""), + elements: [next] + ) + return + } + + let displayName = NSMutableString(string: next.displayName.deformatted()) + CFStringTransform(displayName, nil, kCFStringTransformToLatin, false) + CFStringTransform(displayName, nil, kCFStringTransformStripDiacritics, false) + + let initialCharacter: String = (displayName.length > 0 ? displayName.substring(to: 1) : "") + let section: String = (initialCharacter.capitalized.isSingleAlphabet ? + initialCharacter.capitalized : + nonalphabeticNameTitle + ) + + if result[section] == nil { + result[section] = GlobalSearchViewController.SectionModel( + model: .groupedContacts(title: section), + elements: [] + ) + } + result[section]?.elements.append(next) + } + .values + .sorted { sectionModel0, sectionModel1 in + let title0: String = { + switch sectionModel0.model { + case .groupedContacts(let title): return title + default: return "" + } + }() + let title1: String = { + switch sectionModel1.model { + case .groupedContacts(let title): return title + default: return "" + } + }() + + if ![title0, title1].contains(nonalphabeticNameTitle) { + return title0 < title1 + } + + return title1 == nonalphabeticNameTitle + } + ) + } +} diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 4385dfd86d..2aff904a83 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -324,7 +324,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi serviceNetwork: self.viewModel.state.serviceNetwork, forceOffline: self.viewModel.state.forceOffline ) - setUpNavBarSessionHeading(currentUserSessionProState: viewModel.dependencies[singleton: .sessionProState]) + setUpNavBarSessionHeading(sessionProUIManager: viewModel.dependencies[singleton: .sessionProManager]) // Banner stack view view.addSubview(bannersStackView) @@ -375,7 +375,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi // Onion request path countries cache Task.detached(priority: .background) { [dependencies = viewModel.dependencies] in - dependencies.warmCache(cache: .ip2Country) + dependencies.warm(cache: .ip2Country) } // Bind the UI to the view model @@ -578,19 +578,19 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi switch section.model { case .messageRequests: - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row] let cell: MessageRequestsCell = tableView.dequeue(type: MessageRequestsCell.self, for: indexPath) cell.accessibilityIdentifier = "Message requests banner" cell.isAccessibilityElement = true - cell.update(with: Int(threadViewModel.threadUnreadCount ?? 0)) + cell.update(with: threadInfo.unreadCount) return cell case .threads: - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row] let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) - cell.update(with: threadViewModel, using: viewModel.dependencies) + cell.update(with: threadInfo, using: viewModel.dependencies) cell.accessibilityIdentifier = "Conversation list item" - cell.accessibilityLabel = threadViewModel.displayName + cell.accessibilityLabel = threadInfo.displayName.deformatted() return cell default: preconditionFailure("Other sections should have no content") @@ -630,7 +630,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi public func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { switch sections[section].model { - case .loadMore: self.viewModel.loadNextPage() + case .loadMore: self.viewModel.loadPageAfter() default: break } } @@ -648,10 +648,9 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi self.navigationController?.pushViewController(viewController, animated: true) case .threads: - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row] let viewController: ConversationVC = ConversationVC( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadInfo: threadInfo, focusedInteractionInfo: nil, using: viewModel.dependencies ) @@ -675,7 +674,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi public func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let section: HomeViewModel.SectionModel = sections[indexPath.section] - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row] switch section.model { case .threads: @@ -683,11 +682,11 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi // the 'Note to Self' conversation also doesn't support 'mark as unread' so don't // provide it there either guard - threadViewModel.threadVariant != .legacyGroup && - threadViewModel.threadId != threadViewModel.currentUserSessionId && ( - threadViewModel.threadVariant != .contact || - (try? SessionId(from: section.elements[indexPath.row].threadId))?.prefix == .standard - ) + threadInfo.variant != .legacyGroup && + threadInfo.id != threadInfo.userSessionId.hexString && ( + threadInfo.variant != .contact || + (try? SessionId(from: section.elements[indexPath.row].id))?.prefix == .standard + ) else { return nil } return UIContextualAction.configuration( @@ -696,7 +695,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi for: .leading, indexPath: indexPath, tableView: tableView, - threadViewModel: threadViewModel, + threadInfo: threadInfo, viewController: self, navigatableStateHolder: viewModel, using: viewModel.dependencies @@ -709,7 +708,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let section: HomeViewModel.SectionModel = sections[indexPath.section] - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row] switch section.model { case .messageRequests: @@ -719,7 +718,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi for: .trailing, indexPath: indexPath, tableView: tableView, - threadViewModel: threadViewModel, + threadInfo: threadInfo, viewController: self, navigatableStateHolder: viewModel, using: viewModel.dependencies @@ -727,13 +726,13 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi ) case .threads: - let sessionIdPrefix: SessionId.Prefix? = try? SessionId.Prefix(from: threadViewModel.threadId) + let sessionIdPrefix: SessionId.Prefix? = try? SessionId.Prefix(from: threadInfo.id) // Cannot properly sync outgoing blinded message requests so only provide valid options let shouldHavePinAction: Bool = { - switch threadViewModel.threadVariant { + switch threadInfo.variant { // Only allow unpin for legacy groups - case .legacyGroup: return threadViewModel.threadPinnedPriority > 0 + case .legacyGroup: return (threadInfo.pinnedPriority > 0) default: return ( @@ -743,23 +742,22 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi } }() let shouldHaveMuteAction: Bool = { - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .contact: return ( - !threadViewModel.threadIsNoteToSelf && + !threadInfo.isNoteToSelf && sessionIdPrefix != .blinded15 && sessionIdPrefix != .blinded25 ) - case .group: return (threadViewModel.currentUserIsClosedGroupMember == true) - + case .group: return (threadInfo.groupInfo?.currentUserRole != nil) case .legacyGroup: return false case .community: return true } }() let destructiveAction: UIContextualAction.SwipeAction = { - switch (threadViewModel.threadVariant, threadViewModel.threadIsNoteToSelf, threadViewModel.currentUserIsClosedGroupMember, threadViewModel.currentUserIsClosedGroupAdmin) { - case (.contact, true, _, _): return .hide - case (.group, _, true, false), (.community, _, _, _): return .leave + switch (threadInfo.variant, threadInfo.isNoteToSelf, threadInfo.groupInfo?.currentUserRole) { + case (.contact, true, _): return .hide + case (.group, _, .standard), (.community, _, _): return .leave default: return .delete } }() @@ -774,7 +772,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi for: .trailing, indexPath: indexPath, tableView: tableView, - threadViewModel: threadViewModel, + threadInfo: threadInfo, viewController: self, navigatableStateHolder: viewModel, using: viewModel.dependencies diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 9372fb984b..0340850010 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -5,6 +5,7 @@ import Combine import GRDB import DifferenceKit import SignalUtilitiesKit +import SessionNetworkingKit import SessionMessagingKit import SessionUtilitiesKit import StoreKit @@ -21,7 +22,7 @@ public extension Log.Category { public class HomeViewModel: NavigatableStateHolder { public let navigatableState: NavigatableState = NavigatableState() - public typealias SectionModel = ArraySection + public typealias SectionModel = ArraySection // MARK: - Section @@ -74,6 +75,7 @@ public class HomeViewModel: NavigatableStateHolder { } deinit { + observationTask?.cancel() NotificationCenter.default.removeObserver(self) } @@ -100,9 +102,11 @@ public class HomeViewModel: NavigatableStateHolder { let showViewedSeedBanner: Bool let hasHiddenMessageRequests: Bool let unreadMessageRequestThreadCount: Int - let loadedPageInfo: PagedData.LoadedInfo - let itemCache: [String: SessionThreadViewModel] - let profileCache: [String: Profile] + + let loadedPageInfo: PagedData.LoadedInfo + let dataCache: ConversationDataCache + let itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel] + let appReviewPromptState: AppReviewPromptState? let pendingAppReviewPromptState: AppReviewPromptState? let appWasInstalledPriorToAppReviewRelease: Bool @@ -118,10 +122,13 @@ public class HomeViewModel: NavigatableStateHolder { .appLifecycle(.willEnterForeground), .databaseLifecycle(.resumed), .loadPage(HomeViewModel.self), + .conversationCreated, .messageRequestAccepted, .messageRequestDeleted, .messageRequestMessageRead, .messageRequestUnreadMessageReceived, + .anyMessageCreatedInAnyConversation, + .anyContactBlockedStatusChanged, .profile(userProfile.id), .feature(.serviceNetwork), .feature(.forceOffline), @@ -129,9 +136,6 @@ public class HomeViewModel: NavigatableStateHolder { .setting(.hasSavedMessage), .setting(.hasViewedSeed), .setting(.hasHiddenMessageRequests), - .conversationCreated, - .anyMessageCreatedInAnyConversation, - .anyContactBlockedStatusChanged, .userDefault(.hasVisitedPathScreen), .userDefault(.hasPressedDonateButton), .userDefault(.hasChangedTheme), @@ -141,26 +145,7 @@ public class HomeViewModel: NavigatableStateHolder { .showDonationsCTAModal ] - itemCache.values.forEach { threadViewModel in - result.insert(contentsOf: [ - .conversationUpdated(threadViewModel.threadId), - .conversationDeleted(threadViewModel.threadId), - .messageCreated(threadId: threadViewModel.threadId), - .messageUpdated( - id: threadViewModel.interactionId, - threadId: threadViewModel.threadId - ), - .messageDeleted( - id: threadViewModel.interactionId, - threadId: threadViewModel.threadId - ), - .typingIndicator(threadViewModel.threadId) - ]) - - if let authorId: String = threadViewModel.authorId { - result.insert(.profile(authorId)) - } - } + result.insert(contentsOf: Set(itemCache.values.flatMap { $0.observedKeys })) return result } @@ -171,9 +156,11 @@ public class HomeViewModel: NavigatableStateHolder { appWasInstalledPriorToAppReviewRelease: Bool, showVersionSupportBanner: Bool ) -> State { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + return State( viewState: .loading, - userProfile: Profile(id: dependencies[cache: .general].sessionId.hexString, name: ""), + userProfile: Profile.with(id: userSessionId.hexString, name: ""), serviceNetwork: dependencies[feature: .serviceNetwork], forceOffline: dependencies[feature: .forceOffline], hasSavedThread: false, @@ -182,20 +169,25 @@ public class HomeViewModel: NavigatableStateHolder { hasHiddenMessageRequests: false, unreadMessageRequestThreadCount: 0, loadedPageInfo: PagedData.LoadedInfo( - record: SessionThreadViewModel.self, + record: SessionThread.self, pageSize: HomeViewModel.pageSize, - /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed - /// for the query but differs from the JOINs that are actually used for performance reasons as the - /// basic logic can be simpler for where it's used - requiredJoinSQL: SessionThreadViewModel.optimisedJoinSQL, - filterSQL: SessionThreadViewModel.homeFilterSQL( - userSessionId: dependencies[cache: .general].sessionId - ), - groupSQL: SessionThreadViewModel.groupSQL, - orderSQL: SessionThreadViewModel.homeOrderSQL + requiredJoinSQL: ConversationInfoViewModel.requiredJoinSQL, + filterSQL: ConversationInfoViewModel.homeFilterSQL(userSessionId: userSessionId), + groupSQL: nil, + orderSQL: ConversationInfoViewModel.homeOrderSQL + ), + dataCache: ConversationDataCache( + userSessionId: userSessionId, + context: ConversationDataCache.Context( + source: .conversationList, + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) ), itemCache: [:], - profileCache: [:], appReviewPromptState: nil, pendingAppReviewPromptState: appReviewPromptState, appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease, @@ -221,8 +213,9 @@ public class HomeViewModel: NavigatableStateHolder { var hasHiddenMessageRequests: Bool = previousState.hasHiddenMessageRequests var unreadMessageRequestThreadCount: Int = previousState.unreadMessageRequestThreadCount var loadResult: PagedData.LoadResult = previousState.loadedPageInfo.asResult - var itemCache: [String: SessionThreadViewModel] = previousState.itemCache - var profileCache: [String: Profile] = previousState.profileCache + var dataCache: ConversationDataCache = previousState.dataCache + var itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel] = previousState.itemCache + var appReviewPromptState: AppReviewPromptState? = previousState.appReviewPromptState var pendingAppReviewPromptState: AppReviewPromptState? = previousState.pendingAppReviewPromptState let appWasInstalledPriorToAppReviewRelease: Bool = previousState.appWasInstalledPriorToAppReviewRelease @@ -258,8 +251,7 @@ public class HomeViewModel: NavigatableStateHolder { userProfile = userProfile.with(displayPictureUrl: .set(to: nil)) } - // TODO: [Database Relocation] All profiles should be stored in the `profileCache` - profileCache[userProfile.id] = userProfile + dataCache.insert(userProfile) /// If we haven't hidden the message requests banner then we should include that in the initial fetch if !hasHiddenMessageRequests { @@ -271,86 +263,51 @@ public class HomeViewModel: NavigatableStateHolder { } /// If there are no events we want to process then just return the current state - guard !eventsToProcess.isEmpty else { return previousState } + guard isInitialQuery || !eventsToProcess.isEmpty else { return previousState } /// Split the events between those that need database access and those that don't - let splitEvents: [EventDataRequirement: Set] = eventsToProcess - .reduce(into: [:]) { result, next in - switch next.dataRequirement { - case .databaseQuery: result[.databaseQuery, default: []].insert(next) - case .other: result[.other, default: []].insert(next) - case .bothDatabaseQueryAndOther: - result[.databaseQuery, default: []].insert(next) - result[.other, default: []].insert(next) - } - } - let groupedOtherEvents: [GenericObservableKey: Set]? = splitEvents[.other]? - .reduce(into: [:]) { result, event in - result[event.key.generic, default: []].insert(event) - } + let changes: EventChangeset = eventsToProcess.split(by: { $0.handlingStrategy }) + let loadPageEvent: LoadPageEvent? = changes.latestGeneric(.loadPage, as: LoadPageEvent.self) + + /// Update the context + dataCache.withContext( + source: .conversationList, + requireFullRefresh: ( + isInitialQuery || + changes.containsAny( + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed) + ) + ), + requiresMessageRequestCountUpdate: changes.containsAny( + .messageRequestUnreadMessageReceived, + .messageRequestAccepted, + .messageRequestDeleted, + .messageRequestMessageRead + ) + ) - /// Handle profile events first - groupedOtherEvents?[.profile]?.forEach { event in - guard - let eventValue: ProfileEvent = event.value as? ProfileEvent, - eventValue.id == userProfile.id - else { return } - - switch eventValue.change { - case .name(let name): userProfile = userProfile.with(name: name) - case .nickname(let nickname): userProfile = userProfile.with(nickname: .set(to: nickname)) - case .displayPictureUrl(let url): userProfile = userProfile.with(displayPictureUrl: .set(to: url)) - } - - // TODO: [Database Relocation] All profiles should be stored in the `profileCache` - profileCache[eventValue.id] = userProfile - } + /// Process cache updates first + dataCache = await ConversationDataHelper.applyNonDatabaseEvents( + changes, + currentCache: dataCache, + using: dependencies + ) - /// Then handle database events - if !dependencies[singleton: .storage].isSuspended, let databaseEvents: Set = splitEvents[.databaseQuery], !databaseEvents.isEmpty { + /// Then determine the fetch requirements + let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements( + for: changes, + currentCache: dataCache, + itemCache: itemCache, + loadPageEvent: loadPageEvent + ) + + /// Peform any database changes + if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch { do { - var fetchedConversations: [SessionThreadViewModel] = [] - let idsNeedingRequery: Set = self.extractIdsNeedingRequery( - events: databaseEvents, - cache: itemCache - ) - let loadPageEvent: LoadPageEvent? = databaseEvents - .first(where: { $0.key.generic == .loadPage })? - .value as? LoadPageEvent - - /// Identify any inserted/deleted records - var insertedIds: Set = [] - var deletedIds: Set = [] - - databaseEvents.forEach { event in - switch (event.key.generic, event.value) { - case (GenericObservableKey(.messageRequestAccepted), let threadId as String): - insertedIds.insert(threadId) - - case (GenericObservableKey(.conversationCreated), let event as ConversationEvent): - insertedIds.insert(event.id) - - case (GenericObservableKey(.anyMessageCreatedInAnyConversation), let event as MessageEvent): - insertedIds.insert(event.threadId) - - case (.conversationDeleted, let event as ConversationEvent): - deletedIds.insert(event.id) - - case (GenericObservableKey(.anyContactBlockedStatusChanged), let event as ContactEvent): - if case .isBlocked(true) = event.change { - deletedIds.insert(event.id) - } - else if case .isBlocked(false) = event.change { - insertedIds.insert(event.id) - } - - default: break - } - } - try await dependencies[singleton: .storage].readAsync { db in /// Update the `unreadMessageRequestThreadCount` if needed (since multiple events need this) - if databaseEvents.contains(where: { $0.requiresMessageRequestCountUpdate }) { + if fetchRequirements.requiresMessageRequestCountUpdate { // TODO: [Database Relocation] Should be able to clean this up by getting the conversation list and filtering struct ThreadIdVariant: Decodable, Hashable, FetchableRecord { let id: String @@ -382,54 +339,41 @@ public class HomeViewModel: NavigatableStateHolder { .fetchCount(db) } - /// Update loaded page info as needed (any change to a conversation could result in an order change so reload - /// the paged data if needed (as that will fetch the correct order) - if - loadPageEvent != nil || - !idsNeedingRequery.isEmpty || - !insertedIds.isEmpty || - !deletedIds.isEmpty - { - loadResult = try loadResult.load( - db, - target: ( - loadPageEvent?.target(with: loadResult) ?? - .reloadCurrent(insertedIds: insertedIds, deletedIds: deletedIds) - ) - ) - } - - /// Fetch any records needed - fetchedConversations.append( - contentsOf: try SessionThreadViewModel - .query( - userSessionId: dependencies[cache: .general].sessionId, - groupSQL: SessionThreadViewModel.groupSQL, - orderSQL: SessionThreadViewModel.homeOrderSQL, - ids: Array(idsNeedingRequery) + loadResult.newIds - ) - .fetchAll(db) + /// Fetch any required data from the cache + (loadResult, dataCache) = try ConversationDataHelper.fetchFromDatabase( + db, + requirements: fetchRequirements, + currentCache: dataCache, + loadResult: loadResult, + loadPageEvent: loadPageEvent, + using: dependencies ) } - - /// Update the `itemCache` with the newly fetched values - fetchedConversations.forEach { itemCache[$0.threadId] = $0 } - - /// Remove any deleted values - deletedIds.forEach { id in itemCache.removeValue(forKey: id) } } catch { - let eventList: String = databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") + let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") Log.critical(.homeViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)") } } - else if let databaseEvents: Set = splitEvents[.databaseQuery], !databaseEvents.isEmpty { - Log.warn(.homeViewModel, "Ignored \(databaseEvents.count) database event(s) sent while storage was suspended.") + else if !changes.databaseEvents.isEmpty { + Log.warn(.homeViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") + } + + /// Peform any `libSession` changes + if fetchRequirements.needsAnyFetch { + do { + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) + } + catch { + Log.warn(.homeViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") + } } /// Then handle remaining non-database events - groupedOtherEvents?[.setting]?.forEach { event in - guard let updatedValue: Bool = event.value as? Bool else { return } - + changes.forEachEvent(.setting, as: Bool.self) { event, updatedValue in switch event.key { case .setting(.hasSavedThread): hasSavedThread = (updatedValue || hasSavedThread) case .setting(.hasSavedMessage): hasSavedMessage = (updatedValue || hasSavedMessage) @@ -438,29 +382,30 @@ public class HomeViewModel: NavigatableStateHolder { default: break } } - groupedOtherEvents?[.feature]?.forEach { event in - if event.key == .feature(.serviceNetwork), let updatedValue = event.value as? ServiceNetwork { - serviceNetwork = updatedValue - } - else if event.key == .feature(.forceOffline), let updatedValue = event.value as? Bool { - forceOffline = updatedValue - } - else if event.key == .feature(.versionDeprecationWarning), let updatedValue = event.value as? Bool { - showVersionSupportBanner = isOSVersionDeprecated(using: dependencies) && updatedValue - } - else if event.key == .feature(.versionDeprecationMinimum) { - showVersionSupportBanner = isOSVersionDeprecated(using: dependencies) && dependencies[feature: .versionDeprecationWarning] - } + + if let updatedValue: ServiceNetwork = changes.latest(.feature(.serviceNetwork), as: ServiceNetwork.self) { + serviceNetwork = updatedValue + } + + if let updatedValue: Bool = changes.latest(.feature(.forceOffline), as: Bool.self) { + forceOffline = updatedValue + } + + // FIXME: Should be able to consolodate these two into a single value + if let updatedValue: Bool = changes.latest(.feature(.versionDeprecationWarning), as: Bool.self) { + showVersionSupportBanner = (isOSVersionDeprecated(using: dependencies) && updatedValue) + } + + if changes.latest(.feature(.versionDeprecationMinimum), as: Int.self) != nil { + showVersionSupportBanner = (isOSVersionDeprecated(using: dependencies) && dependencies[feature: .versionDeprecationWarning]) } /// Next trigger should be ignored if `didShowAppReviewPrompt` is true if dependencies[defaults: .standard, key: .didShowAppReviewPrompt] == true { pendingAppReviewPromptState = nil } else { - groupedOtherEvents?[.userDefault]?.forEach { event in - guard let value: Bool = event.value as? Bool else { return } - - switch (event.key, value, appWasInstalledPriorToAppReviewRelease) { + changes.forEachEvent(.userDefault, as: Bool.self) { event, updatedValue in + switch (event.key, updatedValue, appWasInstalledPriorToAppReviewRelease) { case (.userDefault(.hasVisitedPathScreen), true, false): pendingAppReviewPromptState = .enjoyingSession @@ -475,19 +420,30 @@ public class HomeViewModel: NavigatableStateHolder { } } - if let event: HomeViewModelEvent = events.first?.value as? HomeViewModelEvent { - pendingAppReviewPromptState = event.pendingAppReviewPromptState - appReviewPromptState = event.appReviewPromptState + if let updatedValue: HomeViewModelEvent = changes.latestGeneric(.updateScreen, as: HomeViewModelEvent.self) { + pendingAppReviewPromptState = updatedValue.pendingAppReviewPromptState + appReviewPromptState = updatedValue.appReviewPromptState } /// If this update has an event indicating we should show the donations modal then do so, the next change will result in the flag /// being reset so we don't unintentionally show it again - if groupedOtherEvents?[.showDonationsCTAModal] != nil { + if changes.contains(.showDonationsCTAModal) { showDonationsCTAModal = true } else if showDonationsCTAModal { showDonationsCTAModal = false } + + /// Regenerate the `itemCache` now that the `dataCache` is updated + itemCache = loadResult.info.currentIds.reduce(into: [:]) { result, id in + guard let thread: SessionThread = dataCache.thread(for: id) else { return } + + result[id] = ConversationInfoViewModel( + thread: thread, + dataCache: dataCache, + using: dependencies + ) + } /// Generate the new state return State( @@ -504,8 +460,8 @@ public class HomeViewModel: NavigatableStateHolder { hasHiddenMessageRequests: hasHiddenMessageRequests, unreadMessageRequestThreadCount: unreadMessageRequestThreadCount, loadedPageInfo: loadResult.info, + dataCache: dataCache, itemCache: itemCache, - profileCache: profileCache, appReviewPromptState: appReviewPromptState, pendingAppReviewPromptState: pendingAppReviewPromptState, appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease, @@ -514,57 +470,7 @@ public class HomeViewModel: NavigatableStateHolder { ) } - private static func extractIdsNeedingRequery( - events: Set, - cache: [String: SessionThreadViewModel] - ) -> Set { - let requireFullRefresh: Bool = events.contains(where: { event in - event.key == .appLifecycle(.willEnterForeground) || - event.key == .databaseLifecycle(.resumed) - }) - - guard !requireFullRefresh else { - return Set(cache.keys) - } - - return events.reduce(into: []) { result, event in - switch (event.key.generic, event.value) { - case (.conversationUpdated, let event as ConversationEvent): result.insert(event.id) - case (.typingIndicator, let event as TypingIndicatorEvent): result.insert(event.threadId) - - case (.messageCreated, let event as MessageEvent), - (.messageUpdated, let event as MessageEvent), - (.messageDeleted, let event as MessageEvent): - result.insert(event.threadId) - - case (.profile, let event as ProfileEvent): - result.insert( - contentsOf: Set(cache.values - .filter { threadViewModel -> Bool in - threadViewModel.threadId == event.id || - threadViewModel.allProfileIds.contains(event.id) - } - .map { $0.threadId }) - ) - - case (.contact, let event as ContactEvent): - result.insert( - contentsOf: Set(cache.values - .filter { threadViewModel -> Bool in - threadViewModel.threadId == event.id || - threadViewModel.allProfileIds.contains(event.id) - } - .map { $0.threadId }) - ) - - default: break - } - } - } - private static func sections(state: State, viewModel: HomeViewModel) -> [SectionModel] { - let userSessionId: SessionId = viewModel.dependencies[cache: .general].sessionId - return [ /// If the message request section is hidden or there are no unread message requests then hide the message request banner (state.hasHiddenMessageRequests || state.unreadMessageRequestThreadCount == 0 ? @@ -572,10 +478,8 @@ public class HomeViewModel: NavigatableStateHolder { [SectionModel( section: .messageRequests, elements: [ - SessionThreadViewModel( - threadId: SessionThreadViewModel.messageRequestsSectionId, - unreadCount: UInt(state.unreadMessageRequestThreadCount), - using: viewModel.dependencies + ConversationInfoViewModel.unreadMessageRequestsBanner( + unreadCount: state.unreadMessageRequestThreadCount ) ] )] @@ -583,34 +487,7 @@ public class HomeViewModel: NavigatableStateHolder { [ SectionModel( section: .threads, - elements: state.loadedPageInfo.currentIds - .compactMap { state.itemCache[$0] } - .map { conversation -> SessionThreadViewModel in - conversation.populatingPostQueryData( - recentReactionEmoji: nil, - openGroupCapabilities: nil, - // TODO: [Database Relocation] Do we need all of these???? - currentUserSessionIds: [userSessionId.hexString], - wasKickedFromGroup: ( - conversation.threadVariant == .group && - viewModel.dependencies.mutate(cache: .libSession) { cache in - cache.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: conversation.threadId) - ) - } - ), - groupIsDestroyed: ( - conversation.threadVariant == .group && - viewModel.dependencies.mutate(cache: .libSession) { cache in - cache.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: conversation.threadId) - ) - } - ), - threadCanWrite: false, // Irrelevant for the HomeViewModel - threadCanUpload: false // Irrelevant for the HomeViewModel - ) - } + elements: state.loadedPageInfo.currentIds.compactMap { state.itemCache[$0] } ) ], (!state.loadedPageInfo.currentIds.isEmpty && state.loadedPageInfo.hasNextPage ? @@ -640,7 +517,7 @@ public class HomeViewModel: NavigatableStateHolder { willShowCameraPermissionReminder() // Pro expiring/expired CTA - showSessionProCTAIfNeeded() + Task { await showSessionProCTAIfNeeded() } } func scheduleAppReviewRetry() { @@ -649,51 +526,35 @@ public class HomeViewModel: NavigatableStateHolder { .addingTimeInterval(2 * 7 * 24 * 60 * 60) } - func showSessionProCTAIfNeeded() { - switch dependencies[singleton: .sessionProState].sessionProStateSubject.value { - case .none, .refunding: - return - case .active(_, let expiredOn, _ , _): - guard !dependencies[defaults: .standard, key: .hasShownProExpiringCTA] else { return } - let expiryInSeconds: TimeInterval = expiredOn.timeIntervalSinceNow - guard expiryInSeconds <= 7 * 24 * 60 * 60 else { return } - - scheduleExpiringSessionProCTA(expiryInSeconds.ceilingFormatted(format: .long, allowedUnits: [ .day, .hour, .minute ])) - dependencies[defaults: .standard, key: .hasShownProExpiringCTA] = true - case .expired(let expiredOn, _): - guard !dependencies[defaults: .standard, key: .hasShownProExpiredCTA] else { return } - let expiryInSeconds: TimeInterval = expiredOn.timeIntervalSinceNow - guard expiryInSeconds <= 30 * 24 * 60 * 60 && !dependencies[feature: .mockExpiredOverThirtyDays] else { return } - - scheduleExpiringSessionProCTA(nil) - dependencies[defaults: .standard, key: .hasShownProExpiredCTA] = true + @MainActor func showSessionProCTAIfNeeded() async { + guard let info = await dependencies[singleton: .sessionProManager].sessionProExpiringCTAInfo() else { + return } - } - - private func scheduleExpiringSessionProCTA(_ timeLeft: String?) { - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self, dependencies] in - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .expiring(timeLeft: timeLeft), - onConfirm: { - let viewController: SessionHostingViewController = SessionHostingViewController( - rootView: SessionProPaymentScreen( - viewModel: SessionProPaymentScreenContent.ViewModel( - dependencies: dependencies, - dataModel: .init( - flow: dependencies[singleton: .sessionProState].sessionProStateSubject.value.toPaymentFlow(using: dependencies), - plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } - ), - isFromBottomSheet: false - ) + + try? await Task.sleep(for: .seconds(1)) /// Cooperative suspension, so safe to call on main thread + + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + info.variant, + onConfirm: { [weak self, dependencies] in + let viewController: SessionHostingViewController = SessionHostingViewController( + rootView: SessionProPaymentScreen( + viewModel: SessionProPaymentScreenContent.ViewModel( + dataModel: SessionProPaymentScreenContent.DataModel( + flow: info.paymentFlow, + plans: info.planInfo + ), + isFromBottomSheet: false, + using: dependencies ) ) - self?.transitionToScreen(viewController) - }, - presenting: { modal in - self?.transitionToScreen(modal, transitionType: .present) - } - ) - } + ) + self?.transitionToScreen(viewController) + }, + presenting: { [weak self, dependencies] modal in + dependencies[defaults: .standard, key: .hasShownProExpiringCTA] = true + self?.transitionToScreen(modal, transitionType: .present) + } + ) } func handlePromptChangeState(_ state: AppReviewPromptState?) { @@ -766,11 +627,11 @@ public class HomeViewModel: NavigatableStateHolder { @MainActor func submitFeedbackSurvery() { - guard let url: URL = URL(string: Constants.session_feedback_url) else { return } + guard let url: URL = URL(string: Constants.urls.feedback) else { return } // stringlint:disable let surveyUrl: URL = url.appending(queryItems: [ - .init(name: "platform", value: Constants.platform_name), + .init(name: "platform", value: Constants.PaymentProvider.appStore.device), .init(name: "version", value: dependencies[cache: .appVersion].appVersion) ]) @@ -873,7 +734,7 @@ public class HomeViewModel: NavigatableStateHolder { ) } - @MainActor public func loadNextPage() { + @MainActor public func loadPageAfter() { dependencies.notifyAsync( key: .loadPage(HomeViewModel.self), value: LoadPageEvent.nextPage(lastIndex: state.loadedPageInfo.lastIndex) @@ -883,45 +744,27 @@ public class HomeViewModel: NavigatableStateHolder { // MARK: - Convenience -private enum EventDataRequirement { - case databaseQuery - case other - case bothDatabaseQueryAndOther -} - private extension ObservedEvent { - var dataRequirement: EventDataRequirement { - switch (key, key.generic) { - case (.setting(.hasHiddenMessageRequests), _): return .bothDatabaseQueryAndOther - - case (_, .profile): return .bothDatabaseQueryAndOther - case (.feature(.serviceNetwork), _): return .other - case (.feature(.forceOffline), _): return .other - case (.setting(.hasViewedSeed), _): return .other - - case (.appLifecycle(.willEnterForeground), _): return .databaseQuery - case (.messageRequestUnreadMessageReceived, _), (.messageRequestAccepted, _), - (.messageRequestDeleted, _), (.messageRequestMessageRead, _): - return .databaseQuery - case (_, .loadPage): return .databaseQuery - case (.conversationCreated, _): return .databaseQuery - case (.anyMessageCreatedInAnyConversation, _): return .databaseQuery - case (.anyContactBlockedStatusChanged, _): return .databaseQuery - case (_, .typingIndicator): return .databaseQuery - case (_, .conversationUpdated), (_, .conversationDeleted): return .databaseQuery - case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery - default: return .other - } - } - - var requiresMessageRequestCountUpdate: Bool { - switch self.key { - case .messageRequestUnreadMessageReceived, .messageRequestAccepted, .messageRequestDeleted, - .messageRequestMessageRead: - return true + var handlingStrategy: EventHandlingStrategy { + let threadInfoStrategy: EventHandlingStrategy? = ConversationInfoViewModel.handlingStrategy(for: self) + let localStrategy: EventHandlingStrategy = { + switch (key, key.generic) { + case (.setting(.hasHiddenMessageRequests), _): return [.databaseQuery, .directCacheUpdate] + case (ObservableKey.feature(.serviceNetwork), _): return .directCacheUpdate + case (ObservableKey.feature(.forceOffline), _): return .directCacheUpdate + case (.setting(.hasViewedSeed), _): return .directCacheUpdate + + case (.appLifecycle(.willEnterForeground), _): return .databaseQuery + case (.messageRequestUnreadMessageReceived, _), (.messageRequestAccepted, _), + (.messageRequestDeleted, _), (.messageRequestMessageRead, _): + return .databaseQuery + case (_, .loadPage): return .databaseQuery - default: return false - } + default: return .directCacheUpdate + } + }() + + return localStrategy.union(threadInfoStrategy ?? .none) } } diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 7dcc1604a2..8e4ed7459c 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -9,10 +9,18 @@ import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit +// MARK: - Log.Category + +public extension Log.Category { + static let messageRequestsViewModel: Log.Category = .create("MessageRequestsViewModel", defaultLevel: .warn) +} + +// MARK: - MessageRequestsViewModel + class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource, PagedObservationSource { - typealias TableItem = SessionThreadViewModel + typealias TableItem = ConversationInfoViewModel typealias PagedTable = SessionThread - typealias PagedDataModel = SessionThreadViewModel + typealias PagedDataModel = ConversationInfoViewModel // MARK: - Section @@ -34,7 +42,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O public let dependencies: Dependencies public let state: TableDataState = TableDataState() - public let observableState: ObservableTableSourceState = ObservableTableSourceState() + public let observableState: ObservableTableSourceState = ObservableTableSourceState() public let navigatableState: NavigatableState = NavigatableState() private let userSessionId: SessionId @@ -49,7 +57,18 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O self.userSessionId = dependencies[cache: .general].sessionId self.internalState = State.initialState(using: dependencies) - self.bindState() + self.observationTask = ObservationBuilder + .initialValue(self.internalState) + .debounce(for: .milliseconds(250)) + .using(dependencies: dependencies) + .query(MessageRequestsViewModel.queryState) + .assign { [weak self] updatedState in + guard let self = self else { return } + + // FIXME: To slightly reduce the size of the changes this new observation mechanism is currently wired into the old SessionTableViewController observation mechanism, we should refactor it so everything uses the new mechanism + self.internalState = updatedState + self.pendingTableDataSubject.send(updatedState.sections(viewModel: self)) + } } // MARK: - Content @@ -61,7 +80,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O public let cellType: SessionTableViewCellType = .fullConversation @available(*, deprecated, message: "No longer used now that we have updated this ViewModel to use the new ObservationBuilder mechanism") - var pagedDataObserver: PagedDatabaseObserver? = nil + var pagedDataObserver: PagedDatabaseObserver? = nil // MARK: - State @@ -73,8 +92,9 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O } let viewState: ViewState - let loadedPageInfo: PagedData.LoadedInfo - let itemCache: [String: SessionThreadViewModel] + let loadedPageInfo: PagedData.LoadedInfo + let dataCache: ConversationDataCache + let itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel] @MainActor public func sections(viewModel: MessageRequestsViewModel) -> [SectionModel] { MessageRequestsViewModel.sections(state: self, viewModel: viewModel) @@ -82,6 +102,8 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O public var observedKeys: Set { var result: Set = [ + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed), .loadPage(MessageRequestsViewModel.self), .messageRequestUnreadMessageReceived, .messageRequestAccepted, @@ -90,61 +112,40 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O .anyMessageCreatedInAnyConversation ] - itemCache.values.forEach { item in - result.insert(contentsOf: [ - .conversationUpdated(item.threadId), - .conversationDeleted(item.threadId), - .messageCreated(threadId: item.threadId), - .messageUpdated( - id: item.interactionId, - threadId: item.threadId - ), - .messageDeleted( - id: item.interactionId, - threadId: item.threadId - ) - ]) - } + result.insert(contentsOf: Set(itemCache.values.flatMap { $0.observedKeys })) return result } static func initialState(using dependencies: Dependencies) -> State { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + return State( viewState: .loading, loadedPageInfo: PagedData.LoadedInfo( - record: SessionThreadViewModel.self, + record: SessionThread.self, pageSize: MessageRequestsViewModel.pageSize, - /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed - /// for the query but differs from the JOINs that are actually used for performance reasons as the - /// basic logic can be simpler for where it's used - requiredJoinSQL: SessionThreadViewModel.optimisedJoinSQL, - filterSQL: SessionThreadViewModel.messageRequestsFilterSQL( - userSessionId: dependencies[cache: .general].sessionId - ), - groupSQL: SessionThreadViewModel.groupSQL, - orderSQL: SessionThreadViewModel.messageRequestsOrderSQL + requiredJoinSQL: ConversationInfoViewModel.requiredJoinSQL, + filterSQL: ConversationInfoViewModel.messageRequestsFilterSQL(userSessionId: userSessionId), + groupSQL: nil, + orderSQL: ConversationInfoViewModel.messageRequestsOrderSQL + ), + dataCache: ConversationDataCache( + userSessionId: userSessionId, + context: ConversationDataCache.Context( + source: .conversationList, + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) ), itemCache: [:] ) } } - @MainActor private func bindState() { - observationTask = ObservationBuilder - .initialValue(self.internalState) - .debounce(for: .milliseconds(250)) - .using(dependencies: dependencies) - .query(MessageRequestsViewModel.queryState) - .assign { [weak self] updatedState in - guard let self = self else { return } - - // FIXME: To slightly reduce the size of the changes this new observation mechanism is currently wired into the old SessionTableViewController observation mechanism, we should refactor it so everything uses the new mechanism - self.internalState = updatedState - self.pendingTableDataSubject.send(updatedState.sections(viewModel: self)) - } - } - @Sendable private static func queryState( previousState: State, events: [ObservedEvent], @@ -152,7 +153,8 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O using dependencies: Dependencies ) async -> State { var loadResult: PagedData.LoadResult = previousState.loadedPageInfo.asResult - var itemCache: [String: SessionThreadViewModel] = previousState.itemCache + var dataCache: ConversationDataCache = previousState.dataCache + var itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel] = previousState.itemCache /// Store a local copy of the events so we can manipulate it based on the state changes var eventsToProcess: [ObservedEvent] = events @@ -166,129 +168,93 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O } /// If there are no events we want to process then just return the current state - guard !eventsToProcess.isEmpty else { return previousState } + guard isInitialQuery || !eventsToProcess.isEmpty else { return previousState } /// Split the events between those that need database access and those that don't - let splitEvents: [Bool: [ObservedEvent]] = eventsToProcess - .grouped(by: \.requiresDatabaseQueryForMessageRequestsViewModel) + let changes: EventChangeset = eventsToProcess.split(by: { $0.handlingStrategy }) + let loadPageEvent: LoadPageEvent? = changes.latestGeneric(.loadPage, as: LoadPageEvent.self) - /// Handle database events first - if let databaseEvents: Set = splitEvents[true].map({ Set($0) }) { + /// Update the context + dataCache.withContext( + source: .conversationList, + requireFullRefresh: changes.containsAny( + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed) + ) + ) + + /// Process cache updates first + dataCache = await ConversationDataHelper.applyNonDatabaseEvents( + changes, + currentCache: dataCache, + using: dependencies + ) + + /// Then determine the fetch requirements + let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements( + for: changes, + currentCache: dataCache, + itemCache: itemCache, + loadPageEvent: loadPageEvent + ) + + /// Peform any database changes + if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch { do { - var fetchedConversations: [SessionThreadViewModel] = [] - let idsNeedingRequery: Set = extractIdsNeedingRequery( - events: databaseEvents, - cache: itemCache - ) - let loadPageEvent: LoadPageEvent? = databaseEvents - .first(where: { $0.key.generic == .loadPage })? - .value as? LoadPageEvent - - /// Identify any inserted/deleted records - var insertedIds: Set = [] - var deletedIds: Set = [] - - databaseEvents.forEach { event in - switch (event.key.generic, event.value) { - case (GenericObservableKey(.messageRequestAccepted), let threadId as String): - insertedIds.insert(threadId) - - case (GenericObservableKey(.conversationCreated), let event as ConversationEvent): - insertedIds.insert(event.id) - - case (GenericObservableKey(.anyMessageCreatedInAnyConversation), let event as MessageEvent): - insertedIds.insert(event.threadId) - - case (.conversationDeleted, let event as ConversationEvent): - deletedIds.insert(event.id) - - default: break - } - } - try await dependencies[singleton: .storage].readAsync { db in - /// Update loaded page info as needed - if loadPageEvent != nil || !insertedIds.isEmpty || !deletedIds.isEmpty { - loadResult = try loadResult.load( - db, - target: ( - loadPageEvent?.target(with: loadResult) ?? - .reloadCurrent(insertedIds: insertedIds, deletedIds: deletedIds) - ) - ) - } - - /// Fetch any records needed - fetchedConversations.append( - contentsOf: try SessionThreadViewModel - .query( - userSessionId: dependencies[cache: .general].sessionId, - groupSQL: SessionThreadViewModel.groupSQL, - orderSQL: SessionThreadViewModel.messageRequestsOrderSQL, - ids: Array(idsNeedingRequery) + loadResult.newIds - ) - .fetchAll(db) + /// Fetch any required data from the cache + (loadResult, dataCache) = try ConversationDataHelper.fetchFromDatabase( + db, + requirements: fetchRequirements, + currentCache: dataCache, + loadResult: loadResult, + loadPageEvent: loadPageEvent, + using: dependencies ) } - - /// Update the `itemCache` with the newly fetched values - fetchedConversations.forEach { itemCache[$0.threadId] = $0 } - - /// Remove any deleted values - deletedIds.forEach { id in itemCache.removeValue(forKey: id) } } catch { - let eventList: String = databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") - Log.critical(.homeViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)") + let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") + Log.critical(.messageRequestsViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)") + } + } + else if !changes.databaseEvents.isEmpty { + Log.warn(.messageRequestsViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") + } + + /// Peform any `libSession` changes + if fetchRequirements.needsAnyFetch { + do { + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) + } + catch { + Log.warn(.messageRequestsViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") } } + /// Regenerate the `itemCache` now that the `dataCache` is updated + itemCache = loadResult.info.currentIds.reduce(into: [:]) { result, id in + guard let thread: SessionThread = dataCache.thread(for: id) else { return } + + result[id] = ConversationInfoViewModel( + thread: thread, + dataCache: dataCache, + using: dependencies + ) + } + /// Generate the new state return State( viewState: (loadResult.info.totalCount == 0 ? .empty : .loaded), loadedPageInfo: loadResult.info, + dataCache: dataCache, itemCache: itemCache ) } - private static func extractIdsNeedingRequery( - events: Set, - cache: [String: SessionThreadViewModel] - ) -> Set { - return events.reduce(into: []) { result, event in - switch (event.key.generic, event.value) { - case (.conversationUpdated, let event as ConversationEvent): result.insert(event.id) - case (.typingIndicator, let event as TypingIndicatorEvent): result.insert(event.threadId) - - case (.messageCreated, let event as MessageEvent), - (.messageUpdated, let event as MessageEvent), - (.messageDeleted, let event as MessageEvent): - result.insert(event.threadId) - - case (.profile, let event as ProfileEvent): - result.insert( - contentsOf: Set(cache.values - .filter { threadViewModel -> Bool in - threadViewModel.threadId == event.id || - threadViewModel.allProfileIds.contains(event.id) - } - .map { $0.threadId }) - ) - - case (.contact, let event as ContactEvent): - result.insert( - contentsOf: Set(cache.values - .filter { threadViewModel -> Bool in - threadViewModel.threadId == event.id || - threadViewModel.allProfileIds.contains(event.id) - } - .map { $0.threadId }) - ) - - default: break - } - } - } - private static func sections(state: State, viewModel: MessageRequestsViewModel) -> [SectionModel] { return [ [ @@ -296,40 +262,16 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O section: .threads, elements: state.loadedPageInfo.currentIds .compactMap { state.itemCache[$0] } - .map { conversation -> SessionCell.Info in + .map { threadInfo -> SessionCell.Info in return SessionCell.Info( - id: conversation.populatingPostQueryData( - recentReactionEmoji: nil, - openGroupCapabilities: nil, - // TODO: [Database Relocation] Do we need all of these???? - currentUserSessionIds: [viewModel.dependencies[cache: .general].sessionId.hexString], - wasKickedFromGroup: ( - conversation.threadVariant == .group && - viewModel.dependencies.mutate(cache: .libSession) { cache in - cache.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: conversation.threadId) - ) - } - ), - groupIsDestroyed: ( - conversation.threadVariant == .group && - viewModel.dependencies.mutate(cache: .libSession) { cache in - cache.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: conversation.threadId) - ) - } - ), - threadCanWrite: false, // Irrelevant for the MessageRequestsViewModel - threadCanUpload: false // Irrelevant for the MessageRequestsViewModel - ), - canReuseCell: true, + id: threadInfo, accessibility: Accessibility( identifier: "Message request" ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in let viewController: ConversationVC = ConversationVC( - threadId: conversation.threadId, - threadVariant: conversation.threadVariant, + threadInfo: threadInfo, + focusedInteractionInfo: nil, using: dependencies ) viewModel?.transitionToScreen(viewController, transitionType: .push) @@ -349,7 +291,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O .map { [dependencies] state in // TODO: [Database Relocation] Looks like there is a bug where where the `clear all` button will only clear currently loaded message requests (so if there are more than 15 it'll only clear one page at a time) let threadInfo: [(id: String, variant: SessionThread.Variant)] = state.itemCache.values - .map { ($0.threadId, $0.threadVariant) } + .map { ($0.id, $0.variant) } return SessionButton.Info( style: .destructive, @@ -414,7 +356,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O switch section.model { case .threads: - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row].id + let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row].id return UIContextualAction.configuration( for: UIContextualAction.generateSwipeActions( @@ -422,7 +364,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O for: .trailing, indexPath: indexPath, tableView: tableView, - threadViewModel: threadViewModel, + threadInfo: threadInfo, viewController: viewController, navigatableStateHolder: nil, using: dependencies @@ -451,21 +393,26 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O // MARK: - Convenience private extension ObservedEvent { - var requiresDatabaseQueryForMessageRequestsViewModel: Bool { - /// Any event requires a database query - switch (key, key.generic) { - case (_, .loadPage): return true - case (.messageRequestUnreadMessageReceived, _): return true - case (.messageRequestAccepted, _): return true - case (.messageRequestDeleted, _): return true - case (.conversationCreated, _): return true - case (.anyMessageCreatedInAnyConversation, _): return true + var handlingStrategy: EventHandlingStrategy { + let threadInfoStrategy: EventHandlingStrategy? = ConversationInfoViewModel.handlingStrategy(for: self) + let localStrategy: EventHandlingStrategy = { + switch (key, key.generic) { + case (.appLifecycle(.willEnterForeground), _): return .databaseQuery + case (.messageRequestUnreadMessageReceived, _), (.messageRequestAccepted, _), + (.messageRequestDeleted, _), (.messageRequestMessageRead, _): + return .databaseQuery + case (_, .loadPage): return .databaseQuery - /// We only observe events from records we have explicitly fetched so if we get an event for one of these then we need to - /// trigger an update - case (_, .conversationUpdated), (_, .conversationDeleted): return true - case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return true - default: return false - } + default: return .directCacheUpdate + } + }() + + return localStrategy.union(threadInfoStrategy ?? .none) } } + +// FIXME: Remove this when we ditch `PagedDataObservable` +extension ConversationInfoViewModel: @retroactive FetchableRecordWithRowId { + public var rowId: Int64 { -1 } + public init(row: GRDB.Row) throws { throw StorageError.objectNotFound } +} diff --git a/Session/Home/New Conversation/NewMessageScreen.swift b/Session/Home/New Conversation/NewMessageScreen.swift index 4eb0dbfa9f..ff60b11549 100644 --- a/Session/Home/New Conversation/NewMessageScreen.swift +++ b/Session/Home/New Conversation/NewMessageScreen.swift @@ -119,14 +119,16 @@ struct NewMessageScreen: View { } } - private func startNewDM(with sessionId: String) { - dependencies[singleton: .app].presentConversationCreatingIfNeeded( - for: sessionId, - variant: .contact, - action: .compose, - dismissing: self.host.controller, - animated: false - ) + @MainActor private func startNewDM(with sessionId: String) { + Task.detached(priority: .userInitiated) { [dependencies] in + await dependencies[singleton: .app].presentConversationCreatingIfNeeded( + for: sessionId, + variant: .contact, + action: .compose, + dismissing: self.host.controller, + animated: false + ) + } } } diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index c5813eb257..35a2b1eb5c 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -816,8 +816,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou .read { db in Profile.displayName( db, - id: targetItem.interactionAuthorId, - threadVariant: threadVariant + id: targetItem.interactionAuthorId ) } .defaulting(to: targetItem.interactionAuthorId.truncated()) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 0ab8269364..68f9d01c0a 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -8,6 +8,81 @@ import SessionMessagingKit import Lucide struct MessageInfoScreen: View { + public struct ViewModel { + let dependencies: Dependencies + let actions: [ContextMenuVC.Action] + let messageViewModel: MessageViewModel + let threadCanWrite: Bool + let openGroupServer: String? + let openGroupPublicKey: String? + let onStartThread: (@MainActor () -> Void)? + let isMessageFailed: Bool + let isCurrentUser: Bool + let profileInfo: ProfilePictureView.Info? + + /// These are the features that were enabled at the time the message was received + let proFeatures: [ProFeature] + + /// This flag is separate to the `proFeatures` because it should be based on the _current_ pro state of the user rather than + /// the state the user was in when the message was sent + let shouldShowProBadge: Bool + + func ctaVariant(currentUserProStatus: Network.SessionPro.BackendUserProStatus) -> ProCTAModal.Variant { + guard let firstFeature: ProFeature = proFeatures.first, proFeatures.count > 1 else { + return .generic(renew: (currentUserProStatus == .expired)) + } + + switch firstFeature { + case .proBadge: return .generic(renew: (currentUserProStatus == .expired)) + case .increasedMessageLength: return .longerMessages(renew: (currentUserProStatus == .expired)) + case .animatedDisplayPicture: + return .animatedProfileImage( + isSessionProActivated: (currentUserProStatus == .active), + renew: (currentUserProStatus == .expired) + ) + } + } + } + + public enum ProFeature: Equatable { + case proBadge + case increasedMessageLength + case animatedDisplayPicture + + var title: String { + switch self { + case .proBadge: + return "appProBadge" + .put(key: "app_pro", value: Constants.app_pro) + .localized() + + case .increasedMessageLength: return "proIncreasedMessageLengthFeature".localized() + case .animatedDisplayPicture: return "proAnimatedDisplayPictureFeature".localized() + } + } + + static func from( + messageFeatures: SessionPro.MessageFeatures, + profileFeatures: SessionPro.ProfileFeatures + ) -> [ProFeature] { + var result: [ProFeature] = [] + + if profileFeatures.contains(.proBadge) { + result.append(.proBadge) + } + + if messageFeatures.contains(.largerCharacterLimit) { + result.append(.increasedMessageLength) + } + + if profileFeatures.contains(.animatedAvatar) { + result.append(.animatedDisplayPicture) + } + + return result + } + } + @EnvironmentObject var host: HostWrapper @State var index = 1 @@ -16,48 +91,42 @@ struct MessageInfoScreen: View { static private let cornerRadius: CGFloat = 17 - var actions: [ContextMenuVC.Action] - var messageViewModel: MessageViewModel - let threadCanWrite: Bool - let onStartThread: (@MainActor () -> Void)? - let dependencies: Dependencies - let isMessageFailed: Bool - let isCurrentUser: Bool - let profileInfo: ProfilePictureView.Info? - var proFeatures: [String] = [] - var proCTAVariant: ProCTAModal.Variant = .generic(renew: false) + var viewModel: ViewModel public init( actions: [ContextMenuVC.Action], messageViewModel: MessageViewModel, threadCanWrite: Bool, + openGroupServer: String?, + openGroupPublicKey: String?, onStartThread: (@MainActor () -> Void)?, using dependencies: Dependencies ) { - self.actions = actions - self.messageViewModel = messageViewModel - self.threadCanWrite = threadCanWrite - self.onStartThread = onStartThread - self.dependencies = dependencies - - self.isMessageFailed = [.failed, .failedToSync].contains(messageViewModel.state) - self.isCurrentUser = (messageViewModel.currentUserSessionIds ?? []).contains(messageViewModel.authorId) - self.profileInfo = ProfilePictureView.Info.generateInfoFrom( - size: .message, - publicKey: ( - // Prioritise the profile.id because we override it for - // messages sent by the current user in communities - messageViewModel.profile?.id ?? - messageViewModel.authorId + self.viewModel = ViewModel( + dependencies: dependencies, + actions: actions.filter { $0.actionType != .emoji }, // Exclude emoji actions + messageViewModel: messageViewModel, + threadCanWrite: threadCanWrite, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, + onStartThread: onStartThread, + isMessageFailed: [.failed, .failedToSync].contains(messageViewModel.state), + isCurrentUser: messageViewModel.currentUserSessionIds.contains(messageViewModel.authorId), + profileInfo: ProfilePictureView.Info.generateInfoFrom( + size: .message, + publicKey: messageViewModel.profile.id, + threadVariant: .contact, // Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: messageViewModel.profile, + profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), + using: dependencies + ).front, + proFeatures: ProFeature.from( + messageFeatures: messageViewModel.proMessageFeatures, + profileFeatures: messageViewModel.proProfileFeatures ), - threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureUrl: nil, - profile: messageViewModel.profile, - profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), - using: dependencies - ).front - - (self.proFeatures, self.proCTAVariant) = getProFeaturesInfo() + shouldShowProBadge: messageViewModel.profile.proFeatures.contains(.proBadge) + ) } var body: some View { @@ -73,9 +142,9 @@ struct MessageInfoScreen: View { ) { // Message bubble snapshot MessageBubble( - messageViewModel: messageViewModel, + messageViewModel: viewModel.messageViewModel, attachmentOnly: false, - dependencies: dependencies + dependencies: viewModel.dependencies ) .clipShape( RoundedRectangle(cornerRadius: Self.cornerRadius) @@ -83,7 +152,7 @@ struct MessageInfoScreen: View { .background( RoundedRectangle(cornerRadius: Self.cornerRadius) .fill( - themeColor: (messageViewModel.variant == .standardIncoming || messageViewModel.variant == .standardIncomingDeleted || messageViewModel.variant == .standardIncomingDeletedLocally ? + themeColor: (viewModel.messageViewModel.variant == .standardIncoming || viewModel.messageViewModel.variant == .standardIncomingDeleted || viewModel.messageViewModel.variant == .standardIncomingDeletedLocally ? .messageBubble_incomingBackground : .messageBubble_outgoingBackground) ) @@ -99,11 +168,11 @@ struct MessageInfoScreen: View { .padding(.horizontal, Values.largeSpacing) - if isMessageFailed { - let (image, statusText, tintColor) = messageViewModel.state.statusIconInfo( - variant: messageViewModel.variant, - hasBeenReadByRecipient: messageViewModel.hasBeenReadByRecipient, - hasAttachments: (messageViewModel.attachments?.isEmpty == false) + if viewModel.isMessageFailed { + let (image, statusText, tintColor) = viewModel.messageViewModel.state.statusIconInfo( + variant: viewModel.messageViewModel.variant, + hasBeenReadByRecipient: viewModel.messageViewModel.hasBeenReadByRecipient, + hasAttachments: !viewModel.messageViewModel.attachments.isEmpty ) HStack(spacing: 6) { @@ -125,8 +194,10 @@ struct MessageInfoScreen: View { .padding(.horizontal, Values.largeSpacing) } - if let attachments = messageViewModel.attachments { - switch messageViewModel.cellType { + if !viewModel.messageViewModel.attachments.isEmpty { + let attachments: [Attachment] = viewModel.messageViewModel.attachments + + switch viewModel.messageViewModel.cellType { case .mediaMessage: let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count] @@ -135,9 +206,9 @@ struct MessageInfoScreen: View { // Attachment carousel view SessionCarouselView_SwiftUI( index: $index, - isOutgoing: (messageViewModel.variant == .standardOutgoing), + isOutgoing: (viewModel.messageViewModel.variant == .standardOutgoing), contentInfos: attachments, - using: dependencies + using: viewModel.dependencies ) .frame( maxWidth: .infinity, @@ -147,10 +218,10 @@ struct MessageInfoScreen: View { } else { MediaView_SwiftUI( attachment: attachments[0], - isOutgoing: (messageViewModel.variant == .standardOutgoing), + isOutgoing: (viewModel.messageViewModel.variant == .standardOutgoing), shouldSupressControls: true, cornerRadius: 0, - using: dependencies + using: viewModel.dependencies ) .frame( maxWidth: .infinity, @@ -183,9 +254,9 @@ struct MessageInfoScreen: View { default: MessageBubble( - messageViewModel: messageViewModel, + messageViewModel: viewModel.messageViewModel, attachmentOnly: true, - dependencies: dependencies + dependencies: viewModel.dependencies ) .clipShape( RoundedRectangle(cornerRadius: Self.cornerRadius) @@ -193,7 +264,7 @@ struct MessageInfoScreen: View { .background( RoundedRectangle(cornerRadius: Self.cornerRadius) .fill( - themeColor: (messageViewModel.variant == .standardIncoming || messageViewModel.variant == .standardIncomingDeleted || messageViewModel.variant == .standardIncomingDeletedLocally ? + themeColor: (viewModel.messageViewModel.variant == .standardIncoming || viewModel.messageViewModel.variant == .standardIncomingDeleted || viewModel.messageViewModel.variant == .standardIncomingDeletedLocally ? .messageBubble_incomingBackground : .messageBubble_outgoingBackground) ) @@ -211,7 +282,8 @@ struct MessageInfoScreen: View { } // Attachment Info - if let attachments = messageViewModel.attachments, !attachments.isEmpty { + if !viewModel.messageViewModel.attachments.isEmpty { + let attachments: [Attachment] = viewModel.messageViewModel.attachments let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count] ZStack { @@ -294,7 +366,7 @@ struct MessageInfoScreen: View { spacing: Values.mediumSpacing ) { // Pro feature message - if proFeatures.count > 0 { + if viewModel.proFeatures.count > 0 { VStack( alignment: .leading, spacing: Values.mediumSpacing @@ -305,21 +377,7 @@ struct MessageInfoScreen: View { .font(.Body.extraLargeBold) .foregroundColor(themeColor: .textPrimary) } - .onTapGesture { - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - proCTAVariant, - onConfirm: { - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( - presenting: { bottomSheet in - self.host.controller?.present(bottomSheet, animated: true) - } - ) - }, - presenting: { modal in - self.host.controller?.present(modal, animated: true) - } - ) - } + .onTapGesture { showSessionProCTAIfNeeded() } Text( "proMessageInfoFeatures" @@ -333,13 +391,13 @@ struct MessageInfoScreen: View { alignment: .leading, spacing: Values.smallSpacing ) { - ForEach(self.proFeatures, id: \.self) { feature in + ForEach(viewModel.proFeatures, id: \.self) { feature in HStack(spacing: Values.smallSpacing) { AttributedText(Lucide.Icon.circleCheck.attributedString(size: 17)) .font(.system(size: 17)) .foregroundColor(themeColor: .primary) - Text(feature) + Text(feature.title) .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -348,8 +406,8 @@ struct MessageInfoScreen: View { } } - if isMessageFailed { - let failureText: String = messageViewModel.mostRecentFailureText ?? "messageStatusFailedToSend".localized() + if viewModel.isMessageFailed { + let failureText: String = viewModel.messageViewModel.mostRecentFailureText ?? "messageStatusFailedToSend".localized() InfoBlock(title: "theError".localized() + ":") { Text(failureText) .font(.Body.largeRegular) @@ -357,13 +415,13 @@ struct MessageInfoScreen: View { } } else { InfoBlock(title: "sent".localized()) { - Text(messageViewModel.dateForUI.fromattedForMessageInfo) + Text(viewModel.messageViewModel.dateForUI.fromattedForMessageInfo) .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } InfoBlock(title: "received".localized()) { - Text(messageViewModel.receivedDateForUI.fromattedForMessageInfo) + Text(viewModel.messageViewModel.receivedDateForUI.fromattedForMessageInfo) .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -375,12 +433,12 @@ struct MessageInfoScreen: View { ) { let size: ProfilePictureView.Info.Size = .list - if let info: ProfilePictureView.Info = self.profileInfo { + if let info: ProfilePictureView.Info = viewModel.profileInfo { ProfilePictureSwiftUI( size: size, info: info, additionalInfo: nil, - dataManager: dependencies[singleton: .imageDataManager] + dataManager: viewModel.dependencies[singleton: .imageDataManager] ) .frame( width: size.viewSize, @@ -394,44 +452,30 @@ struct MessageInfoScreen: View { spacing: Values.verySmallSpacing ) { HStack(spacing: Values.verySmallSpacing) { - if isCurrentUser { + if viewModel.isCurrentUser { Text("you".localized()) .font(.Body.extraLargeBold) .foregroundColor(themeColor: .textPrimary) } - else if !messageViewModel.authorNameSuppressedId.isEmpty { - Text(messageViewModel.authorNameSuppressedId) + else if !viewModel.messageViewModel.authorName().isEmpty { + Text(viewModel.messageViewModel.authorName()) .font(.Body.extraLargeBold) .foregroundColor(themeColor: .textPrimary) } - if (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: messageViewModel.authorId)}) { + if viewModel.shouldShowProBadge { SessionProBadge_SwiftUI(size: .small) - .onTapGesture { - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - proCTAVariant, - onConfirm: { - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( - presenting: { bottomSheet in - self.host.controller?.present(bottomSheet, animated: true) - } - ) - }, - presenting: { modal in - self.host.controller?.present(modal, animated: true) - } - ) - } + .onTapGesture { showSessionProCTAIfNeeded() } } } - Text(messageViewModel.authorId) + Text(viewModel.messageViewModel.authorId) .font(.Display.base) .foregroundColor( themeColor: { if - messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded15.rawValue) || - messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded25.rawValue) + viewModel.messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded15.rawValue) || + viewModel.messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded25.rawValue) { return .textSecondary } @@ -462,21 +506,21 @@ struct MessageInfoScreen: View { .padding(.horizontal, Values.largeSpacing) // Actions - if !actions.isEmpty { + if !viewModel.actions.isEmpty { ZStack { VStack( alignment: .leading, spacing: 0 ) { ForEach( - 0...(actions.count - 1), + 0...(viewModel.actions.count - 1), id: \.self ) { index in - let tintColor: ThemeValue = actions[index].themeColor + let tintColor: ThemeValue = viewModel.actions[index].themeColor Button( action: { - actions[index].work() { - switch (actions[index].shouldDismissInfoScreen, actions[index].feedback) { + viewModel.actions[index].work() { + switch (viewModel.actions[index].shouldDismissInfoScreen, viewModel.actions[index].feedback) { case (false, _): break case (true, .some): DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { @@ -485,17 +529,20 @@ struct MessageInfoScreen: View { default: dismiss() } } - feedbackMessage = actions[index].feedback + feedbackMessage = viewModel.actions[index].feedback }, label: { HStack(spacing: Values.largeSpacing) { - Image(uiImage: actions[index].icon!.withRenderingMode(.alwaysTemplate)) - .resizable() - .scaledToFit() - .scaleEffect(x: (actions[index].flipIconForRTL ? -1 : 1), y: 1) - .foregroundColor(themeColor: tintColor) - .frame(width: 26, height: 26) - Text(actions[index].title) + if let icon: UIImage = viewModel.actions[index].icon?.withRenderingMode(.alwaysTemplate) { + Image(uiImage: icon) + .resizable() + .scaledToFit() + .scaleEffect(x: (viewModel.actions[index].flipIconForRTL ? -1 : 1), y: 1) + .foregroundColor(themeColor: tintColor) + .frame(width: 26, height: 26) + } + + Text(viewModel.actions[index].title) .font(.Headings.H8) .foregroundColor(themeColor: tintColor) } @@ -504,7 +551,7 @@ struct MessageInfoScreen: View { ) .frame(height: 60) - if index < (actions.count - 1) { + if index < (viewModel.actions.count - 1) { Divider() .foregroundColor(themeColor: .borderSeparator) } @@ -531,166 +578,61 @@ struct MessageInfoScreen: View { .toastView(message: $feedbackMessage) } - private func getProFeaturesInfo() -> (proFeatures: [String], proCTAVariant: ProCTAModal.Variant) { - var proFeatures: [String] = [] - var proCTAVariant: ProCTAModal.Variant = .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) - - guard dependencies[feature: .sessionProEnabled] else { return (proFeatures, proCTAVariant) } - - if (dependencies.mutate(cache: .libSession) { $0.shouldShowProBadge(for: messageViewModel.profile) }) { - proFeatures.append("appProBadge".put(key: "app_pro", value: Constants.app_pro).localized()) - } - - if ( - messageViewModel.isProMessage && - messageViewModel.body.defaulting(to: "").utf16.count > LibSession.CharacterLimit || - dependencies[feature: .messageFeatureLongMessage] - ) { - proFeatures.append("proIncreasedMessageLengthFeature".localized()) - proCTAVariant = ( - proFeatures.count > 1 ? - .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) : - .longerMessages(renew: dependencies[singleton: .sessionProState].isSessionProExpired) - ) - } - - if ( - ImageDataManager.isAnimatedImage(profileInfo?.source) || - dependencies[feature: .messageFeatureAnimatedAvatar] - ) { - proFeatures.append("proAnimatedDisplayPictureFeature".localized()) - proCTAVariant = ( - proFeatures.count > 1 ? - .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) : - .animatedProfileImage( - isSessionProActivated: false, - renew: dependencies[singleton: .sessionProState].isSessionProExpired - ) - ) - } - - return (proFeatures, proCTAVariant) + private func showSessionProCTAIfNeeded() { + viewModel.dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + viewModel.ctaVariant( + currentUserProStatus: viewModel.dependencies[singleton: .sessionProManager] + .currentUserCurrentProState + .status + ), + onConfirm: { + viewModel.dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded( + presenting: { bottomSheet in + self.host.controller?.present(bottomSheet, animated: true) + } + ) + }, + presenting: { modal in + self.host.controller?.present(modal, animated: true) + } + ) } func showUserProfileModal() { - guard threadCanWrite else { return } - // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) - guard (try? SessionId.Prefix(from: messageViewModel.authorId)) != .blinded25 else { return } - - guard let profileInfo: ProfilePictureView.Info = ProfilePictureView.Info.generateInfoFrom( - size: .message, - publicKey: ( - // Prioritise the profile.id because we override it for - // messages sent by the current user in communities - messageViewModel.profile?.id ?? - messageViewModel.authorId - ), - threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureUrl: nil, - profile: messageViewModel.profile, - profileIcon: .none, - using: dependencies - ).front else { - return - } + guard viewModel.threadCanWrite else { return } - let (sessionId, blindedId): (String?, String?) = { + Task.detached(priority: .userInitiated) { guard - (try? SessionId.Prefix(from: messageViewModel.authorId)) == .blinded15, - let openGroupServer: String = messageViewModel.threadOpenGroupServer, - let openGroupPublicKey: String = messageViewModel.threadOpenGroupPublicKey - else { - return (messageViewModel.authorId, nil) - } - let lookup: BlindedIdLookup? = dependencies[singleton: .storage].write { db in - try BlindedIdLookup.fetchOrCreate( - db, - blindedId: messageViewModel.authorId, - openGroupServer: openGroupServer, - openGroupPublicKey: openGroupPublicKey, - isCheckingForOutbox: false, - using: dependencies + let info: UserProfileModal.Info = await viewModel.messageViewModel.createUserProfileModalInfo( + openGroupServer: viewModel.openGroupServer, + openGroupPublicKey: viewModel.openGroupPublicKey, + onStartThread: viewModel.onStartThread, + onProBadgeTapped: showSessionProCTAIfNeeded, + using: viewModel.dependencies ) - } - return (lookup?.sessionId, messageViewModel.authorId.truncated(prefix: 10, suffix: 10)) - }() - - let qrCodeImage: UIImage? = { - guard let sessionId: String = sessionId else { return nil } - return QRCode.generate(for: sessionId, hasBackground: false, iconName: "SessionWhite40") // stringlint:ignore - }() - - let isMessasgeRequestsEnabled: Bool = { - guard messageViewModel.threadVariant == .community else { return true } - return messageViewModel.profile?.blocksCommunityMessageRequests != true - }() - - let (displayName, contactDisplayName): (String?, String?) = { - guard let sessionId: String = sessionId else { - return (messageViewModel.authorNameSuppressedId, nil) - } + else { return } - let profile: Profile? = ( - dependencies.mutate(cache: .libSession) { $0.profile(contactId: sessionId) } ?? - dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId) } - ) - - let isCurrentUser: Bool = (messageViewModel.currentUserSessionIds?.contains(sessionId) == true) - guard !isCurrentUser else { - return ("you".localized(), "you".localized()) - } - - return ( - (profile?.displayName(for: .contact) ?? messageViewModel.authorNameSuppressedId), - profile?.displayName(for: .contact, ignoringNickname: true) - ) - }() - - DispatchQueue.main.async { - let userProfileModal: ModalHostingViewController = ModalHostingViewController( - modal: UserProfileModal( - info: .init( - sessionId: sessionId, - blindedId: blindedId, - qrCodeImage: qrCodeImage, - profileInfo: profileInfo, - displayName: displayName, - contactDisplayName: contactDisplayName, - isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: messageViewModel.profile) }), - isMessageRequestsEnabled: isMessasgeRequestsEnabled, - onStartThread: self.onStartThread, - onProBadgeTapped: { - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - proCTAVariant, - onConfirm: { - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( - presenting: { bottomSheet in - self.host.controller?.present(bottomSheet, animated: true) - } - ) - }, - presenting: { modal in - self.host.controller?.present(modal, animated: true) - } - ) - } - ), - dataManager: dependencies[singleton: .imageDataManager] + await MainActor.run { + let userProfileModal: ModalHostingViewController = ModalHostingViewController( + modal: UserProfileModal( + info: info, + dataManager: viewModel.dependencies[singleton: .imageDataManager] + ) ) - ) - self.host.controller?.present(userProfileModal, animated: true, completion: nil) + self.host.controller?.present(userProfileModal, animated: true, completion: nil) + } } } private func showMediaFullScreen(attachment: Attachment) { if let mediaGalleryView = MediaGalleryViewModel.createDetailViewController( - for: messageViewModel.threadId, - threadVariant: messageViewModel.threadVariant, - interactionId: messageViewModel.id, + for: viewModel.messageViewModel.threadId, + threadVariant: viewModel.messageViewModel.threadVariant, + interactionId: viewModel.messageViewModel.id, selectedAttachmentId: attachment.id, options: [ .sliderEnabled ], useTransitioningDelegate: false, - using: dependencies + using: viewModel.dependencies ) { self.host.controller?.present(mediaGalleryView, animated: true) } @@ -733,8 +675,7 @@ struct MessageBubble: View { for: messageViewModel, with: maxWidth, textColor: bodyLabelTextColor, - searchText: nil, - using: dependencies + searchText: nil ).height VStack( @@ -770,27 +711,13 @@ struct MessageBubble: View { else { if let quoteViewModel: QuoteViewModel = messageViewModel.quoteViewModel { QuoteView_SwiftUI( - viewModel: quoteViewModel.with( - direction: (messageViewModel.variant == .standardOutgoing ? .outgoing : .incoming), - currentUserSessionIds: (messageViewModel.currentUserSessionIds ?? []), - showProBadge: dependencies.mutate(cache: .libSession) { - $0.validateSessionProState(for: quoteViewModel.authorId) - }, - thumbnailSource: .thumbnailFrom( - quoteViewModel: quoteViewModel, - using: dependencies - ), - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: messageViewModel.threadVariant, - using: dependencies - ) - ), + viewModel: quoteViewModel, dataManager: dependencies[singleton: .imageDataManager] ) .fixedSize(horizontal: false, vertical: true) .padding(.top, Self.inset) .padding(.horizontal, Self.inset) - .padding(.bottom, (messageViewModel.body?.isEmpty == false ? + .padding(.bottom, (messageViewModel.bubbleBody?.isEmpty == false ? -Values.smallSpacing : Self.inset )) @@ -800,8 +727,7 @@ struct MessageBubble: View { if let bodyText: ThemedAttributedString = VisibleMessageCell.getBodyAttributedText( for: messageViewModel, textColor: bodyLabelTextColor, - searchText: nil, - using: dependencies + searchText: nil ) { AttributedLabel(bodyText, maxWidth: maxWidth) .padding(.horizontal, Self.inset) @@ -822,13 +748,13 @@ struct MessageBubble: View { else { switch messageViewModel.cellType { case .voiceMessage: - if let attachment: Attachment = messageViewModel.attachments?.first(where: { $0.isAudio }){ + if let attachment: Attachment = messageViewModel.attachments.first(where: { $0.isAudio }){ // TODO: Playback Info and check if playing function is needed VoiceMessageView_SwiftUI(attachment: attachment) .padding(.top, Self.inset) } case .audio, .genericAttachment: - if let attachment: Attachment = messageViewModel.attachments?.first { + if let attachment: Attachment = messageViewModel.attachments.first { DocumentView_SwiftUI( maxWidth: $maxWidth, attachment: attachment, @@ -885,6 +811,8 @@ final class MessageInfoViewController: SessionHostingViewController Void)?, using dependencies: Dependencies ) { @@ -892,6 +820,8 @@ final class MessageInfoViewController: SessionHostingViewController AnyPublisher { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index a51528fb82..615b4e329c 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -58,7 +58,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD dependencies.set(singleton: .appContext, to: MainAppContext(using: dependencies)) verifyDBKeysAvailableBeforeBackgroundLaunch() - dependencies.warmCache(cache: .appVersion) + dependencies.warm(cache: .appVersion) dependencies[singleton: .pushRegistrationManager].createVoipRegistryIfNecessary() // Prevent the device from sleeping during database view async registration @@ -80,7 +80,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // Setup LibSession LibSession.setupLogger(using: dependencies) - dependencies.warmCache(cache: .libSessionNetwork) + dependencies.warm(cache: .libSessionNetwork) + dependencies.warm(singleton: .network) + dependencies.warm(singleton: .sessionProManager) // Configure the different targets SNUtilitiesKit.configure( @@ -470,9 +472,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD Log.info(.cat, "Migrations completed, performing setup and ensuring rootViewController") dependencies[singleton: .jobRunner].setExecutor(SyncPushTokensJob.self, for: .syncPushTokens) - /// We need to do a clean up for disappear after send messages that are received by push notifications before - /// the app set up the main screen and load initial data to prevent a case when the PagedDatabaseObserver - /// hasn't been setup yet then the conversation screen can show stale (ie. deleted) interactions incorrectly + /// We need to do a clean up for disappear after send messages that are received by push notifications before the app sets up + /// the main screen and loads initial data to prevent a case where the the conversation screen can show stale (ie. deleted) + /// interactions incorrectly DisappearingMessagesJob.cleanExpiredMessagesOnResume(using: dependencies) /// Now that the database is setup we can load in any messages which were processed by the extensions (flag that we will load @@ -543,7 +545,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } // May as well run these on the background thread - SessionEnvironment.shared?.audioSession.setup() + dependencies[singleton: .audioSession].setup() } fileprivate func showFailedStartupAlert( @@ -848,18 +850,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD guard dependencies[singleton: .appContext].isValid, !dependencies[defaults: .standard, key: .hasSeenCallMissedTips], - let callerId: String = notification.userInfo?[Notification.Key.senderId.rawValue] as? String, - let presentingVC = dependencies[singleton: .appContext].frontMostViewController + let callerId: String = notification.userInfo?[Notification.Key.senderId.rawValue] as? String else { return } - let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal( - caller: Profile.displayName(id: callerId, using: dependencies), - presentingViewController: presentingVC, - using: dependencies - ) - presentingVC.present(callMissedTipsModal, animated: true, completion: nil) - - dependencies[defaults: .standard, key: .hasSeenCallMissedTips] = true + Task.detached(priority: .userInitiated) { [dependencies] in + let callerDisplayName: String = ((try? await dependencies[singleton: .storage] + .readAsync { db in Profile.displayName(db, id: callerId) }) ?? callerId.truncated()) + + await MainActor.run { [dependencies] in + guard let presentingVC = dependencies[singleton: .appContext].frontMostViewController else { + return + } + + let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal( + caller: callerDisplayName, + presentingViewController: presentingVC, + using: dependencies + ) + presentingVC.present(callMissedTipsModal, animated: true, completion: nil) + + dependencies[defaults: .standard, key: .hasSeenCallMissedTips] = true + } + } } // MARK: - Polling @@ -1064,7 +1076,7 @@ private actor RootViewControllerCoordinator { } // Navigate to the approriate screen depending on the onboarding state - dependencies.warmCache(cache: .onboarding) + dependencies.warm(cache: .onboarding) switch dependencies[cache: .onboarding].state { case .noUser, .noUserInvalidKeyPair, .noUserInvalidSeedGeneration: diff --git a/Session/Meta/Session+SNUIKit.swift b/Session/Meta/Session+SNUIKit.swift index 54bb17e24a..aabc9f90a6 100644 --- a/Session/Meta/Session+SNUIKit.swift +++ b/Session/Meta/Session+SNUIKit.swift @@ -125,9 +125,21 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { } @MainActor func numberOfCharactersLeft(for text: String) -> Int { - return LibSession.numberOfCharactersLeft( - for: text, - isSessionPro: dependencies[cache: .libSession].isSessionPro - ) + return dependencies[singleton: .sessionProManager].numberOfCharactersLeft(for: text) + } + + func urlStringProvider() -> StringProvider.Url { + return Constants.urls + } + + func buildVariantStringProvider() -> StringProvider.BuildVariant { + return Constants.buildVariants + } + + func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> StringProvider.ClientPlatform { + switch platform { + case .iOS: return Constants.PaymentProvider.appStore + case .android: return Constants.PaymentProvider.playStore + } } } diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index d468e3d906..47d8ab2ba0 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -69,43 +69,59 @@ public class SessionApp: SessionAppType { self.homeViewController = homeViewController } - @MainActor public func presentConversationCreatingIfNeeded( + public func presentConversationCreatingIfNeeded( for threadId: String, variant: SessionThread.Variant, action: ConversationViewModel.Action = .none, dismissing presentingViewController: UIViewController?, animated: Bool - ) { + ) async { guard let homeViewController: HomeVC = self.homeViewController else { Log.error("[SessionApp] Unable to present conversation due to missing HomeVC.") return } - let threadExists: Bool? = dependencies[singleton: .storage].read { db in - SessionThread.filter(id: threadId).isNotEmpty(db) - } - /// The thread should generally exist at the time of calling this method, but on the off chance it doesn't then we need to /// `fetchOrCreate` it and should do it on a background thread just in case something is keeping the DBWrite thread /// busy as in the past this could cause the app to hang - creatingThreadIfNeededThenRunOnMain( - threadId: threadId, - variant: variant, - threadExists: (threadExists == true), - onComplete: { [weak self, dependencies] in - self?.showConversation( - threadId: threadId, - threadVariant: variant, - isMessageRequest: dependencies.mutate(cache: .libSession) { cache in - cache.isMessageRequest(threadId: threadId, threadVariant: variant) - }, - action: action, - dismissing: presentingViewController, - homeViewController: homeViewController, - animated: animated + let threadExists: Bool? = try? await dependencies[singleton: .storage].readAsync { db in + SessionThread.filter(id: threadId).isNotEmpty(db) + } + + if threadExists != true { + _ = try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try SessionThread.upsert( + db, + id: threadId, + variant: variant, + values: SessionThread.TargetValues( + shouldBeVisible: .useLibSession, + isDraft: .useExistingOrSetTo(true) + ), + using: dependencies ) } + } + + let maybeThreadInfo: ConversationInfoViewModel? = try? await ConversationViewModel.fetchConversationInfo( + threadId: threadId, + using: dependencies ) + + guard let threadInfo: ConversationInfoViewModel = maybeThreadInfo else { + Log.error("Failed to present \(variant) conversation \(threadId) due to failure to fetch threadViewModel") + return + } + + await MainActor.run { [weak self] in + self?.showConversation( + threadInfo: threadInfo, + action: action, + dismissing: presentingViewController, + homeViewController: homeViewController, + animated: animated + ) + } } public func createNewConversation() { @@ -176,41 +192,8 @@ public class SessionApp: SessionAppType { // MARK: - Internal Functions - @MainActor private func creatingThreadIfNeededThenRunOnMain( - threadId: String, - variant: SessionThread.Variant, - threadExists: Bool, - onComplete: @escaping () -> Void - ) { - guard !threadExists else { - return onComplete() - } - - Task(priority: .userInitiated) { [storage = dependencies[singleton: .storage], dependencies] in - storage.writeAsync( - updates: { db in - try SessionThread.upsert( - db, - id: threadId, - variant: variant, - values: SessionThread.TargetValues( - shouldBeVisible: .useLibSession, - isDraft: .useExistingOrSetTo(true) - ), - using: dependencies - ) - }, - completion: { _ in - Task { @MainActor in onComplete() } - } - ) - } - } - @MainActor private func showConversation( - threadId: String, - threadVariant: SessionThread.Variant, - isMessageRequest: Bool, + threadInfo: ConversationInfoViewModel, action: ConversationViewModel.Action, dismissing presentingViewController: UIViewController?, homeViewController: HomeVC, @@ -221,13 +204,12 @@ public class SessionApp: SessionAppType { homeViewController.navigationController?.setViewControllers( [ homeViewController, - (isMessageRequest && action != .compose ? + (threadInfo.isMessageRequest && action != .compose ? SessionTableViewController(viewModel: MessageRequestsViewModel(using: dependencies)) : nil ), ConversationVC( - threadId: threadId, - threadVariant: threadVariant, + threadInfo: threadInfo, focusedInteractionInfo: nil, using: dependencies ) @@ -244,13 +226,13 @@ public protocol SessionAppType { func setHomeViewController(_ homeViewController: HomeVC) @MainActor func showHomeView() - @MainActor func presentConversationCreatingIfNeeded( + func presentConversationCreatingIfNeeded( for threadId: String, variant: SessionThread.Variant, action: ConversationViewModel.Action, dismissing presentingViewController: UIViewController?, animated: Bool - ) + ) async func createNewConversation() func resetData(onReset: (() -> ())) @MainActor func showPromotedScreen() diff --git a/Session/Notifications/NotificationActionHandler.swift b/Session/Notifications/NotificationActionHandler.swift index 74b18f89f8..0a622aacd3 100644 --- a/Session/Notifications/NotificationActionHandler.swift +++ b/Session/Notifications/NotificationActionHandler.swift @@ -242,13 +242,15 @@ public class NotificationActionHandler { // If this happens when the the app is not, visible we skip the animation so the thread // can be visible to the user immediately upon opening the app, rather than having to watch // it animate in from the homescreen. - dependencies[singleton: .app].presentConversationCreatingIfNeeded( - for: threadId, - variant: threadVariant, - action: .none, - dismissing: dependencies[singleton: .app].homePresentedViewController, - animated: (UIApplication.shared.applicationState == .active) - ) + Task.detached(priority: .userInitiated) { [dependencies] in + await dependencies[singleton: .app].presentConversationCreatingIfNeeded( + for: threadId, + variant: threadVariant, + action: .none, + dismissing: dependencies[singleton: .app].homePresentedViewController, + animated: (UIApplication.shared.applicationState == .active) + ) + } } @MainActor func showHomeVC() { diff --git a/Session/Notifications/NotificationPresenter.swift b/Session/Notifications/NotificationPresenter.swift index b60ed79b92..2e921601b5 100644 --- a/Session/Notifications/NotificationPresenter.swift +++ b/Session/Notifications/NotificationPresenter.swift @@ -171,12 +171,20 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, .filter(id: threadId) .updateAll(db, changes) - if mentionsOnly == oldMentionsOnly { - db.addConversationEvent(id: threadId, type: .updated(.onlyNotifyForMentions(mentionsOnly))) + if mentionsOnly != oldMentionsOnly { + db.addConversationEvent( + id: threadId, + variant: threadVariant, + type: .updated(.onlyNotifyForMentions(mentionsOnly)) + ) } if mutedUntil != oldMutedUntil { - db.addConversationEvent(id: threadId, type: .updated(.mutedUntilTimestamp(mutedUntil))) + db.addConversationEvent( + id: threadId, + variant: threadVariant, + type: .updated(.mutedUntilTimestamp(mutedUntil)) + ) } } } @@ -427,15 +435,15 @@ private extension NotificationPresenter { /// Check whether the current `frontMostViewController` is a `ConversationVC` for the conversation this notification /// would belong to then we don't want to show the notification, so retrieve the `frontMostViewController` (from the main /// thread) and check - guard - let frontMostViewController: UIViewController = DispatchQueue.main.sync(execute: { - dependencies[singleton: .appContext].frontMostViewController - }), - let conversationViewController: ConversationVC = frontMostViewController as? ConversationVC - else { return true } + let currentOpenConversationThreadId: String? = DispatchQueue.main.sync(execute: { + (dependencies[singleton: .appContext].frontMostViewController as? ConversationVC)? + .viewModel + .state + .threadId + }) /// Show notifications for any **other** threads - return (conversationViewController.viewModel.threadData.threadId != threadId) + return (currentOpenConversationThreadId != threadId) } } diff --git a/Session/Onboarding/LandingScreen.swift b/Session/Onboarding/LandingScreen.swift index c94d6624e9..82041cc56f 100644 --- a/Session/Onboarding/LandingScreen.swift +++ b/Session/Onboarding/LandingScreen.swift @@ -188,12 +188,13 @@ struct LandingScreen: View { cancelStyle: .textPrimary, hasCloseButton: true, onConfirm: { _ in - if let url: URL = URL(string: "https://getsession.org/terms-of-service") { + // TODO: [PRO] Update this to use the double url modal + if let url: URL = URL(string: Constants.urls.termsOfService) { UIApplication.shared.open(url) } }, onCancel: { modal in - if let url: URL = URL(string: "https://getsession.org/privacy-policy") { + if let url: URL = URL(string: Constants.urls.privacyPolicy) { UIApplication.shared.open(url) } modal.close() diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 2f3ac00726..1d2a5b727a 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -276,7 +276,7 @@ extension Onboarding { userEd25519SecretKey: identity.ed25519KeyPair.secretKey, groupEd25519SecretKey: nil ) - try cache.unsafeDirectMergeConfigMessage( + _ = try cache.mergeConfigMessages( swarmPublicKey: userSessionId.hexString, messages: [ ConfigMessageReceiveJob.Details.MessageInfo( @@ -414,7 +414,9 @@ extension Onboarding { publicKey: userSessionId.hexString, displayNameUpdate: .currentUserUpdate(displayName), displayPictureUpdate: .none, + proUpdate: .none, profileUpdateTimestamp: dependencies.dateNow.timeIntervalSince1970, + currentUserSessionIds: [userSessionId.hexString], using: dependencies ) } @@ -433,10 +435,12 @@ extension Onboarding { /// won't actually get synced correctly and could result in linking a second device and having the 'Note to Self' conversation incorrectly /// being visible if initialFlow == .register { - try SessionThread.updateVisibility( + try SessionThread.update( db, - threadId: userSessionId.hexString, - isVisible: false, + id: userSessionId.hexString, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(false) + ), using: dependencies ) } diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index aa8c762255..5d257c6027 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -198,14 +198,11 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC onError: (() -> ())? ) { Task.detached(priority: .userInitiated) { [weak self, dependencies] in - let hasExistingOpenGroup: Bool = try await dependencies[singleton: .storage].readAsync { db in - dependencies[singleton: .openGroupManager].hasExistingOpenGroup( - db, - roomToken: roomToken, - server: server, - publicKey: publicKey - ) - } + let hasExistingOpenGroup: Bool = await dependencies[singleton: .communityManager].hasExistingCommunity( + roomToken: roomToken, + server: server, + publicKey: publicKey + ) guard !hasExistingOpenGroup else { await MainActor.run { [weak self] in @@ -243,7 +240,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self, dependencies] _ in dependencies[singleton: .storage] .writePublisher { db in - dependencies[singleton: .openGroupManager].add( + dependencies[singleton: .communityManager].add( db, roomToken: roomToken, server: server, @@ -253,7 +250,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC ) } .flatMap { successfullyAddedGroup in - dependencies[singleton: .openGroupManager].performInitialRequestsAfterAdd( + dependencies[singleton: .communityManager].performInitialRequestsAfterAdd( queue: DispatchQueue.global(qos: .userInitiated), successfullyAddedGroup: successfullyAddedGroup, roomToken: roomToken, @@ -271,7 +268,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC // the next launch so remove it (the user will be left on the previous // screen so can re-trigger the join) dependencies[singleton: .storage].writeAsync { db in - try dependencies[singleton: .openGroupManager].delete( + try dependencies[singleton: .communityManager].delete( db, openGroupId: OpenGroup.idFor(roomToken: roomToken, server: server), skipLibSessionUpdate: false @@ -289,14 +286,17 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC } case .finished: - self?.presentingViewController?.dismiss(animated: true, completion: nil) + guard shouldOpenCommunity else { + self?.presentingViewController?.dismiss(animated: true, completion: nil) + return + } - if shouldOpenCommunity { - dependencies[singleton: .app].presentConversationCreatingIfNeeded( + Task.detached(priority: .userInitiated) { + await dependencies[singleton: .app].presentConversationCreatingIfNeeded( for: OpenGroup.idFor(roomToken: roomToken, server: server), variant: .community, action: .none, - dismissing: nil, + dismissing: self?.presentingViewController, animated: false ) } diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index ddc4c72ba9..3b43fa1c04 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -13,23 +13,27 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle private let dependencies: Dependencies private let itemsPerSection: Int = (UIDevice.current.isIPad ? 4 : 2) private var maxWidth: CGFloat - private var data: [OpenGroupManager.DefaultRoomInfo] = [] { - didSet { - // Start an observer for changes - let updatedIds: Set = data.map { $0.openGroup.id }.asSet() - - if oldValue.map({ $0.openGroup.id }).asSet() != updatedIds { - startObservingRoomChanges(for: updatedIds) - } - } - } - private var dataChangeObservable: DatabaseCancellable? { - didSet { oldValue?.cancel() } // Cancel the old observable if there was one - } + private var state: State private var heightConstraint: NSLayoutConstraint! + private var defaultRoomObservationTask: Task? + private var defaultRoomDisplayPictureObservationTask: Task? var delegate: OpenGroupSuggestionGridDelegate? + struct State: ObservableKeyProvider { + let server: String + let skipAuthentication: Bool + let data: [Network.SOGS.Room] + + var observedKeys: Set { + return Set(data.map { + let id: String = OpenGroup.idFor(roomToken: $0.token, server: server) + + return ObservableKey.conversationUpdated(id) + }) + } + } + // MARK: - UI private static let cellHeight: CGFloat = 40 @@ -115,6 +119,11 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle init(maxWidth: CGFloat, using dependencies: Dependencies) { self.dependencies = dependencies self.maxWidth = maxWidth + self.state = State( + server: Network.SOGS.defaultServer, + skipAuthentication: true, + data: [] + ) super.init(frame: CGRect.zero) @@ -129,6 +138,11 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle preconditionFailure("Use init(maxWidth:) instead.") } + deinit { + defaultRoomObservationTask?.cancel() + defaultRoomDisplayPictureObservationTask?.cancel() + } + private func initialize() { addSubview(collectionView) collectionView.pin(to: self) @@ -159,54 +173,42 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle heightConstraint = set(.height, to: OpenGroupSuggestionGrid.cellHeight) widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true - dependencies[cache: .openGroupManager].defaultRoomsPublisher - .subscribe(on: DispatchQueue.global(qos: .default)) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure: self?.update() - } - }, - receiveValue: { [weak self] roomInfo in self?.data = roomInfo } - ) - } - - // MARK: - Updating - - private func startObservingRoomChanges(for openGroupIds: Set) { - // We don't actually care about the updated data as the 'update' function has the logic - // to fetch any newly downloaded images - dataChangeObservable = dependencies[singleton: .storage].start( - ValueObservation - .tracking( - regions: [ - OpenGroup.select(.name).filter(ids: openGroupIds), - OpenGroup.select(.roomDescription).filter(ids: openGroupIds), - OpenGroup.select(.displayPictureOriginalUrl).filter(ids: openGroupIds) - ], - fetch: { db in try OpenGroup.filter(ids: openGroupIds).fetchAll(db) } + defaultRoomObservationTask = Task.detached(priority: .userInitiated) { [weak self, manager = dependencies[singleton: .communityManager], dependencies] in + for await roomInfo in manager.defaultRooms { + guard let self else { return } + guard !roomInfo.rooms.isEmpty else { continue } + + /// Update the data + let updatedState: State = await State( + server: self.state.server, + skipAuthentication: self.state.skipAuthentication, + data: roomInfo.rooms ) - .removeDuplicates(), - onError: { _ in }, - onChange: { [weak self] result in - guard let strongSelf = self else { return } - let updatedGroupsByToken: [String: OpenGroup] = result - .reduce(into: [:]) { result, next in result[next.roomToken] = next } - strongSelf.data = strongSelf.data - .map { room, oldGroup in (room, (updatedGroupsByToken[room.token] ?? oldGroup)) } - strongSelf.update() + await MainActor.run { [weak self] in + self?.state = updatedState + self?.update() + + /// Observe changes to the data (no need to update the state, just refresh the UI if images were downloaded) + self?.defaultRoomDisplayPictureObservationTask?.cancel() + self?.defaultRoomDisplayPictureObservationTask = ObservationBuilder + .initialValue(updatedState) + .debounce(for: .milliseconds(250)) + .using(dependencies: dependencies) + .query({ previousState, _, _, _ -> State in previousState }) + .assign { [weak self] _ in self?.update() } + } } - ) + } } + // MARK: - Updating + private func update() { spinner.stopAnimating() spinner.isHidden = true - let roomCount: CGFloat = CGFloat(min(data.count, 8)) // Cap to a maximum of 8 (4 rows of 2) + let roomCount: CGFloat = CGFloat(min(state.data.count, 8)) // Cap to a maximum of 8 (4 rows of 2) let numRows: CGFloat = ceil(roomCount / CGFloat(OpenGroupSuggestionGrid.numHorizontalCells)) let height: CGFloat = ((OpenGroupSuggestionGrid.cellHeight * numRows) + ((numRows - 1) * layout.minimumLineSpacing)) heightConstraint.constant = height @@ -233,7 +235,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle // If there isn't an even number of items then we want to calculate proper sizing return CGSize( - width: Cell.calculatedWith(for: data[indexPath.item].room.name), + width: Cell.calculatedWith(for: state.data[indexPath.item].name), height: OpenGroupSuggestionGrid.cellHeight ) } @@ -241,12 +243,17 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle // MARK: - Data Source func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return min(data.count, 8) // Cap to a maximum of 8 (4 rows of 2) + return min(state.data.count, 8) // Cap to a maximum of 8 (4 rows of 2) } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath) - cell.update(with: data[indexPath.item].room, openGroup: data[indexPath.item].openGroup, using: dependencies) + cell.update( + with: state.data[indexPath.item], + server: state.server, + skipAuthentication: state.skipAuthentication, + using: dependencies + ) return cell } @@ -254,7 +261,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle // MARK: - Interaction func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let room = data[indexPath.section * itemsPerSection + indexPath.item].room + let room = state.data[indexPath.section * itemsPerSection + indexPath.item] delegate?.join(room) collectionView.deselectItem(at: indexPath, animated: true) } @@ -355,25 +362,39 @@ extension OpenGroupSuggestionGrid { snContentView.pin(to: self) } - fileprivate func update(with room: Network.SOGS.Room, openGroup: OpenGroup, using dependencies: Dependencies) { + fileprivate func update( + with room: Network.SOGS.Room, + server: String, + skipAuthentication: Bool, + using dependencies: Dependencies + ) { label.text = room.name - let maybePath: String? = openGroup.displayPictureOriginalUrl - .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } - - switch maybePath { - case .some(let path): - imageView.isHidden = false - imageView.setDataManager(dependencies[singleton: .imageDataManager]) - imageView.loadImage(from: path) - - case .none: - imageView.isHidden = true - - dependencies[singleton: .displayPictureManager].scheduleDownload( - for: .community(openGroup) + guard let imageId: String = room.imageId else { + imageView.isHidden = true + return + } + guard + let path: String = try? dependencies[singleton: .displayPictureManager].path( + for: Network.SOGS.downloadUrlString(for: imageId, server: server, roomToken: room.token) + ), + dependencies[singleton: .fileManager].fileExists(atPath: path) + else { + dependencies[singleton: .displayPictureManager].scheduleDownload( + for: .community( + imageId: imageId, + roomToken: room.token, + server: server, + skipAuthentication: skipAuthentication ) + ) + imageView.isHidden = true + return } + + imageView.isHidden = false + imageView.setDataManager(dependencies[singleton: .imageDataManager]) + imageView.loadImage(from: path) } } } diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index b78a658175..4ec5126844 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -302,7 +302,9 @@ public class BlockedContactsViewModel: SessionTableViewModel, NavigatableStateHo (model.profile?.displayName() ?? model.id.truncated()), font: .title, trailingImage: { - guard (viewModel.dependencies.mutate(cache: .libSession) { $0.validateProProof(for: model.profile) }) else { return nil } + guard model.profile?.proFeatures.contains(.proBadge) == true else { + return nil + } return SessionProBadge.trailingImage( size: .small, diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsModalsAndBannersViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsModalsAndBannersViewModel.swift index 6611d7ca5f..5965e6d096 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsModalsAndBannersViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsModalsAndBannersViewModel.swift @@ -128,8 +128,8 @@ class DeveloperSettingsModalsAndBannersViewModel: SessionTableViewModel, Navigat public struct State: Equatable, ObservableKeyProvider { let donationsCTAModalAppearanceCount: Int - let donationsCTAModalLastAppearanceTimestamp: TimeInterval? - let customFirstInstallDateTime: TimeInterval? + let donationsCTAModalLastAppearanceTimestamp: TimeInterval + let customFirstInstallDateTime: TimeInterval let donationsUrlOpenCount: Int let donationsUrlCopyCount: Int diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 4a9d5cb100..570189f74f 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -49,12 +49,14 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public enum Section: SessionTableSection { case general case subscriptions + case proBackend case features var title: String? { switch self { case .general: return nil case .subscriptions: return "Subscriptions" + case .proBackend: return "Pro Backend" case .features: return "Features" } } @@ -70,28 +72,28 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public enum TableItem: Hashable, Differentiable, CaseIterable { case enableSessionPro + case mockCurrentUserSessionProBuildVariant + case mockCurrentUserSessionProBackendStatus + case mockCurrentUserSessionProLoadingState + case mockCurrentUserSessionProOriginatingPlatform + case mockCurrentUserOriginatingAccount + case mockCurrentUserAccessExpiryTimestamp + case proBadgeEverywhere + case fakeAppleSubscriptionForDev + + case forceMessageFeatureProBadge + case forceMessageFeatureLongMessage + case forceMessageFeatureAnimatedAvatar + case purchaseProSubscription case manageProSubscriptions case restoreProSubscription case requestRefund - case proStatus - case loadingState - - case allUsersSessionPro - - case messageFeatureProBadge - case messageFeatureLongMessage - case messageFeatureAnimatedAvatar - - case proPlanToRecover - case proPlanExpiry - case proPlanExpiredOverThirtyDays - case mockInstalledFromIPA - case originatingPlatform - case nonOriginatingAccount - - + case submitPurchaseToProBackend + case refreshProState + case resetRevocationListTicket + case removeProFromUserConfig // MARK: - Conformance @@ -101,26 +103,28 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold switch self { case .enableSessionPro: return "enableSessionPro" + case .mockCurrentUserSessionProBuildVariant: return "mockCurrentUserSessionProBuildVariant" + case .mockCurrentUserSessionProBackendStatus: return "mockCurrentUserSessionProBackendStatus" + case .mockCurrentUserSessionProLoadingState: return "mockCurrentUserSessionProLoadingState" + case .mockCurrentUserSessionProOriginatingPlatform: return "mockCurrentUserSessionProOriginatingPlatform" + case .mockCurrentUserOriginatingAccount: return "mockCurrentUserOriginatingAccount" + case .mockCurrentUserAccessExpiryTimestamp: return "mockCurrentUserAccessExpiryTimestamp" + case .proBadgeEverywhere: return "proBadgeEverywhere" + case .fakeAppleSubscriptionForDev: return "fakeAppleSubscriptionForDev" + + case .forceMessageFeatureProBadge: return "forceMessageFeatureProBadge" + case .forceMessageFeatureLongMessage: return "forceMessageFeatureLongMessage" + case .forceMessageFeatureAnimatedAvatar: return "forceMessageFeatureAnimatedAvatar" + case .purchaseProSubscription: return "purchaseProSubscription" case .manageProSubscriptions: return "manageProSubscriptions" case .restoreProSubscription: return "restoreProSubscription" case .requestRefund: return "requestRefund" - - case .proStatus: return "proStatus" - case .loadingState: return "loadingState" - - case .allUsersSessionPro: return "allUsersSessionPro" - - case .messageFeatureProBadge: return "messageFeatureProBadge" - case .messageFeatureLongMessage: return "messageFeatureLongMessage" - case .messageFeatureAnimatedAvatar: return "messageFeatureAnimatedAvatar" - case .proPlanToRecover: return "proPlanToRecover" - case .proPlanExpiry: return "proPlanExpiry" - case .proPlanExpiredOverThirtyDays: return "proPlanExpiredOverThirtyDays" - case .mockInstalledFromIPA: return "mockInstalledFromIPA" - case .originatingPlatform: return "originatingPlatform" - case .nonOriginatingAccount: return "nonOriginatingAccount" + case .submitPurchaseToProBackend: return "submitPurchaseToProBackend" + case .refreshProState: return "refreshProState" + case .resetRevocationListTicket: return "resetRevocationListTicket" + case .removeProFromUserConfig: return "removeProFromUserConfig" } } @@ -132,27 +136,29 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold var result: [TableItem] = [] switch TableItem.enableSessionPro { case .enableSessionPro: result.append(.enableSessionPro); fallthrough - + + case .mockCurrentUserSessionProBuildVariant: result.append(.mockCurrentUserSessionProBuildVariant); fallthrough + case .mockCurrentUserSessionProBackendStatus: result.append(.mockCurrentUserSessionProBackendStatus); fallthrough + case .mockCurrentUserSessionProLoadingState: result.append(.mockCurrentUserSessionProLoadingState); fallthrough + case .mockCurrentUserSessionProOriginatingPlatform: result.append(.mockCurrentUserSessionProOriginatingPlatform); fallthrough + case .mockCurrentUserAccessExpiryTimestamp: result.append(.mockCurrentUserAccessExpiryTimestamp); fallthrough + case .mockCurrentUserOriginatingAccount: result.append(.mockCurrentUserOriginatingAccount); fallthrough + case .proBadgeEverywhere: result.append(.proBadgeEverywhere); fallthrough + case .fakeAppleSubscriptionForDev: result.append(.fakeAppleSubscriptionForDev); fallthrough + + case .forceMessageFeatureProBadge: result.append(.forceMessageFeatureProBadge); fallthrough + case .forceMessageFeatureLongMessage: result.append(.forceMessageFeatureLongMessage); fallthrough + case .forceMessageFeatureAnimatedAvatar: result.append(.forceMessageFeatureAnimatedAvatar); fallthrough + case .purchaseProSubscription: result.append(.purchaseProSubscription); fallthrough case .manageProSubscriptions: result.append(.manageProSubscriptions); fallthrough case .restoreProSubscription: result.append(.restoreProSubscription); fallthrough case .requestRefund: result.append(.requestRefund); fallthrough - - case .proStatus: result.append(.proStatus); fallthrough - case .loadingState: result.append(.loadingState); fallthrough - - case .allUsersSessionPro: result.append(.allUsersSessionPro); fallthrough - case .messageFeatureProBadge: result.append(.messageFeatureProBadge); fallthrough - case .messageFeatureLongMessage: result.append(.messageFeatureLongMessage); fallthrough - case .messageFeatureAnimatedAvatar: result.append(.messageFeatureAnimatedAvatar); fallthrough - - case .proPlanToRecover: result.append(.proPlanToRecover); fallthrough - case .proPlanExpiry: result.append(.proPlanExpiry); fallthrough - case .proPlanExpiredOverThirtyDays: result.append(.proPlanExpiredOverThirtyDays); fallthrough - case .mockInstalledFromIPA: result.append(.mockInstalledFromIPA); fallthrough - case .originatingPlatform: result.append(.originatingPlatform); fallthrough - case .nonOriginatingAccount: result.append(.nonOriginatingAccount) + case .submitPurchaseToProBackend: result.append(.submitPurchaseToProBackend); fallthrough + case .refreshProState: result.append(.refreshProState); fallthrough + case .resetRevocationListTicket: result.append(.resetRevocationListTicket); fallthrough + case .removeProFromUserConfig: result.append(.removeProFromUserConfig) } return result @@ -162,6 +168,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public enum DeveloperSettingsProEvent: Hashable { case purchasedProduct([Product], Product?, String?, String?, Transaction?) case refundTransaction(Transaction.RefundRequestStatus) + case submittedTransaction(String?, Bool) + case currentProStatus(String?, Bool) } // MARK: - Content @@ -169,6 +177,19 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public struct State: Equatable, ObservableKeyProvider { let sessionProEnabled: Bool + let mockCurrentUserSessionProBuildVariant: MockableFeature + let mockCurrentUserSessionProBackendStatus: MockableFeature + let mockCurrentUserSessionProLoadingState: MockableFeature + let mockCurrentUserSessionProOriginatingPlatform: MockableFeature + let mockCurrentUserOriginatingAccount: MockableFeature + let mockCurrentUserAccessExpiryTimestamp: TimeInterval + let proBadgeEverywhere: Bool + let fakeAppleSubscriptionForDev: Bool + + let forceMessageFeatureProBadge: Bool + let forceMessageFeatureLongMessage: Bool + let forceMessageFeatureAnimatedAvatar: Bool + let products: [Product] let purchasedProduct: Product? let purchaseError: String? @@ -176,21 +197,12 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let purchaseTransaction: Transaction? let refundRequestStatus: Transaction.RefundRequestStatus? - let mockCurrentUserSessionPro: SessionProStateMock - let loadingState: SessionProLoadingState - - let allUsersSessionPro: Bool - - let messageFeatureProBadge: Bool - let messageFeatureLongMessage: Bool - let messageFeatureAnimatedAvatar: Bool + let submittedTransactionStatus: String? + let submittedTransactionErrored: Bool - let proPlanToRecover: Bool - let proPlanExpiry: SessionProStateExpiryMock - let proPlanExpiredOverThirtyDays: Bool - let mockInstalledFromIPA: Bool - let originatingPlatform: ClientPlatform - let nonOriginatingAccount: Bool + let currentProStatus: String? + let currentProStatusErrored: Bool + let currentRevocationListTicket: UInt32 @MainActor public func sections(viewModel: DeveloperSettingsProViewModel, previousState: State) -> [SectionModel] { DeveloperSettingsProViewModel.sections( @@ -202,25 +214,38 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public let observedKeys: Set = [ .feature(.sessionProEnabled), - .updateScreen(DeveloperSettingsProViewModel.self), - .feature(.mockCurrentUserSessionProState), + .feature(.mockCurrentUserSessionProBuildVariant), + .feature(.mockCurrentUserSessionProBackendStatus), .feature(.mockCurrentUserSessionProLoadingState), - .feature(.allUsersSessionPro), - .feature(.messageFeatureProBadge), - .feature(.messageFeatureLongMessage), - .feature(.messageFeatureAnimatedAvatar), - .feature(.proPlanToRecover), - .feature(.mockCurrentUserSessionProExpiry), - .feature(.mockExpiredOverThirtyDays), - .feature(.mockInstalledFromIPA), - .feature(.proPlanOriginatingPlatform), - .feature(.mockNonOriginatingAccount) + .feature(.mockCurrentUserSessionProOriginatingPlatform), + .feature(.mockCurrentUserOriginatingAccount), + .feature(.mockCurrentUserAccessExpiryTimestamp), + .feature(.proBadgeEverywhere), + .feature(.fakeAppleSubscriptionForDev), + .feature(.forceMessageFeatureProBadge), + .feature(.forceMessageFeatureLongMessage), + .feature(.forceMessageFeatureAnimatedAvatar), + .updateScreen(DeveloperSettingsProViewModel.self), + .proRevocationListUpdated ] static func initialState(using dependencies: Dependencies) -> State { return State( sessionProEnabled: dependencies[feature: .sessionProEnabled], + mockCurrentUserSessionProBuildVariant: dependencies[feature: .mockCurrentUserSessionProBuildVariant], + mockCurrentUserSessionProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], + mockCurrentUserSessionProLoadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], + mockCurrentUserSessionProOriginatingPlatform: dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform], + mockCurrentUserOriginatingAccount: dependencies[feature: .mockCurrentUserOriginatingAccount], + mockCurrentUserAccessExpiryTimestamp: dependencies[feature: .mockCurrentUserAccessExpiryTimestamp], + proBadgeEverywhere: dependencies[feature: .proBadgeEverywhere], + fakeAppleSubscriptionForDev: dependencies[feature: .fakeAppleSubscriptionForDev], + + forceMessageFeatureProBadge: dependencies[feature: .forceMessageFeatureProBadge], + forceMessageFeatureLongMessage: dependencies[feature: .forceMessageFeatureLongMessage], + forceMessageFeatureAnimatedAvatar: dependencies[feature: .forceMessageFeatureAnimatedAvatar], + products: [], purchasedProduct: nil, purchaseError: nil, @@ -228,21 +253,12 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchaseTransaction: nil, refundRequestStatus: nil, - mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionProState], - loadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], + submittedTransactionStatus: nil, + submittedTransactionErrored: false, - allUsersSessionPro: dependencies[feature: .allUsersSessionPro], - - messageFeatureProBadge: dependencies[feature: .messageFeatureProBadge], - messageFeatureLongMessage: dependencies[feature: .messageFeatureLongMessage], - messageFeatureAnimatedAvatar: dependencies[feature: .messageFeatureAnimatedAvatar], - - proPlanToRecover: dependencies[feature: .proPlanToRecover], - proPlanExpiry: dependencies[feature: .mockCurrentUserSessionProExpiry], - proPlanExpiredOverThirtyDays: dependencies[feature: .mockExpiredOverThirtyDays], - mockInstalledFromIPA: dependencies[feature: .mockInstalledFromIPA], - originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], - nonOriginatingAccount: dependencies[feature: .mockNonOriginatingAccount] + currentProStatus: nil, + currentProStatusErrored: false, + currentRevocationListTicket: 0 ) } } @@ -255,16 +271,28 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold isInitialQuery: Bool, using dependencies: Dependencies ) async -> State { + var currentProStatus: String? = previousState.currentProStatus + var currentProStatusErrored: Bool = previousState.currentProStatusErrored + var products: [Product] = previousState.products var purchasedProduct: Product? = previousState.purchasedProduct var purchaseError: String? = previousState.purchaseError var purchaseStatus: String? = previousState.purchaseStatus var purchaseTransaction: Transaction? = previousState.purchaseTransaction var refundRequestStatus: Transaction.RefundRequestStatus? = previousState.refundRequestStatus + var submittedTransactionStatus: String? = previousState.submittedTransactionStatus + var submittedTransactionErrored: Bool = previousState.submittedTransactionErrored + var currentRevocationListTicket: UInt32 = previousState.currentRevocationListTicket - events.forEach { event in - guard let eventValue: DeveloperSettingsProEvent = event.value as? DeveloperSettingsProEvent else { return } - + if isInitialQuery { + currentRevocationListTicket = ((try? await dependencies[singleton: .storage].readAsync { db in + UInt32(db[.proRevocationsTicket] ?? 0) + }) ?? 0) + } + + let changes: EventChangeset = events.split() + + changes.forEach(.updateScreen, as: DeveloperSettingsProEvent.self) { eventValue in switch eventValue { case .purchasedProduct(let receivedProducts, let purchased, let error, let status, let transaction): products = receivedProducts @@ -275,29 +303,47 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .refundTransaction(let status): refundRequestStatus = status + + case .submittedTransaction(let status, let errored): + submittedTransactionStatus = status + submittedTransactionErrored = errored + + case .currentProStatus(let status, let errored): + currentProStatus = status + currentProStatusErrored = errored } } + if changes.contains(.proRevocationListUpdated) { + currentRevocationListTicket = ((try? await dependencies[singleton: .storage].readAsync { db in + UInt32(db[.proRevocationsTicket] ?? 0) + }) ?? currentRevocationListTicket) + } + return State( sessionProEnabled: dependencies[feature: .sessionProEnabled], + mockCurrentUserSessionProBuildVariant: dependencies[feature: .mockCurrentUserSessionProBuildVariant], + mockCurrentUserSessionProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], + mockCurrentUserSessionProLoadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], + mockCurrentUserSessionProOriginatingPlatform: dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform], + mockCurrentUserOriginatingAccount: dependencies[feature: .mockCurrentUserOriginatingAccount], + mockCurrentUserAccessExpiryTimestamp: dependencies[feature: .mockCurrentUserAccessExpiryTimestamp], + proBadgeEverywhere: dependencies[feature: .proBadgeEverywhere], + fakeAppleSubscriptionForDev: dependencies[feature: .fakeAppleSubscriptionForDev], + forceMessageFeatureProBadge: dependencies[feature: .forceMessageFeatureProBadge], + forceMessageFeatureLongMessage: dependencies[feature: .forceMessageFeatureLongMessage], + forceMessageFeatureAnimatedAvatar: dependencies[feature: .forceMessageFeatureAnimatedAvatar], products: products, purchasedProduct: purchasedProduct, purchaseError: purchaseError, purchaseStatus: purchaseStatus, purchaseTransaction: purchaseTransaction, refundRequestStatus: refundRequestStatus, - mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionProState], - loadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], - allUsersSessionPro: dependencies[feature: .allUsersSessionPro], - messageFeatureProBadge: dependencies[feature: .messageFeatureProBadge], - messageFeatureLongMessage: dependencies[feature: .messageFeatureLongMessage], - messageFeatureAnimatedAvatar: dependencies[feature: .messageFeatureAnimatedAvatar], - proPlanToRecover: dependencies[feature: .proPlanToRecover], - proPlanExpiry: dependencies[feature: .mockCurrentUserSessionProExpiry], - proPlanExpiredOverThirtyDays: dependencies[feature: .mockExpiredOverThirtyDays], - mockInstalledFromIPA: dependencies[feature: .mockInstalledFromIPA], - originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], - nonOriginatingAccount: dependencies[feature: .mockNonOriginatingAccount] + submittedTransactionStatus: submittedTransactionStatus, + submittedTransactionErrored: submittedTransactionErrored, + currentProStatus: currentProStatus, + currentProStatusErrored: currentProStatusErrored, + currentRevocationListTicket: currentRevocationListTicket ) } @@ -329,6 +375,267 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold guard state.sessionProEnabled else { return [general] } + // MARK: - Mockable Features + + let features: SectionModel = SectionModel( + model: .features, + elements: [ + SessionCell.Info( + id: .mockCurrentUserSessionProBuildVariant, + title: "Mocked Build Variant", + subtitle: """ + Force the app to be a specific build variant. + + Current: \(devValue: state.mockCurrentUserSessionProBuildVariant) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + DeveloperSettingsViewModel.showModalForMockableState( + title: "Mocked Build Variant", + explanation: "Force the app to be a specific build variant.", + feature: .mockCurrentUserSessionProBuildVariant, + currentValue: state.mockCurrentUserSessionProBuildVariant, + navigatableStateHolder: viewModel, + onMockingRemoved: { [dependencies] in + Task.detached(priority: .userInitiated) { [dependencies] in + try? await dependencies[singleton: .sessionProManager].refreshProState() + } + }, + using: viewModel?.dependencies + ) + } + ), + SessionCell.Info( + id: .mockCurrentUserSessionProBackendStatus, + title: "Mocked Pro Status", + subtitle: """ + Force the current users Session Pro to a specific status locally. + + Current: \(devValue: state.mockCurrentUserSessionProBackendStatus) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + DeveloperSettingsViewModel.showModalForMockableState( + title: "Mocked Pro Status", + explanation: "Force the current users Session Pro to a specific status locally.", + feature: .mockCurrentUserSessionProBackendStatus, + currentValue: state.mockCurrentUserSessionProBackendStatus, + navigatableStateHolder: viewModel, + onMockingRemoved: { [dependencies] in + Task.detached(priority: .userInitiated) { [dependencies] in + try? await dependencies[singleton: .sessionProManager].refreshProState() + } + }, + using: viewModel?.dependencies + ) + } + ), + SessionCell.Info( + id: .mockCurrentUserSessionProLoadingState, + title: "Mocked Loading State", + subtitle: """ + Force the Session Pro UI into a specific loading state. + + Current: \(devValue: state.mockCurrentUserSessionProLoadingState) + + Note: This option will only be available if the users pro state has been mocked, there is already a mocked loading state, or the users pro state has been fetched via the "Refresh Pro State" action on this screen. + """, + trailingAccessory: .icon(.squarePen), + isEnabled: { + switch (state.mockCurrentUserSessionProLoadingState, state.mockCurrentUserSessionProBackendStatus, state.currentProStatus) { + case (.simulate, _, _), (_, .simulate, _), (_, _, .some): return true + default: return false + } + }(), + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + DeveloperSettingsViewModel.showModalForMockableState( + title: "Mocked Loading State", + explanation: "Force the Session Pro UI into a specific loading state.", + feature: .mockCurrentUserSessionProLoadingState, + currentValue: state.mockCurrentUserSessionProLoadingState, + navigatableStateHolder: viewModel, + onMockingRemoved: { [dependencies] in + Task.detached(priority: .userInitiated) { [dependencies] in + try? await dependencies[singleton: .sessionProManager].refreshProState() + } + }, + using: viewModel?.dependencies + ) + } + ), + SessionCell.Info( + id: .mockCurrentUserSessionProOriginatingPlatform, + title: "Mocked Originating Platform", + subtitle: """ + Force the current users Session Pro to have originated from a specific platform. + + Current: \(devValue: state.mockCurrentUserSessionProOriginatingPlatform) + + Note: This option will only be available if the users pro state has been mocked, there is already a mocked loading state, or the users pro state has been fetched via the "Refresh Pro State" action on this screen. + """, + trailingAccessory: .icon(.squarePen), + isEnabled: { + switch (state.mockCurrentUserSessionProLoadingState, state.mockCurrentUserSessionProBackendStatus, state.currentProStatus) { + case (.simulate, _, _), (_, .simulate, _), (_, _, .some): return true + default: return false + } + }(), + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + DeveloperSettingsViewModel.showModalForMockableState( + title: "Mocked Originating Platform", + explanation: "Force the current users Session Pro to have originated from a specific platform.", + feature: .mockCurrentUserSessionProOriginatingPlatform, + currentValue: state.mockCurrentUserSessionProOriginatingPlatform, + navigatableStateHolder: viewModel, + onMockingRemoved: { [dependencies] in + Task.detached(priority: .userInitiated) { [dependencies] in + try? await dependencies[singleton: .sessionProManager].refreshProState() + } + }, + using: viewModel?.dependencies + ) + } + ), + SessionCell.Info( + id: .mockCurrentUserOriginatingAccount, + title: "Mocked Originating Account", + subtitle: """ + Force the current users Session Pro to have originated from a specific account. + + Current: \(devValue: state.mockCurrentUserOriginatingAccount) + + Note: This option will only be available if the users pro state has been mocked, there is already a mocked loading state, or the users pro state has been fetched via the "Refresh Pro State" action on this screen. + """, + trailingAccessory: .icon(.squarePen), + isEnabled: { + switch (state.mockCurrentUserSessionProLoadingState, state.mockCurrentUserSessionProBackendStatus, state.currentProStatus) { + case (.simulate, _, _), (_, .simulate, _), (_, _, .some): return true + default: return false + } + }(), + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + DeveloperSettingsViewModel.showModalForMockableState( + title: "Mocked Originating Account", + explanation: "Force the current users Session Pro to have originated from a specific account.", + feature: .mockCurrentUserOriginatingAccount, + currentValue: state.mockCurrentUserOriginatingAccount, + navigatableStateHolder: viewModel, + onMockingRemoved: { [dependencies] in + Task.detached(priority: .userInitiated) { [dependencies] in + try? await dependencies[singleton: .sessionProManager].refreshProState() + } + }, + using: viewModel?.dependencies + ) + } + ), + SessionCell.Info( + id: .mockCurrentUserAccessExpiryTimestamp, + title: "Mocked Access Expiry Date/Time", + subtitle: """ + Specify a custom date/time that the users Session Pro should expire. + + Current: \(devValue: viewModel.dependencies[feature: .mockCurrentUserAccessExpiryTimestamp]) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + DeveloperSettingsViewModel.showModalForMockableDate( + title: "Mocked Access Expiry Date/Time", + explanation: "The custom date/time the users Session Pro should expire.", + feature: .mockCurrentUserAccessExpiryTimestamp, + navigatableStateHolder: viewModel, + using: dependencies + ) + } + ), + SessionCell.Info( + id: .proBadgeEverywhere, + title: "Show the Pro Badge everywhere", + subtitle: """ + Force the pro badge to show everywhere. + + Note: On the "Message Info" screen this will make the Pro Badge appear against the sender profile info, but the message feature pro badge will show based on the "Message Feature: Pro Badge" setting below. + """, + trailingAccessory: .toggle( + state.proBadgeEverywhere, + oldValue: previousState.proBadgeEverywhere + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .proBadgeEverywhere, + to: !state.proBadgeEverywhere + ) + } + ), + SessionCell.Info( + id: .fakeAppleSubscriptionForDev, + title: "Fake the Apple Subscription for Pro Purchases", + subtitle: """ + Apple subscriptions (even with Sandbox accounts) can't be tested on the iOS Simulator, to work around this the dev pro server allows "fake" transaction identifiers for the purposes of testing. + + This setting will bypass the AppStore section of the purchase flow and generate a fake transaction identifier to send to the Pro backend to create the purchase. + """, + trailingAccessory: .toggle( + state.fakeAppleSubscriptionForDev, + oldValue: previousState.fakeAppleSubscriptionForDev + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .fakeAppleSubscriptionForDev, + to: !state.fakeAppleSubscriptionForDev + ) + } + ), + SessionCell.Info( + id: .forceMessageFeatureProBadge, + title: "Message Feature: Pro Badge", + subtitle: "Force all messages to show the \"Pro Badge\" feature.", + trailingAccessory: .toggle( + state.forceMessageFeatureProBadge, + oldValue: previousState.forceMessageFeatureProBadge + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .forceMessageFeatureProBadge, + to: !state.forceMessageFeatureProBadge + ) + } + ), + SessionCell.Info( + id: .forceMessageFeatureLongMessage, + title: "Message Feature: Long Message", + subtitle: "Force all messages to show the \"Long Message\" feature.", + trailingAccessory: .toggle( + state.forceMessageFeatureLongMessage, + oldValue: previousState.forceMessageFeatureLongMessage + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .forceMessageFeatureLongMessage, + to: !state.forceMessageFeatureLongMessage + ) + } + ), + SessionCell.Info( + id: .forceMessageFeatureAnimatedAvatar, + title: "Message Feature: Animated Avatar", + subtitle: "Force all messages to show the \"Animated Avatar\" feature.", + trailingAccessory: .toggle( + state.forceMessageFeatureAnimatedAvatar, + oldValue: previousState.forceMessageFeatureAnimatedAvatar + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .forceMessageFeatureAnimatedAvatar, + to: !state.forceMessageFeatureAnimatedAvatar + ) + } + ) + ] + ) + + // MARK: - Actual Pro Transactions and APIs + let purchaseStatus: String = { switch (state.purchaseError, state.purchaseStatus) { case (.some(let error), _): return "\(error)" @@ -352,6 +659,20 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold @unknown default: return "N/A" } }() + let submittedTransactionStatus: String = { + switch (state.submittedTransactionStatus, state.submittedTransactionErrored) { + case (.some(let error), true): return "\(error)" + case (.some(let status), false): return "\(status)" + case (.none, _): return "None" + } + }() + let currentProStatus: String = { + switch (state.currentProStatus, state.currentProStatusErrored) { + case (.some(let error), true): return "\(error)" + case (.some(let status), false): return "\(status)" + case (.none, _): return "Unknown" + } + }() let subscriptions: SectionModel = SectionModel( model: .subscriptions, elements: [ @@ -361,13 +682,17 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold subtitle: """ Purchase Session Pro via the App Store. + Notes: + • This only works on a real device (and some old iOS versions don't seem to support Sandbox accounts (eg. iOS 16). + • This subscription isn't connected to the Session account by default (they are for testing purposes) + Status: \(purchaseStatus) Product Name: \(productName) TransactionId: \(transactionId) """, trailingAccessory: .highlightingBackgroundLabel(title: "Purchase"), onTap: { [weak viewModel] in - Task { await viewModel?.purchaseSubscription() } + Task { await viewModel?.purchaseSubscription(currentProduct: state.purchasedProduct) } } ), SessionCell.Info( @@ -400,7 +725,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold subtitle: """ Request a refund for a Session Pro subscription via the App Store. - Status:\(refundStatus) + Status: \(refundStatus) """, trailingAccessory: .highlightingBackgroundLabel(title: "Request"), isEnabled: (state.purchaseTransaction != nil), @@ -411,250 +736,67 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold ] ) - let features: SectionModel = SectionModel( - model: .features, + let proBackend: SectionModel = SectionModel( + model: .proBackend, elements: [ SessionCell.Info( - id: .proStatus, - title: "Pro Status", + id: .submitPurchaseToProBackend, + title: "Submit Purchase to Pro Backend", subtitle: """ - Mock current user a Session Pro user locally. + Submit a purchase to the Session Pro Backend. + + Status: \(submittedTransactionStatus) """, - trailingAccessory: .dropDown { state.mockCurrentUserSessionPro.title }, - onTap: { [weak viewModel, dependencies = viewModel.dependencies] in - viewModel?.transitionToScreen( - SessionTableViewController( - viewModel: SessionListViewModel( - title: "Session Pro State", - options: SessionProStateMock.allCases, - behaviour: .autoDismiss( - initialSelection: state.mockCurrentUserSessionPro, - onOptionSelected: viewModel?.updateSessionProState - ), - using: dependencies - ) - ) - ) + trailingAccessory: .highlightingBackgroundLabel(title: "Submit"), + isEnabled: ( + state.purchaseTransaction != nil || + state.fakeAppleSubscriptionForDev + ), + onTap: { [weak viewModel] in + Task { await viewModel?.submitTransactionToProBackend() } } ), SessionCell.Info( - id: .loadingState, - title: "Loading State", - trailingAccessory: .dropDown { state.loadingState.title }, - onTap: { [weak viewModel, dependencies = viewModel.dependencies] in - viewModel?.transitionToScreen( - SessionTableViewController( - viewModel: SessionListViewModel( - title: "Session Pro Loading State", - options: SessionProLoadingState.allCases, - behaviour: .autoDismiss( - initialSelection: state.loadingState, - onOptionSelected: { [dependencies] selected in - dependencies.set( - feature: .mockCurrentUserSessionProLoadingState, - to: selected - ) - } - ), - using: dependencies - ) - ) - ) + id: .refreshProState, + title: "Refresh Pro State", + subtitle: """ + Manually trigger a refresh of the users Pro state. + + Status: \(currentProStatus) + """, + trailingAccessory: .highlightingBackgroundLabel(title: "Refresh"), + onTap: { [weak viewModel] in + Task { await viewModel?.refreshProState() } } ), SessionCell.Info( - id: .allUsersSessionPro, - title: "Everyone is a Pro", + id: .resetRevocationListTicket, + title: "Reset Revocation List Ticket", subtitle: """ - Treat all incoming messages as Pro messages. - Treat all contacts, groups as Session Pro. + Reset the revocation list ticket (this will result in the revocation list being refetched from the beginning). + + Current Ticket: \(state.currentRevocationListTicket) """, - trailingAccessory: .toggle( - state.allUsersSessionPro, - oldValue: previousState.allUsersSessionPro - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .allUsersSessionPro, - to: !state.allUsersSessionPro - ) + trailingAccessory: .highlightingBackgroundLabel(title: "Reset"), + onTap: { [weak viewModel] in + Task { await viewModel?.resetProRevocationListTicket() } + } + ), + SessionCell.Info( + id: .removeProFromUserConfig, + title: "Remove Pro From User Config", + subtitle: """ + Remove the cached pro state from the configs (this will mean the local device doesn't know that the user has pro on restart). + """, + trailingAccessory: .highlightingBackgroundLabel(title: "Remove"), + onTap: { [weak viewModel] in + Task { await viewModel?.removeProFromUserConfig() } } ) - ].appending( - contentsOf: !state.allUsersSessionPro ? [] : [ - SessionCell.Info( - id: .messageFeatureProBadge, - title: .init("Message Feature: Pro Badge", font: .subtitle), - trailingAccessory: .toggle( - state.messageFeatureProBadge, - oldValue: previousState.messageFeatureProBadge - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .messageFeatureProBadge, - to: !state.messageFeatureProBadge - ) - } - ), - SessionCell.Info( - id: .messageFeatureLongMessage, - title: .init("Message Feature: Long Message", font: .subtitle), - trailingAccessory: .toggle( - state.messageFeatureLongMessage, - oldValue: previousState.messageFeatureLongMessage - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .messageFeatureLongMessage, - to: !state.messageFeatureLongMessage - ) - } - ), - SessionCell.Info( - id: .messageFeatureAnimatedAvatar, - title: .init("Message Feature: Animated Avatar", font: .subtitle), - trailingAccessory: .toggle( - state.messageFeatureAnimatedAvatar, - oldValue: previousState.messageFeatureAnimatedAvatar - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .messageFeatureAnimatedAvatar, - to: !state.messageFeatureAnimatedAvatar - ) - } - ) - ] - ).appending( - contentsOf: [ - { - switch state.mockCurrentUserSessionPro { - case .none: - SessionCell.Info( - id: .proPlanToRecover, - title: "Pro plan to recover", - subtitle: """ - Mock a pro plan to recover for pro state `None` and `Expired`. - """, - trailingAccessory: .toggle( - state.proPlanToRecover, - oldValue: previousState.proPlanToRecover - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .proPlanToRecover, - to: !state.proPlanToRecover - ) - } - ) - case .expired: - SessionCell.Info( - id: .proPlanExpiredOverThirtyDays, - title: "Expired over 30 days", - subtitle: """ - Mock pro plan expired over 30 days, so the Expired CTA shouldn't show. - """, - trailingAccessory: .toggle( - state.proPlanExpiredOverThirtyDays, - oldValue: previousState.proPlanExpiredOverThirtyDays - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .mockExpiredOverThirtyDays, - to: !state.proPlanExpiredOverThirtyDays - ) - } - ) - case .active, .expiring: - SessionCell.Info( - id: .proPlanExpiry, - title: "Pro plan expiry", - subtitle: """ - Mock current pro plan expiry. - """, - trailingAccessory: .dropDown { state.proPlanExpiry.title }, - onTap: { [weak viewModel, dependencies = viewModel.dependencies] in - viewModel?.transitionToScreen( - SessionTableViewController( - viewModel: SessionListViewModel( - title: "Session Pro Plan Expiry", - options: SessionProStateExpiryMock.allCases, - behaviour: .autoDismiss( - initialSelection: state.proPlanExpiry, - onOptionSelected: viewModel?.updateSessionProExpiry - ), - using: dependencies - ) - ) - ) - } - ) - default: nil - } - }(), - ( - state.mockCurrentUserSessionPro == .none ? nil : - SessionCell.Info( - id: .originatingPlatform, - title: "Originating Platform", - trailingAccessory: .dropDown { state.originatingPlatform.title }, - onTap: { [dependencies = viewModel.dependencies] in - let newValue: ClientPlatform = { - switch state.originatingPlatform { - case .Android: return .iOS - case .iOS: return .Android - } - }() - - dependencies.set( - feature: .proPlanOriginatingPlatform, - to: newValue - ) - dependencies[singleton: .sessionProState].updateOriginatingPlatform(newValue) - } - ) - ), - ( - state.originatingPlatform != .iOS ? nil : - SessionCell.Info( - id: .nonOriginatingAccount, - title: "Non-Originating Apple ID", - trailingAccessory: .toggle( - state.nonOriginatingAccount, - oldValue: previousState.nonOriginatingAccount - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .mockNonOriginatingAccount, - to: !state.nonOriginatingAccount - ) - } - ) - ), - SessionCell.Info( - id: .mockInstalledFromIPA, - title: "Mock installed from IPA", - subtitle: """ - Mock current app is installed from IPA, - which means NO billing access. - """, - trailingAccessory: .toggle( - state.mockInstalledFromIPA, - oldValue: previousState.mockInstalledFromIPA - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .mockInstalledFromIPA, - to: !state.mockInstalledFromIPA - ) - } - ) - ] - ) - .compactMap { $0 } + ] ) - return [general, subscriptions, features] + return [general, features, subscriptions, proBackend] } // MARK: - Functions @@ -662,7 +804,11 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public static func disableDeveloperMode(using dependencies: Dependencies) { let features: [FeatureConfig] = [ .sessionProEnabled, - .allUsersSessionPro + .proBadgeEverywhere, + .fakeAppleSubscriptionForDev, + .forceMessageFeatureProBadge, + .forceMessageFeatureLongMessage, + .forceMessageFeatureAnimatedAvatar, ] features.forEach { feature in @@ -671,75 +817,107 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold dependencies.reset(feature: feature) } - guard dependencies.hasSet(feature: .mockCurrentUserSessionProState) else { return } - dependencies.reset(feature: .mockCurrentUserSessionProState) + if dependencies.hasSet(feature: .mockCurrentUserSessionProBackendStatus) { + dependencies.reset(feature: .mockCurrentUserSessionProBackendStatus) + } + + if dependencies.hasSet(feature: .mockCurrentUserSessionProLoadingState) { + dependencies.reset(feature: .mockCurrentUserSessionProLoadingState) + } } + // MARK: - Internal Functions + private func updateSessionProEnabled(current: Bool) { dependencies.set(feature: .sessionProEnabled, to: !current) - if dependencies.hasSet(feature: .mockCurrentUserSessionProState) { - dependencies.reset(feature: .mockCurrentUserSessionProState) + if dependencies.hasSet(feature: .proBadgeEverywhere) { + dependencies.reset(feature: .proBadgeEverywhere) } - if dependencies.hasSet(feature: .allUsersSessionPro) { - dependencies.reset(feature: .allUsersSessionPro) - } - } - - private func updateSessionProState(to state: SessionProStateMock) { - dependencies.set(feature: .mockCurrentUserSessionProState, to: state) - switch state { - case .none: - dependencies[singleton: .sessionProState].sessionProStateSubject.send(.none) - dependencies[singleton: .sessionProState].shouldAnimateImageSubject.send(false) - case .active: - Task { - await dependencies[singleton: .sessionProState].upgradeToPro( - plan: SessionProPlan(variant: .threeMonths), - originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], - completion: nil - ) - } - case .expiring: - Task { - await dependencies[singleton: .sessionProState].upgradeToPro( - plan: SessionProPlan(variant: .threeMonths), - originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], - completion: nil - ) - await dependencies[singleton: .sessionProState].cancelPro(completion: nil) - } - case .expired: - Task { - await dependencies[singleton: .sessionProState].expirePro(completion: nil) - } - case .refunding: - Task { - await dependencies[singleton: .sessionProState].requestRefund(completion: nil) - } + if dependencies.hasSet(feature: .mockCurrentUserSessionProBackendStatus) { + dependencies.reset(feature: .mockCurrentUserSessionProBackendStatus) } } - private func updateSessionProExpiry(to expiry: SessionProStateExpiryMock) { - dependencies.set(feature: .mockCurrentUserSessionProExpiry, to: expiry) - dependencies[singleton: .sessionProState].updateProExpiry(expiry.durationInSeconds) - } + // MARK: - Pro Requests - private func purchaseSubscription() async { + private func purchaseSubscription(currentProduct: Product?) async { do { - let products: [Product] = try await Product.products(for: ["com.getsession.org.pro_sub"]) + let products: [Product] = try await Product.products(for: [ + "com.getsession.org.pro_sub_1_month", + "com.getsession.org.pro_sub_3_months", + "com.getsession.org.pro_sub_12_months" + ]) - guard let product: Product = products.first else { - Log.error("[DevSettings] Unable to purchase subscription due to error: No products found") - dependencies.notifyAsync( - key: .updateScreen(DeveloperSettingsProViewModel.self), - value: DeveloperSettingsProEvent.purchasedProduct([], nil, "No products found", nil, nil) + await MainActor.run { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Purchase", + body: .radio( + explanation: ThemedAttributedString( + string: "Please select the subscription to purchaase." + ), + warning: nil, + options: products.sorted().map { product in + ConfirmationModal.Info.Body.RadioOptionInfo( + title: "\(product.displayName), price: \(product.displayPrice)", + descriptionText: ThemedAttributedString( + stringWithHTMLTags: product.description, + font: RadioButton.descriptionFont + ), + enabled: true, + selected: currentProduct?.id == product.id + ) + } + ), + confirmTitle: "select".localized(), + cancelStyle: .alert_text, + onConfirm: { [weak self] modal in + let selectedProduct: Product? = { + switch modal.info.body { + case .radio(_, _, let options): + return options + .enumerated() + .first(where: { _, value in value.selected }) + .map { index, _ in + guard index >= 0 && (index - 1) < products.count else { + return nil + } + + return products[index] + } + + default: return nil + } + }() + + if let product: Product = selectedProduct { + Task(priority: .userInitiated) { [weak self] in + await self?.confirmPurchase(products: products, product: product) + } + } + } + ) + ), + transitionType: .present ) - return } - + } + catch { + Log.error("[DevSettings] Unable to purchase subscription due to error: \(error)") + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.purchasedProduct([], nil, "Failed: \(error)", nil, nil) + ) + } + } + + private func confirmPurchase(products: [Product], product: Product) async { + do { let result = try await product.purchase() + switch result { case .success(let verificationResult): let transaction = try verificationResult.payloadValue @@ -808,6 +986,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold do { let result = try await transaction.beginRefundRequest(in: scene) + dependencies.notifyAsync( key: .updateScreen(DeveloperSettingsProViewModel.self), value: DeveloperSettingsProEvent.refundTransaction(result) @@ -817,4 +996,80 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold Log.error("[DevSettings] Unable to request refund: \(error)") } } + + private func submitTransactionToProBackend() async { + do { + let transactionId: String = try await { + guard await internalState.fakeAppleSubscriptionForDev else { + guard let transaction: Transaction = await internalState.purchaseTransaction else { + throw SessionProError.transactionNotFound + } + + return "\(transaction.id)" + } + + let bytes: [UInt8] = try dependencies[singleton: .crypto].tryGenerate(.randomBytes(8)) + return "DEV.\(bytes.toHexString())" + }() + + try await dependencies[singleton: .sessionProManager].addProPayment(transactionId: transactionId) + + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.submittedTransaction("Success", false) + ) + } + catch { + Log.error("[DevSettings] Tranasction submission failed: \(error)") + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.submittedTransaction("Failed: \(error)", true) + ) + } + } + + private func refreshProState() async { + do { + try await dependencies[singleton: .sessionProManager].refreshProState() + let state: SessionPro.State = dependencies[singleton: .sessionProManager].currentUserCurrentProState + + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.currentProStatus("\(state.status)", false) + ) + } + catch { + Log.error("[DevSettings] Refresh pro state failed: \(error)") + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.currentProStatus("Error: \(error)", true) + ) + } + } + + private func resetProRevocationListTicket() async { + do { + try await dependencies[singleton: .storage].writeAsync { db in + db[.proRevocationsTicket] = nil + } + + await dependencies.notify( + key: .proRevocationListUpdated, + value: Array() + ) + } + catch { + Log.error("[DevSettings] Reset pro revocation list failed failed: \(error)") + } + } + + private func removeProFromUserConfig() async { + try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.removeProConfig() + } + } + } + } } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index c89ebb7011..547df36ce4 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -323,6 +323,14 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ) ] ) + + let sessionProStatus: String = (dependencies[feature: .sessionProEnabled] ? "Enabled" : "Disabled") + let mockedProStatus: String = { + switch (dependencies[feature: .sessionProEnabled], dependencies[feature: .mockCurrentUserSessionProBackendStatus]) { + case (true, .simulate(let status)): return "\(status)" + case (false, _), (_, .useActual): return "None" + } + }() let sessionPro: SectionModel = SectionModel( model: .sessionPro, elements: [ @@ -332,7 +340,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, subtitle: """ Configure settings related to Session Pro. - Session Pro: \(dependencies[feature: .sessionProEnabled] ? "Enabled" : "Disabled") + Session Pro: \(sessionProStatus) + Mock Pro Status: \(mockedProStatus) """, trailingAccessory: .icon(.chevronRight), onTap: { [weak self, dependencies] in @@ -1146,7 +1155,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, } /// Start the new network cache and clear out the old one - dependencies.warmCache(cache: .libSessionNetwork) + dependencies.warm(cache: .libSessionNetwork) /// Free the `oldNetworkCache` so it can be destroyed(the 'if' is only there to prevent the "variable never read" warning) if oldNetworkCache != nil { oldNetworkCache = nil } @@ -1276,7 +1285,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, isApproved: true, currentUserSessionId: currentUserSessionId ).upserted(db) - _ = try Profile( + _ = try Profile.with( id: sessionId.hexString, name: String(format: "\(self?.contactPrefix ?? "")%04d", index + 1) ).upserted(db) @@ -2093,9 +2102,79 @@ extension DeveloperSettingsViewModel { transitionType: .present ) } + + static func showModalForMockableState( + title: String, + explanation: String, + feature: FeatureConfig>, + currentValue: MockableFeature, + navigatableStateHolder: NavigatableStateHolder?, + onMockingRemoved: (() -> Void)? = nil, + using dependencies: Dependencies? + ) { + let allCases: [MockableFeature] = MockableFeature.allCases + + navigatableStateHolder?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: title, + body: .radio( + explanation: ThemedAttributedString(string: explanation), + warning: nil, + options: { + return allCases.enumerated().map { index, feature in + ConfirmationModal.Info.Body.RadioOptionInfo( + title: feature.title, + descriptionText: feature.subtitle.map { + ThemedAttributedString( + stringWithHTMLTags: $0, + font: RadioButton.descriptionFont + ) + }, + enabled: true, + selected: currentValue == feature + ) + } + }() + ), + confirmTitle: "select".localized(), + cancelStyle: .alert_text, + onConfirm: { [dependencies] modal in + let selectedValue: MockableFeature? = { + switch modal.info.body { + case .radio(_, _, let options): + return options + .enumerated() + .first(where: { _, value in value.selected }) + .map { index, _ in + guard index >= 0 && index < allCases.count else { + return nil + } + + return allCases[index] + } + + default: return nil + } + }() + + let finalValue: MockableFeature = (selectedValue ?? .useActual) + + switch finalValue { + case .useActual: + dependencies?.reset(feature: feature) + onMockingRemoved?() + + case .simulate: dependencies?.set(feature: feature, to: finalValue) + } + } + ) + ), + transitionType: .present + ) + } } - // MARK: - DocumentPickerResult private class DocumentPickerResult: NSObject, UIDocumentPickerDelegate { @@ -2137,6 +2216,17 @@ internal extension String.StringInterpolation { } } +// MARK: - Format Convenience + +internal extension String.StringInterpolation { + mutating func appendInterpolation(devValue: MockableFeature) { + switch devValue { + case .useActual: appendLiteral("None") + case .simulate(let value): appendLiteral("\(value)") + } + } +} + // MARK: - WarningVersion struct WarningVersion: Listable { @@ -2157,12 +2247,3 @@ extension Network.PushNotification.Service: Listable {} extension Log.Level: @retroactive ContentIdentifiable {} extension Log.Level: @retroactive ContentEquatable {} extension Log.Level: Listable {} -extension SessionProStateMock: @retroactive ContentIdentifiable {} -extension SessionProStateMock: @retroactive ContentEquatable {} -extension SessionProStateMock: Listable {} -extension SessionProLoadingState: @retroactive ContentIdentifiable {} -extension SessionProLoadingState: @retroactive ContentEquatable {} -extension SessionProLoadingState: Listable {} -extension SessionProStateExpiryMock: @retroactive ContentIdentifiable {} -extension SessionProStateExpiryMock: @retroactive ContentEquatable {} -extension SessionProStateExpiryMock: Listable {} diff --git a/Session/Settings/HelpViewModel.swift b/Session/Settings/HelpViewModel.swift index 5eb6f42277..c392e3b944 100644 --- a/Session/Settings/HelpViewModel.swift +++ b/Session/Settings/HelpViewModel.swift @@ -81,9 +81,7 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa pinEdges: [.right] ), onTap: { - guard let url: URL = URL(string: "https://getsession.org/translate") else { - return - } + guard let url: URL = URL(string: Constants.urls.translate) else { return } UIApplication.shared.open(url) } @@ -103,9 +101,7 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa pinEdges: [.right] ), onTap: { - guard let url: URL = URL(string: "https://getsession.org/survey") else { - return - } + guard let url: URL = URL(string: Constants.urls.survey) else { return } UIApplication.shared.open(url) } @@ -125,9 +121,7 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa pinEdges: [.right] ), onTap: { - guard let url: URL = URL(string: "https://getsession.org/faq") else { - return - } + guard let url: URL = URL(string: Constants.urls.faq) else { return } UIApplication.shared.open(url) } @@ -147,9 +141,7 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa pinEdges: [.right] ), onTap: { - guard let url: URL = URL(string: "https://sessionapp.zendesk.com/hc/en-us") else { - return - } + guard let url: URL = URL(string: Constants.urls.support) else { return } UIApplication.shared.open(url) } diff --git a/Session/Settings/NotificationSoundViewModel.swift b/Session/Settings/NotificationSoundViewModel.swift index bc0c8384b6..5e952ebec0 100644 --- a/Session/Settings/NotificationSoundViewModel.swift +++ b/Session/Settings/NotificationSoundViewModel.swift @@ -33,8 +33,9 @@ class NotificationSoundViewModel: SessionTableViewModel, NavigationItemSource, N } deinit { - self.audioPlayer?.stop() - self.audioPlayer = nil + Task { @MainActor [audioPlayer] in + audioPlayer?.stop() + } } // MARK: - Config @@ -83,7 +84,7 @@ class NotificationSoundViewModel: SessionTableViewModel, NavigationItemSource, N lazy var observation: TargetObservation = ObservationBuilderOld .subject(currentSelection) - .map { [weak self] selectedSound in + .map { [weak self, dependencies] selectedSound in return [ SectionModel( model: .content, @@ -110,7 +111,8 @@ class NotificationSoundViewModel: SessionTableViewModel, NavigationItemSource, N DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { self?.audioPlayer = Preferences.Sound.audioPlayer( for: sound, - behavior: .playback + behavior: .playback, + using: dependencies ) self?.audioPlayer?.isLooping = false self?.audioPlayer?.play() diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index 81262bc16b..8190b92a09 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -144,8 +144,8 @@ final class NukeDataModal: Modal { title: "clearDataAll".localized(), body: .attributedText( { - switch dependencies[singleton: .sessionProState].sessionProStateSubject.value { - case .active, .refunding: + switch dependencies[singleton: .sessionProManager].currentUserCurrentProState.status { + case .active: "proClearAllDataNetwork" .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) @@ -169,8 +169,8 @@ final class NukeDataModal: Modal { } private func clearDeviceOnly() { - switch dependencies[singleton: .sessionProState].sessionProStateSubject.value { - case .active, .refunding: + switch dependencies[singleton: .sessionProManager].currentUserCurrentProState.status { + case .active: let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "clearDataAll".localized(), @@ -220,25 +220,22 @@ final class NukeDataModal: Modal { ModalActivityIndicatorViewController .present(fromViewController: presentedViewController, canCancel: false) { [weak self, dependencies] _ in dependencies[singleton: .storage] - .readPublisher { db -> (AuthenticationMethod, [AuthenticationMethod]) in - ( - try Authentication.with( - db, - swarmPublicKey: dependencies[cache: .general].sessionId.hexString, - using: dependencies - ), - try OpenGroup - .filter(OpenGroup.Columns.isActive == true) - .select(.server) - .distinct() - .asRequest(of: String.self) - .fetchSet(db) - .map { try Authentication.with(db, server: $0, using: dependencies) } - ) + .readPublisher { db -> [AuthenticationMethod] in + try OpenGroup + .select(.server) + .distinct() + .asRequest(of: String.self) + .fetchSet(db) + .map { try Authentication.with(db, server: $0, using: dependencies) } } .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .tryFlatMap { (userAuth: AuthenticationMethod, communityAuth: [AuthenticationMethod]) -> AnyPublisher<(AuthenticationMethod, [String]), Error> in - Publishers + .tryFlatMap { communityAuth -> AnyPublisher<(AuthenticationMethod, [String]), Error> in + let userAuth: AuthenticationMethod = try Authentication.with( + swarmPublicKey: dependencies[cache: .general].sessionId.hexString, + using: dependencies + ) + + return Publishers .MergeMany( try communityAuth.compactMap { authMethod in switch authMethod.info { diff --git a/Session/Settings/QRCodeScreen.swift b/Session/Settings/QRCodeScreen.swift index 1a9666bfec..3ea8990d99 100644 --- a/Session/Settings/QRCodeScreen.swift +++ b/Session/Settings/QRCodeScreen.swift @@ -52,13 +52,15 @@ struct QRCodeScreen: View { errorString = "qrNotAccountId".localized() } else { - dependencies[singleton: .app].presentConversationCreatingIfNeeded( - for: hexEncodedPublicKey, - variant: .contact, - action: .compose, - dismissing: self.host.controller, - animated: false - ) + Task.detached(priority: .userInitiated) { + await dependencies[singleton: .app].presentConversationCreatingIfNeeded( + for: hexEncodedPublicKey, + variant: .contact, + action: .compose, + dismissing: self.host.controller, + animated: false + ) + } } } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 28b1aaed8c..a6340be509 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -8,6 +8,7 @@ import GRDB import DifferenceKit import SessionUIKit import SessionMessagingKit +import SessionNetworkingKit import SessionUtilitiesKit import SignalUtilitiesKit @@ -37,7 +38,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl self.dependencies = dependencies self.internalState = State.initialState( userSessionId: dependencies[cache: .general].sessionId, - sessionProPlanState: dependencies[singleton: .sessionProState].sessionProStateSubject.value + proState: dependencies[singleton: .sessionProManager].currentUserCurrentProState ) bindState() @@ -156,7 +157,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl public struct State: ObservableKeyProvider { let userSessionId: SessionId let profile: Profile - let sessionProPlanState: SessionProPlanState + let proState: SessionPro.State let serviceNetwork: ServiceNetwork let forceOffline: Bool let developerModeEnabled: Bool @@ -166,23 +167,30 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl SettingsViewModel.sections(state: self, viewModel: viewModel) } - public var observedKeys: Set { - [ + /// We need `dependencies` to generate the keys in this case so set the variable `observedKeys` to an empty array to + /// suppress the conformance warning + public let observedKeys: Set = [] + public func observedKeys(using dependencies: Dependencies) -> Set { + let sessionProManager: SessionProManagerType = dependencies[singleton: .sessionProManager] + + return [ .profile(userSessionId.hexString), + .currentUserProState(sessionProManager), .feature(.serviceNetwork), .feature(.forceOffline), - .feature(.mockCurrentUserSessionProState), .setting(.developerModeEnabled), .setting(.hideRecoveryPasswordPermanently) - // TODO: [PRO] Need to observe changes to the users pro status ] } - static func initialState(userSessionId: SessionId, sessionProPlanState: SessionProPlanState) -> State { + static func initialState( + userSessionId: SessionId, + proState: SessionPro.State + ) -> State { return State( userSessionId: userSessionId, profile: Profile.defaultFor(userSessionId.hexString), - sessionProPlanState: sessionProPlanState, + proState: proState, serviceNetwork: .mainnet, forceOffline: false, developerModeEnabled: false, @@ -216,7 +224,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) async -> State { /// Store mutable copies of the data to update var profile: Profile = previousState.profile - var sessionProPlanState: SessionProPlanState = previousState.sessionProPlanState + var proState: SessionPro.State = previousState.proState var serviceNetwork: ServiceNetwork = previousState.serviceNetwork var forceOffline: Bool = previousState.forceOffline var developerModeEnabled: Bool = previousState.developerModeEnabled @@ -225,7 +233,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl if isInitialFetch { serviceNetwork = dependencies[feature: .serviceNetwork] forceOffline = dependencies[feature: .forceOffline] - sessionProPlanState = dependencies[singleton: .sessionProState].sessionProStateSubject.value + proState = await dependencies[singleton: .sessionProManager].state.first(defaultValue: .invalid) dependencies.mutate(cache: .libSession) { libSession in profile = libSession.profile @@ -234,6 +242,9 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } + /// Split the events + let changes: EventChangeset = events.split() + /// If the users profile picture doesn't exist on disk then clear out the value (that way if we get events after downloading /// it then then there will be a diff in the `State` and the UI will update if @@ -278,18 +289,17 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl forceOffline = updatedValue } - else if event.key == .feature(.mockCurrentUserSessionProState) { - guard let updatedValue: SessionProStateMock = event.value as? SessionProStateMock else { return } - - sessionProPlanState = dependencies[singleton: .sessionProState].sessionProStateSubject.value - } + } + + if let value = changes.latestGeneric(.currentUserProState, as: SessionPro.State.self) { + proState = value } /// Generate the new state return State( userSessionId: previousState.userSessionId, profile: profile, - sessionProPlanState: sessionProPlanState, + proState: proState, serviceNetwork: serviceNetwork, forceOffline: forceOffline, developerModeEnabled: developerModeEnabled, @@ -329,7 +339,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl label: "Profile picture" ), onTap: { [weak viewModel] in - viewModel?.updateProfilePicture(currentUrl: state.profile.displayPictureUrl) + viewModel?.updateProfilePicture( + currentUrl: state.profile.displayPictureUrl, + proState: state.proState + ) } ), SessionCell.Info( @@ -339,9 +352,9 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl font: .titleLarge, alignment: .center, trailingImage: { - switch state.sessionProPlanState { - case .none: return nil - case .active, .refunding: + switch state.proState.status { + case .neverBeenPro: return nil + case .active: return SessionProBadge.trailingImage( size: .medium, themeBackgroundColor: .primary @@ -434,7 +447,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl let donationAndNetwork: SectionModel // FIXME: [PRO] Should be able to remove this once pro is properly enabled - if viewModel.dependencies[feature: .sessionProEnabled] { + if state.proState.sessionProEnabled { sessionProAndCommunity = SectionModel( model: .sessionProAndCommunity, elements: [ @@ -442,15 +455,17 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl id: .sessionPro, leadingAccessory: .proBadge(size: .small), title: { - switch state.sessionProPlanState { - case .none: + switch state.proState.status { + case .neverBeenPro: return "upgradeSession" .put(key: "app_name", value: Constants.app_name) .localized() - case .active, .refunding: + + case .active: return "sessionProBeta" .put(key: "app_pro", value: Constants.app_pro) .localized() + case .expired: return "proRenewBeta" .put(key: "pro", value: Constants.pro) @@ -812,7 +827,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) } - private func updateProfilePicture(currentUrl: String?) { + private func updateProfilePicture( + currentUrl: String?, + proState: SessionPro.State + ) { let iconName: String = "profile_placeholder" // stringlint:ignore var hasSetNewProfilePicture: Bool = false let currentSource: ImageDataManager.DataSource? = { @@ -838,26 +856,20 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl icon: (currentUrl != nil ? .pencil : .rightPlus), style: .circular, description: { - guard dependencies[feature: .sessionProEnabled] else { return nil } - return dependencies[cache: .libSession].isSessionPro ? - "proAnimatedDisplayPictureModalDescription" - .localized() - .addProBadge( - at: .leading, - font: .systemFont(ofSize: Values.smallFontSize), - textColor: .textSecondary, - proBadgeSize: .small, - using: dependencies - ): - "proAnimatedDisplayPicturesNonProModalDescription" - .localized() - .addProBadge( - at: .trailing, - font: .systemFont(ofSize: Values.smallFontSize), - textColor: .textSecondary, - proBadgeSize: .small, - using: dependencies + switch (proState.sessionProEnabled, proState.status) { + case (false, _): return nil + case (true, .active): + return SessionListScreenContent.TextInfo( + "proAnimatedDisplayPictureModalDescription".localized(), + accessory: .proBadgeLeading(size: .small, themeBackgroundColor: .textSecondary) + ) + + case (true, _): + return SessionListScreenContent.TextInfo( + "proAnimatedDisplayPicturesNonProModalDescription".localized(), + accessory: .proBadgeTrailing(size: .small, themeBackgroundColor: .textSecondary) ) + } }(), accessibility: Accessibility( identifier: "Upload", @@ -866,13 +878,13 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl dataManager: dependencies[singleton: .imageDataManager], onProBageTapped: { [weak self, dependencies] in Task { @MainActor in - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( .animatedProfileImage( - isSessionProActivated: dependencies[cache: .libSession].isSessionPro, - renew: dependencies[singleton: .sessionProState].isSessionProExpired + isSessionProActivated: (proState.status == .active), + renew: (proState.status == .expired) ), onConfirm: { - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded( presenting: { bottomSheet in self?.transitionToScreen(bottomSheet, transitionType: .present) } @@ -923,32 +935,31 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl switch modal.info.body { case .image(.some(let source), _, _, let style, _, _, _, _, _): let isAnimatedImage: Bool = ImageDataManager.isAnimatedImage(source) - guard ( - !isAnimatedImage || - dependencies[cache: .libSession].isSessionPro || - !dependencies[feature: .sessionProEnabled] - ) else { - Task { @MainActor in - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .animatedProfileImage( - isSessionProActivated: dependencies[cache: .libSession].isSessionPro, - renew: dependencies[singleton: .sessionProState].isSessionProExpired - ), - onConfirm: { - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( - presenting: { bottomSheet in - self?.transitionToScreen(bottomSheet, transitionType: .present) - } - ) - }, - presenting: { modal in - self?.transitionToScreen(modal, transitionType: .present) - } - ) - } - return + var didShowCTAModal: Bool = false + + if isAnimatedImage && proState.sessionProEnabled { + didShowCTAModal = dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + .animatedProfileImage( + isSessionProActivated: (proState.status == .active), + renew: (proState.status == .expired) + ), + onConfirm: { + dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded( + presenting: { bottomSheet in + self?.transitionToScreen(bottomSheet, transitionType: .present) + } + ) + }, + presenting: { modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) } + /// If we showed the CTA modal then the user doesn't have Session Pro so can't use the + /// selected image as their display picture + guard !didShowCTAModal else { return } + self?.updateProfile( displayPictureUpdateGenerator: { [weak self] in guard let self = self else { throw AttachmentError.uploadFailed } @@ -1022,13 +1033,12 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl return .currentUserUpdateTo( url: result.downloadUrl, key: result.encryptionKey, - sessionProProof: dependencies.mutate(cache: .libSession) { $0.getCurrentUserProProof() }, - isReupload: false + type: (pendingAttachment.utType.isAnimated ? .animatedImage : .staticImage) ) } @MainActor fileprivate func updateProfile( - displayNameUpdate: Profile.DisplayNameUpdate = .none, + displayNameUpdate: Profile.TargetUserUpdate = .none, displayPictureUpdateGenerator generator: @escaping () async throws -> DisplayPictureManager.Update = { .none }, onComplete: @escaping () -> () ) { @@ -1137,7 +1147,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } private func openTokenUrl() { - guard let url: URL = URL(string: Constants.session_token_url) else { return } + guard let url: URL = URL(string: Constants.urls.token) else { return } let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( diff --git a/Session/Settings/Views/ThemeMessagePreviewView.swift b/Session/Settings/Views/ThemeMessagePreviewView.swift index 5fe8566770..e1dd9e85f9 100644 --- a/Session/Settings/Views/ThemeMessagePreviewView.swift +++ b/Session/Settings/Views/ThemeMessagePreviewView.swift @@ -16,13 +16,14 @@ final class ThemeMessagePreviewView: UIView { let cell: VisibleMessageCell = VisibleMessageCell() cell.update( with: MessageViewModel( + cellType: .textOnlyMessage, + timestampMs: 0, variant: .standardIncoming, body: "appearancePreview2".localized(), quoteViewModel: QuoteViewModel( showYouAsAuthor: true, previewBody: "appearancePreview1".localized() - ), - cellType: .textOnlyMessage + ) ), playbackInfo: nil, showExpandedReactions: false, @@ -40,9 +41,10 @@ final class ThemeMessagePreviewView: UIView { let cell: VisibleMessageCell = VisibleMessageCell() cell.update( with: MessageViewModel( + cellType: .textOnlyMessage, + timestampMs: 0, variant: .standardOutgoing, body: "appearancePreview3".localized(), - cellType: .textOnlyMessage, isLast: false // To hide the status indicator ), playbackInfo: nil, diff --git a/Session/Shared/BaseVC.swift b/Session/Shared/BaseVC.swift index 1e352951b4..f2f672a585 100644 --- a/Session/Shared/BaseVC.swift +++ b/Session/Shared/BaseVC.swift @@ -6,7 +6,7 @@ import Combine import SessionUtilitiesKit public class BaseVC: UIViewController { - private var disposables: Set = Set() + private var proObservationTask: Task? public var onViewWillAppear: ((UIViewController) -> Void)? public var onViewWillDisappear: ((UIViewController) -> Void)? public var onViewDidDisappear: ((UIViewController) -> Void)? @@ -34,6 +34,10 @@ public class BaseVC: UIViewController { return result }() + + deinit { + proObservationTask?.cancel() + } public override func viewDidLoad() { super.viewDidLoad() @@ -84,7 +88,7 @@ public class BaseVC: UIViewController { navigationItem.titleView = container } - internal func setUpNavBarSessionHeading(currentUserSessionProState: SessionProManagerType) { + internal func setUpNavBarSessionHeading(sessionProUIManager: SessionProUIManagerType) { let headingImageView = UIImageView( image: UIImage(named: "SessionHeading")? .withRenderingMode(.alwaysTemplate) @@ -95,34 +99,21 @@ public class BaseVC: UIViewController { headingImageView.set(.height, to: Values.mediumFontSize) let sessionProBadge: SessionProBadge = SessionProBadge(size: .medium) - let isPro: Bool = { - switch currentUserSessionProState.sessionProStateSubject.value { - case .active, .refunding : return true - case .none, .expired: return false - } - }() - sessionProBadge.isHidden = !isPro + sessionProBadge.isHidden = !sessionProUIManager.currentUserIsCurrentlyPro let stackView: UIStackView = UIStackView(arrangedSubviews: [ headingImageView, sessionProBadge ]) stackView.axis = .horizontal stackView.alignment = .center stackView.spacing = 0 - currentUserSessionProState.sessionProStatePublisher - .subscribe(on: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink( - receiveValue: { [weak sessionProBadge] sessionProPlanState in - let isPro: Bool = { - switch sessionProPlanState { - case .active, .refunding : return true - case .none, .expired: return false - } - }() + proObservationTask?.cancel() + proObservationTask = Task.detached(priority: .userInitiated) { [weak sessionProBadge] in + for await isPro in sessionProUIManager.currentUserIsPro { + await MainActor.run { [weak sessionProBadge] in sessionProBadge?.isHidden = !isPro } - ) - .store(in: &disposables) + } + } navigationItem.titleView = stackView } diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index 3065e50df8..bb6ba7e8ca 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -1,15 +1,17 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Lucide import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit import SessionUtilitiesKit public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticCell { - public static let mutePrefix: String = "\u{e067} " // stringlint:ignore public static let unreadCountViewSize: CGFloat = 20 private static let statusIndicatorSize: CGFloat = 14 + private static let displayNameFont: UIFont = .boldSystemFont(ofSize: Values.mediumFontSize) + private static let snippetFont: UIFont = .systemFont(ofSize: Values.smallFontSize) // MARK: - UI @@ -31,7 +33,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC proBadgeSize: .small, withStretchingSpacer: false ) - result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.font = FullConversationCell.displayNameFont result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail result.isProBadgeHidden = true @@ -152,7 +154,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC private lazy var snippetLabel: UILabel = { let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.smallFontSize) + result.font = FullConversationCell.snippetFont result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail @@ -281,13 +283,21 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC // MARK: - Content + public override func prepareForReuse() { + super.prepareForReuse() + + /// Need to reset the fonts as it seems that the `.font` values can end up using a styled font from the attributed text + displayNameLabel.font = FullConversationCell.displayNameFont + snippetLabel.font = FullConversationCell.snippetFont + } + // MARK: --Search Results - public func updateForDefaultContacts(with cellViewModel: SessionThreadViewModel, using dependencies: Dependencies) { + public func updateForDefaultContacts(with cellViewModel: ConversationInfoViewModel, using dependencies: Dependencies) { profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) profilePictureView.update( - publicKey: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant, - displayPictureUrl: cellViewModel.threadDisplayPictureUrl, + publicKey: cellViewModel.id, + threadVariant: cellViewModel.variant, + displayPictureUrl: cellViewModel.displayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies @@ -298,25 +308,22 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC unreadImageView.isHidden = true hasMentionView.isHidden = true timestampLabel.isHidden = true - timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay + timestampLabel.text = cellViewModel.dateForDisplay bottomLabelStackView.isHidden = true - displayNameLabel.themeAttributedText = ThemedAttributedString( - string: cellViewModel.displayName, - attributes: [ .themeForegroundColor: ThemeValue.textPrimary ] - ) - displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) + displayNameLabel.themeAttributedText = cellViewModel.displayName.formatted(baseFont: displayNameLabel.font) + displayNameLabel.isProBadgeHidden = !cellViewModel.shouldShowProBadge } public func updateForMessageSearchResult( - with cellViewModel: SessionThreadViewModel, + with cellViewModel: ConversationInfoViewModel, searchText: String, using dependencies: Dependencies ) { profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) profilePictureView.update( - publicKey: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant, - displayPictureUrl: cellViewModel.threadDisplayPictureUrl, + publicKey: cellViewModel.id, + threadVariant: cellViewModel.variant, + displayPictureUrl: cellViewModel.displayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies @@ -327,45 +334,25 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC unreadImageView.isHidden = true hasMentionView.isHidden = true timestampLabel.isHidden = false - timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay + timestampLabel.text = cellViewModel.dateForDisplay bottomLabelStackView.isHidden = false - displayNameLabel.themeAttributedText = ThemedAttributedString( - string: cellViewModel.displayName, - attributes: [ .themeForegroundColor: ThemeValue.textPrimary ] - ) - displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) - snippetLabel.themeAttributedText = getHighlightedSnippet( - content: Interaction.previewText( - variant: (cellViewModel.interactionVariant ?? .standardIncoming), - body: cellViewModel.interactionBody, - authorDisplayName: cellViewModel.authorName(for: .contact), - attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, - attachmentCount: cellViewModel.interactionAttachmentCount, - isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true), - using: dependencies - ), - authorName: (!(cellViewModel.currentUserSessionIds ?? []).contains(cellViewModel.authorId ?? "") ? - cellViewModel.authorName(for: .contact) : - nil - ), - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - searchText: searchText.lowercased(), - fontSize: Values.smallFontSize, - textColor: .textPrimary, - using: dependencies - ) + displayNameLabel.themeAttributedText = cellViewModel.displayName.formatted(baseFont: displayNameLabel.font) + displayNameLabel.isProBadgeHidden = !cellViewModel.shouldShowProBadge + snippetLabel.themeAttributedText = cellViewModel.messageSnippet? + .formatted(baseFont: snippetLabel.font) + .stylingNotificationPrefixesIfNeeded(fontSize: Values.verySmallFontSize) } public func updateForContactAndGroupSearchResult( - with cellViewModel: SessionThreadViewModel, + with cellViewModel: ConversationInfoViewModel, searchText: String, using dependencies: Dependencies ) { profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) profilePictureView.update( - publicKey: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant, - displayPictureUrl: cellViewModel.threadDisplayPictureUrl, + publicKey: cellViewModel.id, + threadVariant: cellViewModel.variant, + displayPictureUrl: cellViewModel.displayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies @@ -376,40 +363,27 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC unreadImageView.isHidden = true hasMentionView.isHidden = true timestampLabel.isHidden = true - displayNameLabel.themeAttributedText = getHighlightedSnippet( - content: cellViewModel.displayName, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - searchText: searchText.lowercased(), - fontSize: Values.mediumFontSize, - textColor: .textPrimary, - using: dependencies - ) - displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) + displayNameLabel.themeAttributedText = cellViewModel.displayName.formatted(baseFont: displayNameLabel.font) + displayNameLabel.isProBadgeHidden = !cellViewModel.shouldShowProBadge - switch cellViewModel.threadVariant { + switch cellViewModel.variant { case .contact, .community: bottomLabelStackView.isHidden = true case .legacyGroup, .group: - bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty - snippetLabel.themeAttributedText = getHighlightedSnippet( - content: (cellViewModel.threadMemberNames ?? ""), - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - searchText: searchText.lowercased(), - fontSize: Values.smallFontSize, - textColor: .textPrimary, - using: dependencies - ) + bottomLabelStackView.isHidden = cellViewModel.memberNames.isEmpty + snippetLabel.themeAttributedText = cellViewModel.memberNames + .formatted(baseFont: snippetLabel.font) } } // MARK: --Standard - public func update(with cellViewModel: SessionThreadViewModel, using dependencies: Dependencies) { - let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0) + public func update(with cellViewModel: ConversationInfoViewModel, using dependencies: Dependencies) { + let unreadCount: Int = cellViewModel.unreadCount let threadIsUnread: Bool = ( unreadCount > 0 || ( - cellViewModel.threadId != cellViewModel.currentUserSessionId && - cellViewModel.threadWasMarkedUnread == true + cellViewModel.id != cellViewModel.userSessionId.hexString && + cellViewModel.wasMarkedUnread ) ) let themeBackgroundColor: ThemeValue = (threadIsUnread ? @@ -420,7 +394,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC self.selectedBackgroundView?.themeBackgroundColor = .highlighted(themeBackgroundColor) accentLineView.alpha = (unreadCount > 0 ? 1 : 0) - isPinnedIcon.isHidden = (cellViewModel.threadPinnedPriority == 0) + isPinnedIcon.isHidden = (cellViewModel.pinnedPriority <= LibSession.visiblePriority) unreadCountView.isHidden = (unreadCount <= 0) unreadImageView.isHidden = (!unreadCountView.isHidden || !threadIsUnread) unreadCountLabel.text = (unreadCount <= 0 ? @@ -431,33 +405,33 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8) ) hasMentionView.isHidden = !( - ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && ( - cellViewModel.threadVariant == .legacyGroup || - cellViewModel.threadVariant == .group || - cellViewModel.threadVariant == .community + (cellViewModel.unreadMentionCount > 0) && ( + cellViewModel.variant == .legacyGroup || + cellViewModel.variant == .group || + cellViewModel.variant == .community ) ) profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) profilePictureView.update( - publicKey: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant, - displayPictureUrl: cellViewModel.threadDisplayPictureUrl, + publicKey: cellViewModel.id, + threadVariant: cellViewModel.variant, + displayPictureUrl: cellViewModel.displayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies ) - displayNameLabel.text = cellViewModel.displayName - displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) - timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay + displayNameLabel.themeAttributedText = cellViewModel.displayName.formatted(baseFont: displayNameLabel.font) + displayNameLabel.isProBadgeHidden = !cellViewModel.shouldShowProBadge + timestampLabel.text = cellViewModel.dateForDisplay - if cellViewModel.threadContactIsTyping == true { + if cellViewModel.isTyping { snippetLabel.text = "" typingIndicatorView.isHidden = false typingIndicatorView.startAnimation() } else { displayNameLabel.themeTextColor = { - guard cellViewModel.interactionVariant != .infoGroupCurrentUserLeaving else { + guard cellViewModel.lastInteraction?.variant != .infoGroupCurrentUserLeaving else { return .textSecondary } @@ -465,30 +439,22 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC }() typingIndicatorView.isHidden = true typingIndicatorView.stopAnimation() - snippetLabel.themeAttributedText = getSnippet( - cellViewModel: cellViewModel, - textColor: { - switch cellViewModel.interactionVariant { - case .infoGroupCurrentUserLeaving: return .textSecondary - case .infoGroupCurrentUserErrorLeaving: return .danger - default: return .textPrimary - } - }(), - using: dependencies - ) + snippetLabel.themeAttributedText = cellViewModel.messageSnippet? + .formatted(baseFont: snippetLabel.font) + .stylingNotificationPrefixesIfNeeded(fontSize: Values.verySmallFontSize) } - let stateInfo = cellViewModel.interactionState?.statusIconInfo( - variant: (cellViewModel.interactionVariant ?? .standardOutgoing), - hasBeenReadByRecipient: (cellViewModel.interactionHasBeenReadByRecipient ?? false), - hasAttachments: ((cellViewModel.interactionAttachmentCount ?? 0) > 0) + let stateInfo = cellViewModel.lastInteraction?.state.statusIconInfo( + variant: (cellViewModel.lastInteraction?.variant ?? .standardOutgoing), + hasBeenReadByRecipient: (cellViewModel.lastInteraction?.hasBeenReadByRecipient == true), + hasAttachments: (cellViewModel.lastInteraction?.hasAttachments == true) ) statusIndicatorView.image = stateInfo?.image statusIndicatorView.themeTintColor = stateInfo?.themeTintColor statusIndicatorView.isHidden = ( - cellViewModel.interactionVariant != .standardOutgoing && - cellViewModel.interactionState != .localOnly && - cellViewModel.interactionState != .deleted + cellViewModel.lastInteraction?.variant != .standardOutgoing && + cellViewModel.lastInteraction?.state != .localOnly && + cellViewModel.lastInteraction?.state != .deleted ) } @@ -504,19 +470,24 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC // update might get reset (this should be rare and is a relatively minor bug so can be left in) if let isMuted: Bool = isMuted { let attrString: NSAttributedString = (self.snippetLabel.attributedText ?? NSAttributedString()) - let hasMutePrefix: Bool = attrString.string.starts(with: FullConversationCell.mutePrefix) + let hasMutePrefix: Bool = attrString.string.starts(with: NotificationsUI.mutePrefix.rawValue) switch (isMuted, hasMutePrefix) { case (true, false): - self.snippetLabel.attributedText = NSAttributedString( - string: FullConversationCell.mutePrefix, - attributes: [ .font: UIFont(name: "ElegantIcons", size: 10) as Any ] + snippetLabel.themeAttributedText = ThemedAttributedString( + string: NotificationsUI.mutePrefix.rawValue, + attributes: Lucide.attributes(for: .systemFont(ofSize: Values.verySmallFontSize)) ) - .appending(attrString) + .appending(NSAttributedString(string: " ")) + .appending(attrString.adding(attributes: [.font: FullConversationCell.snippetFont])) case (false, true): - self.snippetLabel.attributedText = attrString - .attributedSubstring(from: NSRange(location: FullConversationCell.mutePrefix.count, length: (attrString.length - FullConversationCell.mutePrefix.count))) + /// Need to remove the space as well + let location: Int = (NotificationsUI.mutePrefix.rawValue.count + 1) + snippetLabel.attributedText = attrString + .attributedSubstring( + from: NSRange(location: location, length: (attrString.length - location)) + ) default: break } @@ -538,216 +509,4 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC } } } - - // MARK: - Snippet generation - - private func getSnippet( - cellViewModel: SessionThreadViewModel, - textColor: ThemeValue, - using dependencies: Dependencies - ) -> ThemedAttributedString { - guard cellViewModel.groupIsDestroyed != true else { - return ThemedAttributedString( - string: "groupDeletedMemberDescription" - .put(key: "group_name", value: cellViewModel.displayName) - .localizedDeformatted() - ) - } - guard cellViewModel.wasKickedFromGroup != true else { - return ThemedAttributedString( - string: "groupRemovedYou" - .put(key: "group_name", value: cellViewModel.displayName) - .localizedDeformatted() - ) - } - - // If we don't have an interaction then do nothing - guard cellViewModel.interactionId != nil else { return ThemedAttributedString() } - - let result = ThemedAttributedString() - - if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) { - result.append(ThemedAttributedString( - string: FullConversationCell.mutePrefix, - attributes: [ - .font: UIFont(name: "ElegantIcons", size: 10) as Any, - .themeForegroundColor: textColor - ] - )) - } - else if cellViewModel.threadOnlyNotifyForMentions == true { - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(named: "NotifyMentions.png")? - .withRenderingMode(.alwaysTemplate) - imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) - - let imageString = ThemedAttributedString( - attachment: imageAttachment, - attributes: [.themeForegroundColor: textColor] - ) - result.append(imageString) - result.append(ThemedAttributedString( - string: " ", - attributes: [ - .font: UIFont(name: "ElegantIcons", size: 10) as Any, - .themeForegroundColor: textColor - ] - )) - } - - if - (cellViewModel.threadVariant == .legacyGroup || cellViewModel.threadVariant == .group || cellViewModel.threadVariant == .community) && - (cellViewModel.interactionVariant?.isInfoMessage == false) - { - let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant) - - result.append(ThemedAttributedString( - string: "messageSnippetGroup" - .put(key: "author", value: authorName) - .put(key: "message_snippet", value: "") - .localizedDeformatted(), - attributes: [ .themeForegroundColor: textColor ] - )) - } - - let previewText: String = { - switch cellViewModel.interactionVariant { - case .infoGroupCurrentUserErrorLeaving: - return "groupLeaveErrorFailed" - .put(key: "group_name", value: cellViewModel.displayName) - .localizedDeformatted() - - default: - return Interaction.previewText( - variant: (cellViewModel.interactionVariant ?? .standardIncoming), - body: cellViewModel.interactionBody, - threadContactDisplayName: cellViewModel.threadContactName(), - authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant), - attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, - attachmentCount: cellViewModel.interactionAttachmentCount, - isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true), - using: dependencies - ).localizedDeformatted() - } - }() - - result.append(ThemedAttributedString( - string: MentionUtilities.highlightMentionsNoAttributes( - in: previewText, - threadVariant: cellViewModel.threadVariant, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - using: dependencies - ), - attributes: [ .themeForegroundColor: textColor ] - )) - - return result - } - - private func getHighlightedSnippet( - content: String, - authorName: String? = nil, - currentUserSessionIds: Set, - searchText: String, - fontSize: CGFloat, - textColor: ThemeValue, - using dependencies: Dependencies - ) -> ThemedAttributedString { - guard !content.isEmpty, content != "noteToSelf".localized() else { - if let authorName: String = authorName, !authorName.isEmpty { - return ThemedAttributedString( - string: "messageSnippetGroup" - .put(key: "author", value: authorName) - .put(key: "message_snippet", value: content) - .localized(), - attributes: [ .themeForegroundColor: textColor ] - ) - } - - return ThemedAttributedString( - string: content, - attributes: [ .themeForegroundColor: textColor ] - ) - } - - // Replace mentions in the content - // - // Note: The 'threadVariant' is used for profile context but in the search results - // we don't want to include the truncated id as part of the name so we exclude it - let mentionReplacedContent: String = MentionUtilities.highlightMentionsNoAttributes( - in: content, - threadVariant: .contact, - currentUserSessionIds: currentUserSessionIds, - using: dependencies - ) - let result: ThemedAttributedString = ThemedAttributedString( - string: mentionReplacedContent, - attributes: [ - .themeForegroundColor: ThemeValue.value(textColor, alpha: Values.lowOpacity) - ] - ) - - // Bold each part of the searh term which matched - let normalizedSnippet: String = mentionReplacedContent.lowercased() - var firstMatchRange: Range? - - SessionThreadViewModel.searchTermParts(searchText) - .map { part -> String in - guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } // stringlint:ignore - - return part.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) // stringlint:ignore - } - .forEach { part in - // Highlight all ranges of the text (Note: The search logic only finds results that start - // with the term so we use the regex below to ensure we only highlight those cases) - normalizedSnippet - .ranges( - of: (Dependencies.isRTL ? - "(\(part.lowercased()))(^|[^a-zA-Z0-9])" : // stringlint:ignore - "(^|[^a-zA-Z0-9])(\(part.lowercased()))" // stringlint:ignore - ), - options: [.regularExpression] - ) - .forEach { range in - let targetRange: Range = { - let term: String = String(normalizedSnippet[range]) - - // If the matched term doesn't actually match the "part" value then it means - // we've matched a term after a non-alphanumeric character so need to shift - // the range over by 1 - guard term.starts(with: part.lowercased()) else { - return (normalizedSnippet.index(after: range.lowerBound).. ThemedAttributedString? in - guard !authorName.isEmpty else { return nil } - - let authorPrefix: ThemedAttributedString = ThemedAttributedString( - string: "messageSnippetGroup" - .put(key: "author", value: authorName) - .put(key: "message_snippet", value: "") - .localized(), - attributes: [ .themeForegroundColor: textColor ] - ) - - return authorPrefix.appending(result) - } - .defaulting(to: result) - } } diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 65d8b0e931..850f4c3803 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -480,7 +480,7 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa using: viewModel.dependencies ) - case (let cell as FullConversationCell, let threadInfo as SessionCell.Info): + case (let cell as FullConversationCell, let threadInfo as SessionCell.Info): cell.accessibilityIdentifier = info.accessibility?.identifier cell.isAccessibilityElement = (info.accessibility != nil) cell.update(with: threadInfo.id, using: viewModel.dependencies) diff --git a/Session/Shared/UserListViewModel.swift b/Session/Shared/UserListViewModel.swift index 30c64d5493..4177ef5e09 100644 --- a/Session/Shared/UserListViewModel.swift +++ b/Session/Shared/UserListViewModel.swift @@ -71,7 +71,7 @@ class UserListViewModel: SessionTableVie public indirect enum OnTapAction { case none - case callback((UserListViewModel?, WithProfile) -> Void) + case callback((UserListViewModel?, WithProfile) async -> Void) case radio case conditionalAction(action: (WithProfile) -> OnTapAction) case custom(trailingAccessory: (WithProfile) -> SessionCell.Accessory, onTap: (UserListViewModel?, WithProfile) -> Void) @@ -79,7 +79,7 @@ class UserListViewModel: SessionTableVie public enum OnSubmitAction { case none - case callback((UserListViewModel?, Set>) throws -> Void) + case callback((UserListViewModel?, Set>) async throws -> Void) case publisher((UserListViewModel?, Set>) -> AnyPublisher) var hasAction: Bool { @@ -154,7 +154,9 @@ class UserListViewModel: SessionTableVie title, font: .title, trailingImage: { - guard (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: userInfo.profile) }) else { return nil } + guard userInfo.profile?.proFeatures.contains(.proBadge) == true else { + return nil + } return SessionProBadge.trailingImage( size: .small, @@ -181,7 +183,11 @@ class UserListViewModel: SessionTableVie // Trigger any 'onTap' actions switch finalAction { case .none: return - case .callback(let callback): callback(self, userInfo) + case .callback(let callback): + Task.detached(priority: .userInitiated) { [weak self] in + await callback(self, userInfo) + } + case .custom(_, let callback): callback(self, userInfo) case .radio: break case .conditionalAction(_): return // Shouldn't hit this case @@ -229,25 +235,32 @@ class UserListViewModel: SessionTableVie case .none: return case .callback(let submission): - do { - try submission(self, selectedUsers) - selectedUsersSubject.send([]) - forceRefresh() // Just in case the filter was impacted - } - catch { - transitionToScreen( - ConfirmationModal( - info: ConfirmationModal.Info( - title: "theError".localized(), - body: .text(error.localizedDescription), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text + Task.detached(priority: .userInitiated) { [weak self] in + do { + try await submission(self, selectedUsers) + await MainActor.run { [weak self] in + self?.selectedUsersSubject.send([]) + self?.forceRefresh() // Just in case the filter was impacted + } + } + catch { + await MainActor.run { [weak self] in + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "theError".localized(), + body: .text(error.localizedDescription), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ), + transitionType: .present ) - ), - transitionType: .present - ) + } + } } + case .publisher(let submission): transitionToScreen( ModalActivityIndicatorViewController(canCancel: false) { [weak self, dependencies] modalActivityIndicator in diff --git a/Session/Shared/Views/SessionProBadge+Utilities.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift index 3b1aae707a..4f22d64b64 100644 --- a/Session/Shared/Views/SessionProBadge+Utilities.swift +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -4,21 +4,7 @@ import UIKit import SessionUIKit import SessionUtilitiesKit -public extension SessionProBadge.Size{ - // stringlint:ignore_contents - var cacheKey: String { - switch self { - case .mini: return "SessionProBadge.Mini" - case .small: return "SessionProBadge.Small" - case .medium: return "SessionProBadge.Medium" - case .large: return "SessionProBadge.Large" - } - } -} - public extension SessionProBadge { - fileprivate static let accessibilityLabel: String = Constants.app_pro - static func trailingImage( size: SessionProBadge.Size, themeBackgroundColor: ThemeValue @@ -30,58 +16,3 @@ public extension SessionProBadge { ) } } - -public extension String { - enum SessionProBadgePosition { - case leading, trailing - } - - func addProBadge( - at postion: SessionProBadgePosition, - font: UIFont, - textColor: ThemeValue = .textPrimary, - proBadgeSize: SessionProBadge.Size, - spacing: String = " ", - using dependencies: Dependencies - ) -> ThemedAttributedString { - let base = ThemedAttributedString() - switch postion { - case .leading: - base.append( - ThemedAttributedString( - imageAttachmentGenerator: { - ( - UIView.image( - for: .themedKey(proBadgeSize.cacheKey, themeBackgroundColor: .primary), - generator: { SessionProBadge(size: proBadgeSize) } - ), - SessionProBadge.accessibilityLabel - ) - }, - referenceFont: font - ) - ) - base.append(ThemedAttributedString(string: spacing)) - base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) - case .trailing: - base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) - base.append(ThemedAttributedString(string: spacing)) - base.append( - ThemedAttributedString( - imageAttachmentGenerator: { - ( - UIView.image( - for: .themedKey(proBadgeSize.cacheKey, themeBackgroundColor: .primary), - generator: { SessionProBadge(size: proBadgeSize) } - ), - SessionProBadge.accessibilityLabel - ) - }, - referenceFont: font - ) - ) - } - - return base - } -} diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 28ba6ab691..503fd5a146 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -33,22 +33,15 @@ public final class BackgroundPoller { ( try ClosedGroup .select(.threadId) - .joining( - required: ClosedGroup.members - .filter(GroupMember.Columns.profileId == dependencies[cache: .general].sessionId.hexString) - ) + .filter(ClosedGroup.Columns.shouldPoll) .asRequest(of: String.self) .fetchSet(db), - /// The default room promise creates an OpenGroup with an empty `roomToken` value, we - /// don't want to start a poller for this as the user hasn't actually joined a room - /// - /// We also want to exclude any rooms which have failed to poll too many times in a row from + /// We want to exclude any rooms which have failed to poll too many times in a row from /// the background poll as they are likely to fail again try OpenGroup .select(.server) .filter( - OpenGroup.Columns.roomToken != "" && - OpenGroup.Columns.isActive && + OpenGroup.Columns.shouldPoll == true && OpenGroup.Columns.pollFailureCount < CommunityPoller.maxRoomFailureCountForBackgroundPoll ) .distinct() @@ -57,8 +50,7 @@ public final class BackgroundPoller { try OpenGroup .select(.roomToken) .filter( - OpenGroup.Columns.roomToken != "" && - OpenGroup.Columns.isActive && + OpenGroup.Columns.shouldPoll == true && OpenGroup.Columns.pollFailureCount < CommunityPoller.maxRoomFailureCountForBackgroundPoll ) .distinct() diff --git a/Session/Utilities/DonationsManager.swift b/Session/Utilities/DonationsManager.swift index e9fa74245b..e0a84435bd 100644 --- a/Session/Utilities/DonationsManager.swift +++ b/Session/Utilities/DonationsManager.swift @@ -107,7 +107,7 @@ public class DonationsManager { } @MainActor public func openDonationsUrlModal(superPresenter: UIViewController? = nil) -> ConfirmationModal? { - guard let url: URL = URL(string: Constants.session_donations_url) else { return nil } + guard let url: URL = URL(string: Constants.urls.donationsApp) else { return nil } return ConfirmationModal( info: ConfirmationModal.Info( diff --git a/Session/Utilities/MentionUtilities+DisplayName.swift b/Session/Utilities/MentionUtilities+DisplayName.swift deleted file mode 100644 index e818ecac4e..0000000000 --- a/Session/Utilities/MentionUtilities+DisplayName.swift +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import GRDB -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit - -public extension MentionUtilities { - static func highlightMentionsNoAttributes( - in string: String, - threadVariant: SessionThread.Variant, - currentUserSessionIds: Set, - using dependencies: Dependencies - ) -> String { - return MentionUtilities.highlightMentionsNoAttributes( - in: string, - currentUserSessionIds: currentUserSessionIds, - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: threadVariant, - using: dependencies - ) - ) - } - - static func highlightMentions( - in string: String, - threadVariant: SessionThread.Variant, - currentUserSessionIds: Set, - location: MentionLocation, - textColor: ThemeValue, - attributes: [NSAttributedString.Key: Any], - using dependencies: Dependencies - ) -> ThemedAttributedString { - return MentionUtilities.highlightMentions( - in: string, - currentUserSessionIds: currentUserSessionIds, - location: location, - textColor: textColor, - attributes: attributes, - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: threadVariant, - using: dependencies - ) - ) - } -} diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index ca130970e6..27969be95e 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -110,7 +110,7 @@ enum MockDataGenerator { currentUserSessionId: userSessionId ) .upserted(db) - try Profile( + try Profile.with( id: randomSessionId, name: (0.. 0 + threadInfo.wasMarkedUnread || + threadInfo.unreadCount > 0 ) return UIContextualAction( @@ -102,18 +102,20 @@ public extension UIContextualAction { tableView: tableView ) { _, _, completionHandler in // Delay the change to give the cell "unswipe" animation some time to complete - DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { + Task.detached(priority: .userInitiated) { + try await Task.sleep(for: unswipeAnimationDelay) switch isUnread { - case true: threadViewModel.markAsRead( + case true: try? await threadInfo.markAsRead( target: .threadAndInteractions( - interactionsBeforeInclusive: threadViewModel.interactionId + interactionsBeforeInclusive: threadInfo.lastInteraction?.id ), using: dependencies ) - case false: threadViewModel.markAsUnread(using: dependencies) + case false: try? await threadInfo.markAsUnread(using: dependencies) } } + completionHandler(true) } @@ -143,8 +145,8 @@ public extension UIContextualAction { try SessionThread.deleteOrLeave( db, type: .deleteContactConversationAndMarkHidden, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, using: dependencies ) } @@ -172,48 +174,48 @@ public extension UIContextualAction { indexPath: indexPath, tableView: tableView ) { _, _, completionHandler in - switch threadViewModel.threadId { - case SessionThreadViewModel.messageRequestsSectionId: - dependencies.setAsync(.hasHiddenMessageRequests, true) - completionHandler(true) - - default: - let confirmationModal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "noteToSelfHide".localized(), - body: .attributedText( - "hideNoteToSelfDescription" - .localizedFormatted(baseFont: ConfirmationModal.explanationFont) - ), - confirmTitle: "hide".localized(), - confirmStyle: .danger, - cancelStyle: .alert_text, - dismissOnConfirm: true, - onConfirm: { _ in - dependencies[singleton: .storage].writeAsync { db in - try SessionThread.deleteOrLeave( - db, - type: .hideContactConversation, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - using: dependencies - ) - } - - completionHandler(true) - }, - afterClosed: { completionHandler(false) } - ) - ) - - viewController?.present(confirmationModal, animated: true, completion: nil) + guard !threadInfo.isMessageRequestsSection else { + dependencies.setAsync(.hasHiddenMessageRequests, true) + completionHandler(true) + return } + + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "noteToSelfHide".localized(), + body: .attributedText( + "hideNoteToSelfDescription" + .localizedFormatted(baseFont: ConfirmationModal.explanationFont) + ), + confirmTitle: "hide".localized(), + confirmStyle: .danger, + cancelStyle: .alert_text, + dismissOnConfirm: true, + onConfirm: { _ in + dependencies[singleton: .storage].writeAsync { db in + try SessionThread.deleteOrLeave( + db, + type: .hideContactConversation, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, + using: dependencies + ) + } + + completionHandler(true) + }, + afterClosed: { completionHandler(false) } + ) + ) + + viewController?.present(confirmationModal, animated: true, completion: nil) } // MARK: -- pin case .pin: - let isCurrentlyPinned: Bool = (threadViewModel.threadPinnedPriority > 0) + let isCurrentlyPinned: Bool = (threadInfo.pinnedPriority > 0) + return UIContextualAction( title: (isCurrentlyPinned ? "pinUnpin".localized() : "pin".localized()), icon: (isCurrentlyPinned ? UIImage(systemName: "pin.slash") : UIImage(systemName: "pin")), @@ -230,21 +232,23 @@ public extension UIContextualAction { if dependencies[feature: .sessionProEnabled], !isCurrentlyPinned, - !dependencies[cache: .libSession].isSessionPro, + !dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro, let pinnedConversationsNumber: Int = dependencies[singleton: .storage].read({ db in try SessionThread .filter(SessionThread.Columns.pinnedPriority > 0) .fetchCount(db) }), - pinnedConversationsNumber >= LibSession.PinnedConversationLimit + pinnedConversationsNumber >= SessionPro.PinnedConversationLimit { - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( .morePinnedConvos( - isGrandfathered: (pinnedConversationsNumber > LibSession.PinnedConversationLimit), - renew: dependencies[singleton: .sessionProState].isSessionProExpired + isGrandfathered: (pinnedConversationsNumber >= SessionPro.PinnedConversationLimit), + renew: (dependencies[singleton: .sessionProManager] + .currentUserCurrentProState + .status == .expired) ), onConfirm: { [dependencies] in - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded( afterClosed: nil, presenting: { bottomSheet in viewController?.present(bottomSheet, animated: true) @@ -266,11 +270,16 @@ public extension UIContextualAction { // Delay the change to give the cell "unswipe" animation some time to complete DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { dependencies[singleton: .storage].writeAsync { db in - try SessionThread.updateVisibility( + try SessionThread.update( db, - threadId: threadViewModel.threadId, - isVisible: true, - customPriority: (isCurrentlyPinned ? LibSession.visiblePriority : 1), + id: threadInfo.id, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true), + pinnedPriority: .setTo(isCurrentlyPinned ? + LibSession.visiblePriority : + 1 + ) + ), using: dependencies ) } @@ -281,18 +290,18 @@ public extension UIContextualAction { case .mute: return UIContextualAction( - title: (threadViewModel.threadMutedUntilTimestamp == nil ? + title: (threadInfo.mutedUntilTimestamp == nil ? "notificationsMute".localized() : "notificationsMuteUnmute".localized() ), - icon: (threadViewModel.threadMutedUntilTimestamp == nil ? + icon: (threadInfo.mutedUntilTimestamp == nil ? UIImage(systemName: "speaker.slash") : UIImage(systemName: "speaker") ), themeTintColor: .white, themeBackgroundColor: themeBackgroundColor, accessibility: Accessibility( - identifier: (threadViewModel.threadMutedUntilTimestamp == nil ? "Mute button" : "Unmute button") + identifier: (threadInfo.mutedUntilTimestamp == nil ? "Mute button" : "Unmute button") ), side: side, actionIndex: targetIndex, @@ -301,7 +310,7 @@ public extension UIContextualAction { ) { _, _, completionHandler in (tableView.cellForRow(at: indexPath) as? SwipeActionOptimisticCell)? .optimisticUpdate( - isMuted: !(threadViewModel.threadMutedUntilTimestamp != nil) + isMuted: !(threadInfo.mutedUntilTimestamp != nil) ) completionHandler(true) @@ -309,7 +318,7 @@ public extension UIContextualAction { DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { dependencies[singleton: .storage].writeAsync { db in let currentValue: TimeInterval? = try SessionThread - .filter(id: threadViewModel.threadId) + .filter(id: threadInfo.id) .select(.mutedUntilTimestamp) .asRequest(of: TimeInterval.self) .fetchOne(db) @@ -319,7 +328,7 @@ public extension UIContextualAction { ) try SessionThread - .filter(id: threadViewModel.threadId) + .filter(id: threadInfo.id) .updateAll( db, SessionThread.Columns.mutedUntilTimestamp.set(to: newValue) @@ -327,7 +336,8 @@ public extension UIContextualAction { if currentValue != newValue { db.addConversationEvent( - id: threadViewModel.threadId, + id: threadInfo.id, + variant: threadInfo.variant, type: .updated(.mutedUntilTimestamp(newValue)) ) } @@ -342,16 +352,16 @@ public extension UIContextualAction { guard let profileInfo: (id: String, profile: Profile?) = dependencies[singleton: .storage] .read({ db in - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .contact: return ( - threadViewModel.threadId, - try Profile.fetchOne(db, id: threadViewModel.threadId) + threadInfo.id, + try Profile.fetchOne(db, id: threadInfo.id) ) case .group: let firstAdmin: GroupMember? = try GroupMember - .filter(GroupMember.Columns.groupId == threadViewModel.threadId) + .filter(GroupMember.Columns.groupId == threadInfo.id) .filter(GroupMember.Columns.role == GroupMember.Role.admin) .fetchOne(db) @@ -369,7 +379,7 @@ public extension UIContextualAction { else { return nil } return UIContextualAction( - title: (threadViewModel.threadIsBlocked == true ? + title: (threadInfo.isBlocked ? "blockUnblock".localized() : "block".localized() ), @@ -382,10 +392,10 @@ public extension UIContextualAction { indexPath: indexPath, tableView: tableView ) { [weak viewController] _, _, completionHandler in - let threadIsBlocked: Bool = (threadViewModel.threadIsBlocked == true) + let threadIsBlocked: Bool = threadInfo.isBlocked let threadIsContactMessageRequest: Bool = ( - threadViewModel.threadVariant == .contact && - threadViewModel.threadIsMessageRequest == true + threadInfo.variant == .contact && + threadInfo.isMessageRequest ) let contactChanges: [ConfigColumnAssignment] = [ Contact.Columns.isBlocked.set(to: !threadIsBlocked), @@ -400,17 +410,15 @@ public extension UIContextualAction { [.isApproved(false), .didApproveMe(true)] ) let nameToUse: String = { - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .group: return Profile.displayName( - for: .contact, id: profileInfo.id, name: profileInfo.profile?.name, - nickname: profileInfo.profile?.nickname, - suppressId: false + nickname: profileInfo.profile?.nickname ) - default: return threadViewModel.displayName + default: return threadInfo.displayName.deformatted() } }() @@ -447,17 +455,17 @@ public extension UIContextualAction { dependencies[singleton: .storage] .writePublisher { db in // Create the contact if it doesn't exist - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .contact: try Contact .fetchOrCreate( db, - id: threadViewModel.threadId, + id: threadInfo.id, using: dependencies ) .upsert(db) try Contact - .filter(id: threadViewModel.threadId) + .filter(id: threadInfo.id) .updateAllAndConfig( db, contactChanges, @@ -465,7 +473,7 @@ public extension UIContextualAction { ) contactChangeEvents.forEach { change in db.addContactEvent( - id: threadViewModel.threadId, + id: threadInfo.id, change: change ) } @@ -496,12 +504,12 @@ public extension UIContextualAction { } // Blocked message requests should be deleted - if threadViewModel.threadIsMessageRequest == true { + if threadInfo.isMessageRequest { try SessionThread.deleteOrLeave( db, type: .deleteContactConversationAndMarkHidden, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, using: dependencies ) } @@ -532,7 +540,7 @@ public extension UIContextualAction { tableView: tableView ) { [weak viewController] _, _, completionHandler in let confirmationModalTitle: String = { - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .legacyGroup, .group: return "groupLeave".localized() @@ -541,20 +549,22 @@ public extension UIContextualAction { }() let confirmationModalExplanation: ThemedAttributedString = { - switch (threadViewModel.threadVariant, threadViewModel.currentUserIsClosedGroupAdmin) { - case (.group, true): + let groupName: String = threadInfo.displayName.deformatted() + + switch (threadInfo.variant, threadInfo.groupInfo?.currentUserRole) { + case (.group, .admin): return "groupLeaveDescriptionAdmin" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: groupName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) - case (.legacyGroup, true): + case (.legacyGroup, .admin): return "groupLeaveDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: groupName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) default: return "groupLeaveDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: groupName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) } }() @@ -569,7 +579,7 @@ public extension UIContextualAction { dismissOnConfirm: true, onConfirm: { _ in let deletionType: SessionThread.DeletionType = { - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .legacyGroup, .group: return .leaveGroupAsync default: return .deleteCommunityAndContent } @@ -580,22 +590,24 @@ public extension UIContextualAction { try SessionThread.deleteOrLeave( db, type: deletionType, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, using: dependencies ) } catch { DispatchQueue.main.async { let toastBody: String = { - switch threadViewModel.threadVariant { + let deformattedDisplayName: String = threadInfo.displayName.deformatted() + + switch threadInfo.variant { case .legacyGroup, .group: return "groupLeaveErrorFailed" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: deformattedDisplayName) .localized() default: return "communityLeaveError" - .put(key: "community_name", value: threadViewModel.displayName) + .put(key: "community_name", value: deformattedDisplayName) .localized() } }() @@ -630,17 +642,17 @@ public extension UIContextualAction { indexPath: indexPath, tableView: tableView ) { [weak viewController] _, _, completionHandler in - let isMessageRequest: Bool = (threadViewModel.threadIsMessageRequest == true) + let isMessageRequest: Bool = threadInfo.isMessageRequest let groupDestroyedOrKicked: Bool = { - guard threadViewModel.threadVariant == .group else { return false } + guard threadInfo.variant == .group else { return false } return ( - threadViewModel.wasKickedFromGroup == true || - threadViewModel.groupIsDestroyed == true + threadInfo.groupInfo?.wasKicked == true || + threadInfo.groupInfo?.isDestroyed == true ) }() let confirmationModalTitle: String = { - switch (threadViewModel.threadVariant, isMessageRequest) { + switch (threadInfo.variant, isMessageRequest) { case (_, true): return "delete".localized() case (.contact, _): return "conversationsDelete".localized() @@ -653,31 +665,28 @@ public extension UIContextualAction { }() let confirmationModalExplanation: ThemedAttributedString = { guard !isMessageRequest else { - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .group: return ThemedAttributedString(string: "groupInviteDelete".localized()) default: return ThemedAttributedString(string: "messageRequestsContactDelete".localized()) } } - let threadInfo: (SessionThread.Variant, Bool) = ( - threadViewModel.threadVariant, - threadViewModel.currentUserIsClosedGroupAdmin == true - ) + let deformattedDisplayName: String = threadInfo.displayName.deformatted() - switch threadInfo { + switch (threadInfo.variant, threadInfo.groupInfo?.currentUserRole) { case (.contact, _): return "deleteConversationDescription" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: deformattedDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) - case (.group, true): + case (.group, .admin): return "groupDeleteDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: deformattedDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) default: return "groupDeleteDescriptionMember" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: deformattedDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) } }() @@ -692,7 +701,7 @@ public extension UIContextualAction { dismissOnConfirm: true, onConfirm: { _ in let deletionType: SessionThread.DeletionType = { - switch (threadViewModel.threadVariant, isMessageRequest, groupDestroyedOrKicked) { + switch (threadInfo.variant, isMessageRequest, groupDestroyedOrKicked) { case (.community, _, _): return .deleteCommunityAndContent case (.group, true, _), (.group, _, true), (.legacyGroup, _, _): return .deleteGroupAndContent @@ -710,8 +719,8 @@ public extension UIContextualAction { try SessionThread.deleteOrLeave( db, type: deletionType, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, using: dependencies ) } @@ -745,7 +754,7 @@ public extension UIContextualAction { title: "contactDelete".localized(), body: .attributedText( "contactDeleteDescription" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadInfo.displayName.deformatted()) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ), confirmTitle: "delete".localized(), @@ -757,8 +766,8 @@ public extension UIContextualAction { try SessionThread.deleteOrLeave( db, type: .deleteContactConversationAndContact, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, using: dependencies ) } diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 4e71f0f955..fa9ff36804 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -49,7 +49,9 @@ public enum SNMessagingKit { _043_RenameAttachments.self, _044_AddProMessageFlag.self, _045_LastProfileUpdateTimestamp.self, - _046_RemoveQuoteUnusedColumnsAndForeignKeys.self + _046_RemoveQuoteUnusedColumnsAndForeignKeys.self, + _047_DropUnneededColumnsAndTables.self, + _048_SessionProChanges.self ] public static func configure(using dependencies: Dependencies) { diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index ff4bb93852..4aaf6693f5 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -2,8 +2,281 @@ import Foundation import SessionUtil +import SessionNetworkingKit import SessionUtilitiesKit +// MARK: - Messages + +public extension Crypto.Generator { + static func encodedMessage( + plaintext: I, + proMessageFeatures: SessionPro.MessageFeatures, + proProfileFeatures: SessionPro.ProfileFeatures, + destination: Message.Destination, + sentTimestampMs: UInt64 + ) throws -> Crypto.Generator where R.Element == UInt8 { + return Crypto.Generator( + id: "encodedMessage", + args: [] + ) { dependencies in + let cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey + let cRotatingProSecretKey: [UInt8]? = { + /// If the message doens't contain any pro features then we shouldn't include a pro signature + guard proMessageFeatures != .none || proProfileFeatures != .none else { return nil } + + return dependencies[singleton: .sessionProManager] + .currentUserCurrentRotatingKeyPair? + .secretKey + }() + + guard !cEd25519SecretKey.isEmpty else { throw CryptoError.missingUserSecretKey } + + let cPlaintext: [UInt8] = Array(plaintext) + var error: [CChar] = [CChar](repeating: 0, count: 256) + var result: session_protocol_encoded_for_destination + + switch destination { + case .contact(let pubkey): + var cPubkey: bytes33 = bytes33() + cPubkey.set(\.data, to: Data(hex: pubkey)) + result = session_protocol_encode_for_1o1( + cPlaintext, + cPlaintext.count, + cEd25519SecretKey, + cEd25519SecretKey.count, + sentTimestampMs, + &cPubkey, + cRotatingProSecretKey, + (cRotatingProSecretKey?.count ?? 0), + &error, + error.count + ) + + case .syncMessage: + var cPubkey: bytes33 = bytes33() + cPubkey.set(\.data, to: Data(hex: dependencies[cache: .general].sessionId.hexString)) + result = session_protocol_encode_for_1o1( + cPlaintext, + cPlaintext.count, + cEd25519SecretKey, + cEd25519SecretKey.count, + sentTimestampMs, + &cPubkey, + cRotatingProSecretKey, + (cRotatingProSecretKey?.count ?? 0), + &error, + error.count + ) + + case .group(let pubkey): + let currentGroupEncPrivateKey: [UInt8] = try dependencies.mutate(cache: .libSession) { cache in + try cache.latestGroupKey(groupSessionId: SessionId(.group, hex: pubkey)) + } + + var cPubkey: bytes33 = bytes33() + var cCurrentGroupEncPrivateKey: bytes32 = bytes32() + cPubkey.set(\.data, to: Data(hex: pubkey)) + cCurrentGroupEncPrivateKey.set(\.data, to: currentGroupEncPrivateKey) + result = session_protocol_encode_for_group( + cPlaintext, + cPlaintext.count, + cEd25519SecretKey, + cEd25519SecretKey.count, + sentTimestampMs, + &cPubkey, + &cCurrentGroupEncPrivateKey, + cRotatingProSecretKey, + (cRotatingProSecretKey?.count ?? 0), + &error, + error.count + ) + + case .community: + result = session_protocol_encode_for_community( + cPlaintext, + cPlaintext.count, + cRotatingProSecretKey, + (cRotatingProSecretKey?.count ?? 0), + &error, + error.count + ) + + case .communityInbox(_, let serverPubkey, let recipientPubkey): + var cServerPubkey: bytes32 = bytes32() + var cRecipientPubkey: bytes33 = bytes33() + cServerPubkey.set(\.data, to: Data(hex: serverPubkey)) + cRecipientPubkey.set(\.data, to: Data(hex: recipientPubkey)) + result = session_protocol_encode_for_community_inbox( + cPlaintext, + cPlaintext.count, + cEd25519SecretKey, + cEd25519SecretKey.count, + sentTimestampMs, + &cRecipientPubkey, + &cServerPubkey, + cRotatingProSecretKey, + (cRotatingProSecretKey?.count ?? 0), + &error, + error.count + ) + } + defer { session_protocol_encode_for_destination_free(&result) } + + guard result.success else { + Log.error(.messageSender, "Failed to encode due to error: \(String(cString: error))") + throw MessageError.encodingFailed + } + + return R(UnsafeBufferPointer(start: result.ciphertext.data, count: result.ciphertext.size)) + } + } + + static func decodedMessage( + encodedMessage: I, + origin: Message.Origin + ) throws -> Crypto.Generator { + return Crypto.Generator( + id: "decodedMessage", + args: [] + ) { dependencies in + let cEncodedMessage: [UInt8] = Array(encodedMessage) + let cBackendPubkey: [UInt8] = Array(Data(hex: Network.SessionPro.serverEdPublicKey)) + var error: [CChar] = [CChar](repeating: 0, count: 256) + + switch origin { + case .community(_, let sender, let posted, _, _, _, _): + /// **Note:** This will generate an error in the debug console because we are slowly migrating the structure of + /// Community protobuf content, first we try to decode as an envelope (which logs this error when it's the legacy + /// structure) then we try to decode as the legacy structure (which succeeds) + let sentTimestampMs: UInt64 = UInt64(floor(posted * 1000)) + var cResult: session_protocol_decoded_community_message = session_protocol_decode_for_community( + cEncodedMessage, + cEncodedMessage.count, + sentTimestampMs, + (cBackendPubkey.isEmpty ? nil : cBackendPubkey), + cBackendPubkey.count, + &error, + error.count + ) + defer { session_protocol_decode_for_community_free(&cResult) } + + guard cResult.success else { + Log.error(.messageSender, "Failed to decode community message due to error: \(String(cString: error))") + throw MessageError.decodingFailed + } + + return try DecodedMessage(decodedValue: cResult, sender: sender, posted: posted) + + case .communityInbox(let posted, _, let serverPublicKey, let senderId, let recipientId): + // FIXME: Fold into `session_protocol_decode_envelope` once support is added + let (plaintextWithPadding, sender): (Data, String) = try dependencies[singleton: .crypto].tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: encodedMessage, + senderId: senderId, + recipientId: recipientId, + serverPublicKey: serverPublicKey + ) + ) + let cPlaintext: [UInt8] = Array(plaintextWithPadding.removePadding()) + + /// **Note:** This will generate an error in the debug console because we are slowly migrating the structure of + /// Community protobuf content, first we try to decode as an envelope (which logs this error when it's the legacy + /// structure) then we try to decode as the legacy structure (which succeeds) + let sentTimestampMs: UInt64 = UInt64(floor(posted * 1000)) + var cResult: session_protocol_decoded_community_message = session_protocol_decode_for_community( + cPlaintext, + cPlaintext.count, + sentTimestampMs, + (cBackendPubkey.isEmpty ? nil : cBackendPubkey), + cBackendPubkey.count, + &error, + error.count + ) + defer { session_protocol_decode_for_community_free(&cResult) } + + guard cResult.success else { + Log.error(.messageSender, "Failed to decode community message due to error: \(String(cString: error))") + throw MessageError.decodingFailed + } + + return try DecodedMessage(decodedValue: cResult, sender: sender, posted: posted) + + case .swarm(let publicKey, let namespace, _, _, _): + /// Function to provide pointers to the keys based on the namespace the message was received from + func withKeys( + for namespace: Network.SnodeAPI.Namespace, + publicKey: String, + using dependencies: Dependencies, + _ closure: (span_u8, UnsafePointer?, Int) throws -> R + ) throws -> R { + let privateKeys: [[UInt8]] + let sessionId: SessionId = try SessionId(from: publicKey) + + switch namespace { + case .default: + let ed25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey + + guard !ed25519SecretKey.isEmpty else { throw CryptoError.missingUserSecretKey } + + privateKeys = [ed25519SecretKey] + + case .groupMessages: + guard sessionId.prefix == .group else { + throw MessageError.requiresGroupId(publicKey) + } + + privateKeys = try dependencies.mutate(cache: .libSession) { cache in + try cache.allActiveGroupKeys(groupSessionId: sessionId) + } + + default: + throw MessageError.invalidMessage("Tried to decode a message from an incorrect namespace: \(namespace)") + } + + /// Exclude the prefix when providing the publicKey + return try sessionId.publicKey.withUnsafeSpan { cPublicKey in + return try privateKeys.withUnsafeSpanOfSpans { cPrivateKeys, cPrivateKeysLen in + try closure(cPublicKey, cPrivateKeys, cPrivateKeysLen) + } + } + } + + return try withKeys(for: namespace, publicKey: publicKey, using: dependencies) { cPublicKey, cPrivateKeys, cPrivateKeysLen in + let cEncodedMessage: [UInt8] = Array(encodedMessage) + var cKeys: session_protocol_decode_envelope_keys = session_protocol_decode_envelope_keys() + cKeys.set(\.decrypt_keys, to: cPrivateKeys) + cKeys.set(\.decrypt_keys_len, to: cPrivateKeysLen) + + /// If it's a group message then we need to set the group pubkey + if namespace == .groupMessages { + cKeys.set(\.group_ed25519_pubkey, to: cPublicKey) + } + + var cResult: session_protocol_decoded_envelope = session_protocol_decode_envelope( + &cKeys, + cEncodedMessage, + cEncodedMessage.count, + (cBackendPubkey.isEmpty ? nil : cBackendPubkey), + cBackendPubkey.count, + &error, + error.count + ) + defer { session_protocol_decode_envelope_free(&cResult) } + + guard cResult.success else { + Log.error(.messageReceiver, "Failed to decode message due to error: \(String(cString: error))") + throw MessageError.decodingFailed + } + + return DecodedMessage(decodedValue: cResult) + } + } + } + } +} + +// MARK: - Groups + public extension Crypto.Generator { static func tokenSubaccount( config: LibSession.Config?, @@ -84,7 +357,7 @@ public extension Crypto.Generator { &subaccount, &subaccountSig, &signature - ) else { throw MessageSenderError.signingFailed } + ) else { throw CryptoError.signatureGenerationFailed } return Authentication.Signature.subaccount( subaccount: subaccount, @@ -93,88 +366,6 @@ public extension Crypto.Generator { ) } } - - static func ciphertextForGroupMessage( - groupSessionId: SessionId, - message: [UInt8] - ) -> Crypto.Generator { - return Crypto.Generator( - id: "ciphertextForGroupMessage", - args: [groupSessionId, message] - ) { dependencies in - return try dependencies.mutate(cache: .libSession) { cache in - guard let config: LibSession.Config = cache.config(for: .groupKeys, sessionId: groupSessionId) else { - throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) - } - guard case .groupKeys(let conf, _, _) = config else { - throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) - } - - var maybeCiphertext: UnsafeMutablePointer? = nil - var ciphertextLen: Int = 0 - groups_keys_encrypt_message( - conf, - message, - message.count, - &maybeCiphertext, - &ciphertextLen - ) - - guard - ciphertextLen > 0, - let ciphertext: Data = maybeCiphertext - .map({ Data(bytes: $0, count: ciphertextLen) }) - else { throw MessageSenderError.encryptionFailed } - - return ciphertext - } ?? { throw MessageSenderError.encryptionFailed }() - } - } - - static func plaintextForGroupMessage( - groupSessionId: SessionId, - ciphertext: [UInt8] - ) throws -> Crypto.Generator<(plaintext: Data, sender: String)> { - return Crypto.Generator( - id: "plaintextForGroupMessage", - args: [groupSessionId, ciphertext] - ) { dependencies in - return try dependencies.mutate(cache: .libSession) { cache in - guard let config: LibSession.Config = cache.config(for: .groupKeys, sessionId: groupSessionId) else { - throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) - } - guard case .groupKeys(let conf, _, _) = config else { - throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) - } - - var cSessionId: [CChar] = [CChar](repeating: 0, count: 67) - var maybePlaintext: UnsafeMutablePointer? = nil - var plaintextLen: Int = 0 - let didDecrypt: Bool = groups_keys_decrypt_message( - conf, - ciphertext, - ciphertext.count, - &cSessionId, - &maybePlaintext, - &plaintextLen - ) - - // If we got a reported failure then just stop here - guard didDecrypt else { throw MessageReceiverError.decryptionFailed } - - // We need to manually free 'maybePlaintext' upon a successful decryption - defer { free(UnsafeMutableRawPointer(mutating: maybePlaintext)) } - - guard - plaintextLen > 0, - let plaintext: Data = maybePlaintext - .map({ Data(bytes: $0, count: plaintextLen) }) - else { throw MessageReceiverError.decryptionFailed } - - return (plaintext, String(cString: cSessionId)) - } ?? { throw MessageReceiverError.decryptionFailed }() - } - } } public extension Crypto.Verification { @@ -203,3 +394,32 @@ public extension Crypto.Verification { } } } + +// MARK: - Session Pro + +public extension Crypto.Generator { + static func sessionProMasterKeyPair() -> Crypto.Generator { + return Crypto.Generator( + id: "sessionProMasterKeyPair", + args: [] + ) { dependencies in + let cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey + var cMasterSecretKey: [UInt8] = [UInt8](repeating: 0, count: 64) + + guard !cEd25519SecretKey.isEmpty else { throw CryptoError.missingUserSecretKey } + + guard session_ed25519_pro_privkey_for_ed25519_seed(cEd25519SecretKey, &cMasterSecretKey) else { + throw CryptoError.keyGenerationFailed + } + + let seed: Data = try dependencies[singleton: .crypto].tryGenerate(.ed25519Seed(ed25519SecretKey: cMasterSecretKey)) + + return try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair(seed: seed)) + } + } +} + +extension bytes32: @retroactive CAccessible & CMutable {} +extension bytes33: @retroactive CAccessible & CMutable {} +extension bytes64: @retroactive CAccessible & CMutable {} +extension session_protocol_decode_envelope_keys: @retroactive CAccessible & CMutable {} diff --git a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift index 4cb9f22724..5dfed0b6db 100644 --- a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift +++ b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift @@ -10,51 +10,6 @@ import SessionUtilitiesKit // MARK: - Encryption public extension Crypto.Generator { - static func ciphertextWithSessionProtocol( - plaintext: Data, - destination: Message.Destination - ) -> Crypto.Generator { - return Crypto.Generator( - id: "ciphertextWithSessionProtocol", - args: [plaintext, destination] - ) { dependencies in - let destinationX25519PublicKey: Data = try { - switch destination { - case .contact(let publicKey): return Data(SessionId(.standard, hex: publicKey).publicKey) - case .syncMessage: return Data(dependencies[cache: .general].sessionId.publicKey) - case .closedGroup: throw MessageSenderError.deprecatedLegacyGroup - default: throw MessageSenderError.signingFailed - } - }() - - var cPlaintext: [UInt8] = Array(plaintext) - var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey - var cDestinationPubKey: [UInt8] = Array(destinationX25519PublicKey) - var maybeCiphertext: UnsafeMutablePointer? = nil - var ciphertextLen: Int = 0 - - guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } - guard - cEd25519SecretKey.count == 64, - cDestinationPubKey.count == 32, - session_encrypt_for_recipient_deterministic( - &cPlaintext, - cPlaintext.count, - &cEd25519SecretKey, - &cDestinationPubKey, - &maybeCiphertext, - &ciphertextLen - ), - ciphertextLen > 0, - let ciphertext: Data = maybeCiphertext.map({ Data(bytes: $0, count: ciphertextLen) }) - else { throw MessageSenderError.encryptionFailed } - - free(UnsafeMutableRawPointer(mutating: maybeCiphertext)) - - return ciphertext - } - } - static func ciphertextWithMultiEncrypt( messages: [Data], toRecipients recipients: [SessionId], @@ -90,7 +45,7 @@ public extension Crypto.Generator { let encryptedData: Data? = cEncryptedDataPtr.map { Data(bytes: $0, count: outLen) } free(UnsafeMutableRawPointer(mutating: cEncryptedDataPtr)) - return try encryptedData ?? { throw MessageSenderError.encryptionFailed }() + return try encryptedData ?? { throw MessageError.encodingFailed }() } } } @@ -117,7 +72,7 @@ public extension Crypto.Generator { ), ciphertextLen > 0, let ciphertext: Data = maybeCiphertext.map({ Data(bytes: $0, count: ciphertextLen) }) - else { throw MessageSenderError.encryptionFailed } + else { throw MessageError.encodingFailed } free(UnsafeMutableRawPointer(mutating: maybeCiphertext)) @@ -129,40 +84,6 @@ public extension Crypto.Generator { // MARK: - Decryption public extension Crypto.Generator { - static func plaintextWithSessionProtocol( - ciphertext: Data - ) -> Crypto.Generator<(plaintext: Data, senderSessionIdHex: String)> { - return Crypto.Generator( - id: "plaintextWithSessionProtocol", - args: [ciphertext] - ) { dependencies in - var cCiphertext: [UInt8] = Array(ciphertext) - var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey - var cSenderSessionId: [CChar] = [CChar](repeating: 0, count: 67) - var maybePlaintext: UnsafeMutablePointer? = nil - var plaintextLen: Int = 0 - - guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } - guard - cEd25519SecretKey.count == 64, - session_decrypt_incoming( - &cCiphertext, - cCiphertext.count, - &cEd25519SecretKey, - &cSenderSessionId, - &maybePlaintext, - &plaintextLen - ), - plaintextLen > 0, - let plaintext: Data = maybePlaintext.map({ Data(bytes: $0, count: plaintextLen) }) - else { throw MessageReceiverError.decryptionFailed } - - free(UnsafeMutableRawPointer(mutating: maybePlaintext)) - - return (plaintext, String(cString: cSenderSessionId)) - } - } - static func plaintextWithMultiEncrypt( ciphertext: Data, senderSessionId: SessionId, @@ -192,28 +113,7 @@ public extension Crypto.Generator { let decryptedData: Data? = cDecryptedDataPtr.map { Data(bytes: $0, count: outLen) } free(UnsafeMutableRawPointer(mutating: cDecryptedDataPtr)) - return try decryptedData ?? { throw MessageReceiverError.decryptionFailed }() - } - } - - static func messageServerHash( - swarmPubkey: String, - namespace: Network.SnodeAPI.Namespace, - data: Data - ) -> Crypto.Generator { - return Crypto.Generator( - id: "messageServerHash", - args: [swarmPubkey, namespace, data] - ) { - let cSwarmPubkey: [CChar] = try swarmPubkey.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() - let cData: [CChar] = try data.base64EncodedString().cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() - var cHash: [CChar] = [CChar](repeating: 0, count: 65) - - guard session_compute_message_hash(cSwarmPubkey, Int16(namespace.rawValue), cData, &cHash) else { - throw MessageReceiverError.decryptionFailed - } - - return String(cString: cHash) + return try decryptedData ?? { throw CryptoError.decryptionFailed }() } } @@ -238,7 +138,7 @@ public extension Crypto.Generator { ), plaintextLen > 0, let plaintext: Data = maybePlaintext.map({ Data(bytes: $0, count: plaintextLen) }) - else { throw MessageReceiverError.decryptionFailed } + else { throw CryptoError.decryptionFailed } free(UnsafeMutableRawPointer(mutating: maybePlaintext)) diff --git a/SessionMessagingKit/Database/Migrations/_006_SMK_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_006_SMK_InitialSetupMigration.swift index 6c9e861ca9..f7dbbabe52 100644 --- a/SessionMessagingKit/Database/Migrations/_006_SMK_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_006_SMK_InitialSetupMigration.swift @@ -13,7 +13,7 @@ enum _006_SMK_InitialSetupMigration: Migration { Contact.self, Profile.self, SessionThread.self, DisappearingMessagesConfiguration.self, ClosedGroup.self, OpenGroup.self, Capability.self, BlindedIdLookup.self, GroupMember.self, Interaction.self, Attachment.self, InteractionAttachment.self, Quote.self, - LinkPreview.self, ThreadTypingIndicator.self + LinkPreview.self ] public static let fullTextSearchTokenizer: FTS5TokenizerDescriptor = { diff --git a/SessionMessagingKit/Database/Migrations/_027_SessionUtilChanges.swift b/SessionMessagingKit/Database/Migrations/_027_SessionUtilChanges.swift index 57e76b93a9..73285e3fa6 100644 --- a/SessionMessagingKit/Database/Migrations/_027_SessionUtilChanges.swift +++ b/SessionMessagingKit/Database/Migrations/_027_SessionUtilChanges.swift @@ -187,40 +187,42 @@ enum _027_SessionUtilChanges: Migration { /// **Note:** Since migrations are run when running tests creating a random SessionThread will result in unexpected thread /// counts so don't do this when running tests (this logic is the same as in `MainAppContext.isRunningTests` if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { - let threadExists: Bool? = try Bool.fetchOne( - db, - sql: "SELECT EXISTS (SELECT * FROM thread WHERE id = '\(userSessionId.hexString)')" - ) - - if threadExists == false { - try db.execute( - sql: """ - INSERT INTO thread ( - id, - variant, - creationDateTimestamp, - shouldBeVisible, - isPinned, - messageDraft, - notificationSound, - mutedUntilTimestamp, - onlyNotifyForMentions, - markedAsUnread, - pinnedPriority - ) - VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?) - """, - arguments: [ - userSessionId.hexString, - SessionThread.Variant.contact.rawValue, - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), - false, // Not visible - false, - false, - false, - -1, // Hidden priority at the time of writing - ] + if MigrationHelper.userExists(db) { + let threadExists: Bool? = try Bool.fetchOne( + db, + sql: "SELECT EXISTS (SELECT * FROM thread WHERE id = '\(userSessionId.hexString)')" ) + + if threadExists == false { + try db.execute( + sql: """ + INSERT INTO thread ( + id, + variant, + creationDateTimestamp, + shouldBeVisible, + isPinned, + messageDraft, + notificationSound, + mutedUntilTimestamp, + onlyNotifyForMentions, + markedAsUnread, + pinnedPriority + ) + VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?) + """, + arguments: [ + userSessionId.hexString, + SessionThread.Variant.contact.rawValue, + (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), + false, // Not visible + false, + false, + false, + -1, // Hidden priority at the time of writing + ] + ) + } } } diff --git a/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift b/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift index fc0e97b3d2..fccfbaace9 100644 --- a/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift +++ b/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift @@ -64,6 +64,7 @@ enum _028_GenerateInitialUserConfigDumps: Migration { displayName: .set(to: (userProfile?["name"] ?? "")), displayPictureUrl: .set(to: userProfile?["profilePictureUrl"]), displayPictureEncryptionKey: .set(to: userProfile?["profileEncryptionKey"]), + proProfileFeatures: .useExisting, isReuploadProfilePicture: false ) diff --git a/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift b/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift index 94d807c222..8efba44c91 100644 --- a/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift +++ b/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift @@ -146,7 +146,6 @@ enum _036_GroupsRebuildChanges: Migration { /// If the group isn't in the invited state then make sure to subscribe for PNs once the migrations are done if !group.invited, let token: String = dependencies[defaults: .standard, key: .deviceToken] { let maybeAuthMethod: AuthenticationMethod? = try? Authentication.with( - db, swarmPublicKey: group.groupSessionId, using: dependencies ) diff --git a/SessionMessagingKit/Database/Migrations/_047_DropUnneededColumnsAndTables.swift b/SessionMessagingKit/Database/Migrations/_047_DropUnneededColumnsAndTables.swift new file mode 100644 index 0000000000..d75d33135c --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_047_DropUnneededColumnsAndTables.swift @@ -0,0 +1,37 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _047_DropUnneededColumnsAndTables: Migration { + static let identifier: String = "DropUnneededColumnsAndTables" + static let minExpectedRunDuration: TimeInterval = 0.1 + static var createdTables: [(FetchableRecord & TableRecord).Type] = [] + + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + /// This is now handled entirely in memory (previously we had this in the database because UI updates were driven by database + /// changes, but now they are driven via our even observation system - removing this from the database means we no longer need + /// to deal with cleaning up entries on launch as well) + try db.drop(table: "threadTypingIndicator") + + try db.alter(table: "openGroup") { t in + /// We previously stored the "default" communities in the database as, in the past, we wanted to show them immediately + /// regardless of whether we have network connectivity - that changed a while back where we now only want to show them + /// if they are "correct" + /// + /// Instead of removing this column we are repurposing it to `shouldPoll` as, while we don't currently have a mechanism + /// to disable polling a comminuty, it's likley adding one in the future would be beneficial + t.rename(column: "isActive", to: "shouldPoll") + } + + /// When we were storing the "default" communities we added an entry to the database which had an empty `roomToken`, as + /// a result we needed a bunch of checks to ensure we wouldn't include this when doing any operations related to the communities + /// explicitly joined by the user + /// + /// Now that these "default" communities exist solely in memory we can discard these entries + try OpenGroup.filter(OpenGroup.Columns.roomToken == "").deleteAll(db) + + MigrationExecution.updateProgress(1) + } +} diff --git a/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift b/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift new file mode 100644 index 0000000000..bb02c5ffe6 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift @@ -0,0 +1,46 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _048_SessionProChanges: Migration { + static let identifier: String = "SessionProChanges" + static let minExpectedRunDuration: TimeInterval = 0.1 + static var createdTables: [(FetchableRecord & TableRecord).Type] = [] + + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + try db.alter(table: "interaction") { t in + t.drop(column: "isProMessage") + t.add(column: "proMessageFeatures", .integer) + .notNull() + .defaults(to: 0) + t.add(column: "proProfileFeatures", .integer) + .notNull() + .defaults(to: 0) + } + + try db.alter(table: "profile") { t in + t.add(column: "proFeatures", .integer) + .notNull() + .defaults(to: 0) + t.add(column: "proExpiryUnixTimestampMs", .integer) + .notNull() + .defaults(to: 0) + t.add(column: "proGenIndexHashHex", .text) + } + + /// SQLite doesn't retroactively insert default values into columns so we need to add them now + try db.execute(sql: """ + UPDATE interaction + SET proMessageFeatures = 0, proProfileFeatures = 0 + """) + + try db.execute(sql: """ + UPDATE profile + SET proFeatures = 0, proExpiryUnixTimestampMs = 0 + """) + + MigrationExecution.updateProgress(1) + } +} diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 532d5fccd8..42e125920e 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -12,14 +12,6 @@ import SessionUIKit public struct Attachment: Sendable, Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } - internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId]) - public static let interactionAttachments = hasOne(InteractionAttachment.self) - public static let interaction = hasOne( - Interaction.self, - through: interactionAttachments, - using: InteractionAttachment.interaction - ) - fileprivate static let linkPreview = belongsTo(LinkPreview.self, using: linkPreviewForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { @@ -42,7 +34,7 @@ public struct Attachment: Sendable, Codable, Identifiable, Equatable, Hashable, case caption } - public enum Variant: Int, Sendable, Codable, DatabaseValueConvertible { + public enum Variant: Int, Sendable, Codable, CaseIterable, DatabaseValueConvertible { case standard case voiceMessage } diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift index 1d53aff01d..c14dba4b35 100644 --- a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -102,9 +102,14 @@ public extension BlindedIdLookup { ) ) { - return try lookup + lookup = try lookup .with(sessionId: sessionId) .upserted(db) + db.addContactEvent( + id: lookup.blindedId, + change: .unblinded(blindedId: lookup.blindedId, unblindedId: sessionId) + ) + return lookup } // We now need to try to match the blinded id to an existing contact, this can only be done by looping @@ -129,6 +134,10 @@ public extension BlindedIdLookup { lookup = try lookup .with(sessionId: contact.id) .upserted(db) + db.addContactEvent( + id: lookup.blindedId, + change: .unblinded(blindedId: lookup.blindedId, unblindedId: contact.id) + ) // There is an edge-case where the contact might not have their 'isApproved' flag set to true // but if we have a `BlindedIdLookup` for them and are performing the lookup from the outbox @@ -176,6 +185,10 @@ public extension BlindedIdLookup { lookup = try lookup .with(sessionId: sessionId) .upserted(db) + db.addContactEvent( + id: lookup.blindedId, + change: .unblinded(blindedId: lookup.blindedId, unblindedId: sessionId) + ) break } diff --git a/SessionMessagingKit/Database/Models/Capability.swift b/SessionMessagingKit/Database/Models/Capability.swift index c44f2c74c3..f45de32af9 100644 --- a/SessionMessagingKit/Database/Models/Capability.swift +++ b/SessionMessagingKit/Database/Models/Capability.swift @@ -16,7 +16,7 @@ public struct Capability: Codable, FetchableRecord, PersistableRecord, TableReco case isMissing } - public enum Variant: Equatable, Hashable, CaseIterable, Codable, DatabaseValueConvertible { + public enum Variant: Sendable, Equatable, Hashable, CaseIterable, Codable, DatabaseValueConvertible { public static var allCases: [Variant] { [.sogs, .blind, .reactions] } diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index da2655eb59..1abdb89e62 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -8,11 +8,8 @@ import SessionUIKit import SessionNetworkingKit import SessionUtilitiesKit -public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct ClosedGroup: Sendable, Codable, Equatable, Hashable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "closedGroup" } - internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) - public static let thread = belongsTo(SessionThread.self, using: threadForeignKey) - public static let members = hasMany(GroupMember.self, using: GroupMember.closedGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -67,36 +64,6 @@ public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, Fetchable /// A flag indicating whether this group is in the "expired" state (ie. it's config messages no longer exist) public let expired: Bool? - // MARK: - Relationships - - public var thread: QueryInterfaceRequest { - request(for: ClosedGroup.thread) - } - - public var allMembers: QueryInterfaceRequest { - request(for: ClosedGroup.members) - } - - public var members: QueryInterfaceRequest { - request(for: ClosedGroup.members) - .filter(GroupMember.Columns.role == GroupMember.Role.standard) - } - - public var zombies: QueryInterfaceRequest { - request(for: ClosedGroup.members) - .filter(GroupMember.Columns.role == GroupMember.Role.zombie) - } - - public var moderators: QueryInterfaceRequest { - request(for: ClosedGroup.members) - .filter(GroupMember.Columns.role == GroupMember.Role.moderator) - } - - public var admins: QueryInterfaceRequest { - request(for: ClosedGroup.members) - .filter(GroupMember.Columns.role == GroupMember.Role.admin) - } - // MARK: - Initialization public init( @@ -159,6 +126,27 @@ public extension ClosedGroup { case userGroup } + func with( + name: Update = .useExisting, + groupDescription: Update = .useExisting, + displayPictureUrl: Update = .useExisting, + displayPictureEncryptionKey: Update = .useExisting + ) -> ClosedGroup { + return ClosedGroup( + threadId: threadId, + name: name.or(self.name), + groupDescription: groupDescription.or(self.groupDescription), + formationTimestamp: formationTimestamp, + displayPictureUrl: displayPictureUrl.or(self.displayPictureUrl), + displayPictureEncryptionKey: displayPictureEncryptionKey.or(self.displayPictureEncryptionKey), + shouldPoll: shouldPoll, + groupIdentityPrivateKey: groupIdentityPrivateKey, + authData: authData, + invited: invited, + expired: expired + ) + } + static func approveGroupIfNeeded( _ db: ObservingDatabase, group: ClosedGroup, @@ -227,7 +215,6 @@ public extension ClosedGroup { /// Subscribe for group push notifications if let token: String = dependencies[defaults: .standard, key: .deviceToken] { let maybeAuthMethod: AuthenticationMethod? = try? Authentication.with( - db, swarmPublicKey: group.id, using: dependencies ) @@ -261,6 +248,7 @@ public extension ClosedGroup { // Remove the group from the database and unsubscribe from PNs let threadVariants: [ThreadIdVariant] = try { guard + dataToRemove.contains(.thread) || dataToRemove.contains(.pushNotifications) || dataToRemove.contains(.userGroup) || dataToRemove.contains(.libSessionState) @@ -272,6 +260,9 @@ public extension ClosedGroup { .asRequest(of: ThreadIdVariant.self) .fetchAll(db) }() + let threadVariantMap: [String: SessionThread.Variant] = threadVariants.reduce(into: [:]) { result, next in + result[next.id] = next.variant + } let messageRequestMap: [String: Bool] = dependencies.mutate(cache: .libSession) { libSession in threadVariants .map { ($0.id, libSession.isMessageRequest(threadId: $0.id, threadVariant: $0.variant)) } @@ -311,24 +302,27 @@ public extension ClosedGroup { /// Bulk unsubscripe from updated groups being removed if dataToRemove.contains(.pushNotifications) && threadVariants.contains(where: { $0.variant == .group }) { if let token: String = dependencies[defaults: .standard, key: .deviceToken] { - try? Network.PushNotification - .preparedUnsubscribe( - token: Data(hex: token), - swarms: threadVariants - .filter { $0.variant == .group } - .compactMap { info in - let authMethod: AuthenticationMethod? = try? Authentication.with( - db, - swarmPublicKey: info.id, - using: dependencies - ) - - return authMethod.map { (SessionId(.group, hex: info.id), $0) } - }, - using: dependencies - ) - .send(using: dependencies) - .sinkUntilComplete() + let swarms: [(sessionId: SessionId, authMethod: AuthenticationMethod)] = threadVariants + .filter { $0.variant == .group } + .compactMap { info in + let authMethod: AuthenticationMethod? = try? Authentication.with( + swarmPublicKey: info.id, + using: dependencies + ) + + return authMethod.map { (SessionId(.group, hex: info.id), $0) } + } + + if !swarms.isEmpty { + try? Network.PushNotification + .preparedUnsubscribe( + token: Data(hex: token), + swarms: swarms, + using: dependencies + ) + .send(using: dependencies) + .sinkUntilComplete() + } } } } @@ -388,7 +382,11 @@ public extension ClosedGroup { .deleteAll(db) threadIds.forEach { id in - db.addConversationEvent(id: id, type: .deleted) + db.addConversationEvent( + id: id, + variant: (threadVariantMap[id] ?? .contact), + type: .deleted + ) /// Need an explicit event for deleting a message request to trigger a home screen update if messageRequestMap[id] == true { diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 8bc35627d4..45faa59b57 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -12,9 +12,6 @@ public struct Contact: Codable, Sendable, PagableRecord, Identifiable, Equatable public static var databaseTableName: String { "contact" } public static let idColumn: ColumnExpression = Columns.id - internal static let threadForeignKey = ForeignKey([Columns.id], to: [SessionThread.Columns.id]) - public static let profile = hasOne(Profile.self, using: Profile.contactForeignKey) - public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case id @@ -48,12 +45,6 @@ public struct Contact: Codable, Sendable, PagableRecord, Identifiable, Equatable /// This flag is used to determine whether this contact has ever been blocked (will be included in the config message if so) public let hasBeenBlocked: Bool - // MARK: - Relationships - - public var profile: QueryInterfaceRequest { - request(for: Contact.profile) - } - // MARK: - Initialization public init( @@ -100,11 +91,30 @@ extension Contact: ProfileAssociated { public var profileId: String { id } public static func compare(lhs: WithProfile, rhs: WithProfile) -> Bool { - let lhsDisplayName: String = (lhs.profile?.displayName(for: .contact)) - .defaulting(to: lhs.profileId.truncated(threadVariant: .contact)) - let rhsDisplayName: String = (rhs.profile?.displayName(for: .contact)) - .defaulting(to: rhs.profileId.truncated(threadVariant: .contact)) + let lhsDisplayName: String = (lhs.profile?.displayName() ?? lhs.profileId.truncated()) + let rhsDisplayName: String = (rhs.profile?.displayName() ?? rhs.profileId.truncated()) return (lhsDisplayName.lowercased() < rhsDisplayName.lowercased()) } + + public func with( + isTrusted: Update = .useExisting, + isApproved: Update = .useExisting, + isBlocked: Update = .useExisting, + lastKnownClientVersion: Update = .useExisting, + didApproveMe: Update = .useExisting, + hasBeenBlocked: Update = .useExisting, + currentUserSessionId: SessionId + ) -> Contact { + return Contact( + id: id, + isTrusted: isTrusted.or(self.isTrusted), + isApproved: isApproved.or(self.isApproved), + isBlocked: isBlocked.or(self.isBlocked), + lastKnownClientVersion: lastKnownClientVersion.or(self.lastKnownClientVersion), + didApproveMe: didApproveMe.or(self.didApproveMe), + hasBeenBlocked: hasBeenBlocked.or(self.hasBeenBlocked), + currentUserSessionId: currentUserSessionId + ) + } } diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 10e9faf495..8822207599 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -7,10 +7,8 @@ import SessionUtil import SessionUtilitiesKit import SessionNetworkingKit -public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct DisappearingMessagesConfiguration: Sendable, Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "disappearingMessagesConfiguration" } - internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) - private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -37,7 +35,7 @@ public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatabl } } - public enum DisappearingMessageType: Int, Codable, Hashable, DatabaseValueConvertible { + public enum DisappearingMessageType: Int, Sendable, Codable, Hashable, DatabaseValueConvertible { case unknown case disappearAfterRead case disappearAfterSend @@ -107,12 +105,6 @@ public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatabl public let isEnabled: Bool public let durationSeconds: TimeInterval public var type: DisappearingMessageType? - - // MARK: - Relationships - - public var thread: QueryInterfaceRequest { - request(for: DisappearingMessagesConfiguration.thread) - } } // MARK: - Mutation @@ -305,7 +297,7 @@ public extension DisappearingMessagesConfiguration { _ db: ObservingDatabase, threadVariant: SessionThread.Variant, authorId: String, - timestampMs: Int64, + timestampMs: UInt64, serverHash: String?, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies @@ -368,7 +360,7 @@ public extension DisappearingMessagesConfiguration { ), using: dependencies ), - timestampMs: timestampMs, + timestampMs: Int64(timestampMs), wasRead: wasRead, expiresInSeconds: interactionExpirationInfo?.expiresInSeconds, expiresStartedAtMs: interactionExpirationInfo?.expiresStartedAtMs, diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index fe278e0665..77fa36e695 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -5,13 +5,8 @@ import GRDB import SessionUIKit import SessionUtilitiesKit -public struct GroupMember: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct GroupMember: Sendable, Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "groupMember" } - internal static let openGroupForeignKey = ForeignKey([Columns.groupId], to: [OpenGroup.Columns.threadId]) - internal static let closedGroupForeignKey = ForeignKey([Columns.groupId], to: [ClosedGroup.Columns.threadId]) - public static let openGroup = belongsTo(OpenGroup.self, using: openGroupForeignKey) - public static let closedGroup = belongsTo(ClosedGroup.self, using: closedGroupForeignKey) - public static let profile = hasOne(Profile.self, using: Profile.groupMemberForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -22,7 +17,7 @@ public struct GroupMember: Codable, Equatable, Hashable, FetchableRecord, Persis case isHidden } - public enum Role: Int, Codable, Comparable, CaseIterable, DatabaseValueConvertible { + public enum Role: Int, Sendable, Codable, Comparable, CaseIterable, DatabaseValueConvertible { case standard case zombie case moderator @@ -31,7 +26,7 @@ public struct GroupMember: Codable, Equatable, Hashable, FetchableRecord, Persis public static func < (lhs: Role, rhs: Role) -> Bool { lhs.rawValue < rhs.rawValue } } - public enum RoleStatus: Int, Codable, CaseIterable, DatabaseValueConvertible { + public enum RoleStatus: Int, Sendable, Codable, CaseIterable, DatabaseValueConvertible { case accepted case pending case failed @@ -48,20 +43,6 @@ public struct GroupMember: Codable, Equatable, Hashable, FetchableRecord, Persis public let roleStatus: RoleStatus public let isHidden: Bool - // MARK: - Relationships - - public var openGroup: QueryInterfaceRequest { - request(for: GroupMember.openGroup) - } - - public var closedGroup: QueryInterfaceRequest { - request(for: GroupMember.closedGroup) - } - - public var profile: QueryInterfaceRequest { - request(for: GroupMember.profile) - } - // MARK: - Initialization public init( @@ -143,10 +124,8 @@ extension GroupMember: ProfileAssociated { rhs: WithProfile ) -> Bool { let isUpdatedGroup: Bool = (((try? SessionId.Prefix(from: lhs.value.groupId)) ?? .group) == .group) - let lhsDisplayName: String = (lhs.profile?.displayName(for: .contact)) - .defaulting(to: lhs.profileId.truncated(threadVariant: .contact)) - let rhsDisplayName: String = (rhs.profile?.displayName(for: .contact)) - .defaulting(to: rhs.profileId.truncated(threadVariant: .contact)) + let lhsDisplayName: String = (lhs.profile?.displayName() ?? lhs.profileId.truncated()) + let rhsDisplayName: String = (rhs.profile?.displayName() ?? rhs.profileId.truncated()) // Legacy groups have a different sorting behaviour guard isUpdatedGroup else { @@ -226,10 +205,8 @@ extension GroupMember: ProfileAssociated { rhs: WithProfile ) -> Bool { let isUpdatedGroup: Bool = (((try? SessionId.Prefix(from: lhs.value.groupId)) ?? .group) == .group) - let lhsDisplayName: String = (lhs.profile?.displayName(for: .contact)) - .defaulting(to: lhs.profileId.truncated(threadVariant: .contact)) - let rhsDisplayName: String = (rhs.profile?.displayName(for: .contact)) - .defaulting(to: rhs.profileId.truncated(threadVariant: .contact)) + let lhsDisplayName: String = (lhs.profile?.displayName() ?? lhs.profileId.truncated()) + let rhsDisplayName: String = (rhs.profile?.displayName() ?? rhs.profileId.truncated()) // Legacy groups have a different sorting behaviour guard isUpdatedGroup else { diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 5df57bec91..4aad505811 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -5,28 +5,10 @@ import GRDB import SessionUtilitiesKit import SessionNetworkingKit -public struct Interaction: Codable, Identifiable, Equatable, Hashable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { +public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, PagableRecord, FetchableRecord, MutablePersistableRecord, IdentifiableTableRecord, ColumnExpressible { + public typealias PagedDataType = Interaction public static var databaseTableName: String { "interaction" } - internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) - internal static let linkPreviewForeignKey = ForeignKey( - [Columns.linkPreviewUrl], - to: [LinkPreview.Columns.url] - ) - public static let thread = belongsTo(SessionThread.self, using: threadForeignKey) - public static let profile = hasOne(Profile.self, using: Profile.interactionForeignKey) - public static let interactionAttachments = hasMany( - InteractionAttachment.self, - using: InteractionAttachment.interactionForeignKey - ) - public static let attachments = hasMany( - Attachment.self, - through: interactionAttachments, - using: InteractionAttachment.attachment - ) - - /// Whenever using this `linkPreview` association make sure to filter the result using - /// `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned - public static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) + public static let idColumn: ColumnExpression = Columns.id // stringlint:ignore_contents public static func linkPreviewFilterLiteral( @@ -70,10 +52,11 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable case mostRecentFailureText // Session Pro - case isProMessage + case proMessageFeatures + case proProfileFeatures } - public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible, CaseIterable { + public enum Variant: Int, Sendable, Codable, Hashable, DatabaseValueConvertible, CaseIterable { case _legacyStandardIncomingDeleted = 2 // Had an incorrect index so broke this... case standardIncoming = 0 @@ -105,7 +88,7 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable case infoCall = 5000 } - public enum State: Int, Codable, Hashable, DatabaseValueConvertible { + public enum State: Int, Sendable, Codable, Hashable, DatabaseValueConvertible { case sending // Spacing out the values to allow for additional statuses in the future @@ -221,42 +204,11 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable /// The reason why the most recent attempt to send this message failed public private(set) var mostRecentFailureText: String? - /// A flag indicating if the message sender is a Session Pro user when the message is sent - public let isProMessage: Bool + /// A bitset indicating which Session Pro message features were used when this message was sent + public let proMessageFeatures: SessionPro.MessageFeatures - // MARK: - Relationships - - public var thread: QueryInterfaceRequest { - request(for: Interaction.thread) - } - - public var profile: QueryInterfaceRequest { - request(for: Interaction.profile) - } - - /// Depending on the data associated to this interaction this array will represent different things, these - /// cases are mutually exclusive: - /// - /// **Quote:** The thumbnails associated to the `Quote` - /// **LinkPreview:** The thumbnails associated to the `LinkPreview` - /// **Other:** The files directly attached to the interaction - public var attachments: QueryInterfaceRequest { - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - return request(for: Interaction.attachments) - .order(interactionAttachment[.albumIndex]) - } - - public var linkPreview: QueryInterfaceRequest { - /// **Note:** This equation **MUST** match the `linkPreviewFilterLiteral` logic - let halfResolution: Double = LinkPreview.timstampResolution - - return request(for: Interaction.linkPreview) - .filter( - (timestampMs >= (LinkPreview.Columns.timestamp - halfResolution) * 1000) && - (timestampMs <= (LinkPreview.Columns.timestamp + halfResolution) * 1000) - ) - } + /// A bitset indicating which Session Pro profile features were used when this message was sent + public let proProfileFeatures: SessionPro.ProfileFeatures // MARK: - Initialization @@ -282,7 +234,8 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable state: State, recipientReadTimestampMs: Int64?, mostRecentFailureText: String?, - isProMessage: Bool + proMessageFeatures: SessionPro.MessageFeatures, + proProfileFeatures: SessionPro.ProfileFeatures ) { self.id = id self.serverHash = serverHash @@ -305,7 +258,8 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable self.state = (variant.isLocalOnly ? .localOnly : state) self.recipientReadTimestampMs = recipientReadTimestampMs self.mostRecentFailureText = mostRecentFailureText - self.isProMessage = isProMessage + self.proMessageFeatures = proMessageFeatures + self.proProfileFeatures = proProfileFeatures } public init( @@ -327,7 +281,8 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable openGroupWhisperMods: Bool = false, openGroupWhisperTo: String? = nil, state: Interaction.State? = nil, - isProMessage: Bool = false, + proMessageFeatures: SessionPro.MessageFeatures = .none, + proProfileFeatures: SessionPro.ProfileFeatures = .none, using dependencies: Dependencies ) { self.serverHash = serverHash @@ -342,7 +297,7 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable case .standardIncoming, .standardOutgoing: return dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - /// For TSInteractions which are not `standardIncoming` and `standardOutgoing` use the `timestampMs` value + /// For Interactions which are not `standardIncoming` and `standardOutgoing` use the `timestampMs` value default: return timestampMs } }() @@ -364,7 +319,8 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable self.recipientReadTimestampMs = nil self.mostRecentFailureText = nil - self.isProMessage = isProMessage + self.proMessageFeatures = proMessageFeatures + self.proProfileFeatures = proProfileFeatures } // MARK: - Custom Database Interaction @@ -379,14 +335,14 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable } public func aroundInsert(_ db: Database, insert: () throws -> InsertionSuccess) throws { - _ = try insert() + let result: InsertionSuccess = try insert() // Start the disappearing messages timer if needed switch ObservationContext.observingDb { case .none: Log.error("[Interaction] Could not process 'aroundInsert' due to missing observingDb.") case .some(let observingDb): observingDb.dependencies.setAsync(.hasSavedMessage, true) - observingDb.addMessageEvent(id: id, threadId: threadId, type: .created) + observingDb.addMessageEvent(id: result.rowID, threadId: threadId, type: .created) if self.expiresStartedAtMs != nil { observingDb.dependencies[singleton: .jobRunner].upsert( @@ -454,7 +410,8 @@ public extension Interaction { state: try container.decode(State.self, forKey: .state), recipientReadTimestampMs: try? container.decode(Int64?.self, forKey: .recipientReadTimestampMs), mostRecentFailureText: try? container.decode(String?.self, forKey: .mostRecentFailureText), - isProMessage: (try? container.decode(Bool.self, forKey: .isProMessage)).defaulting(to: false) + proMessageFeatures: try container.decode(SessionPro.MessageFeatures.self, forKey: .proMessageFeatures), + proProfileFeatures: try container.decode(SessionPro.ProfileFeatures.self, forKey: .proProfileFeatures) ) } } @@ -498,7 +455,8 @@ public extension Interaction { state: (state ?? self.state), recipientReadTimestampMs: (recipientReadTimestampMs ?? self.recipientReadTimestampMs), mostRecentFailureText: (mostRecentFailureText ?? self.mostRecentFailureText), - isProMessage: self.isProMessage + proMessageFeatures: self.proMessageFeatures, + proProfileFeatures: self.proProfileFeatures ) } @@ -568,7 +526,7 @@ public extension Interaction { JOIN \(SessionThread.self) ON ( \(thread[.id]) = \(interaction[.threadId]) AND -- Ignore message request threads (these should be counted by the PN extension but - -- seeing the "Message Requests" banner is considered marking the "Unread Message + -- seeing the 'Message Requests' banner is considered marking the "Unread Message -- Request" notification as read) \(thread[.id]) NOT IN \(messageRequestThreadIds) AND ( -- Ignore muted threads @@ -632,7 +590,11 @@ public extension Interaction { _ = try Interaction .filter(id: interactionId) .updateAll(db, Columns.wasRead.set(to: true)) - db.addConversationEvent(id: threadId, type: .updated(.unreadCountChanged)) + db.addConversationEvent( + id: threadId, + variant: threadVariant, + type: .updated(.unreadCount) + ) /// Need to trigger an unread message request count update as well if dependencies.mutate(cache: .libSession, { $0.isMessageRequest(threadId: threadId, threadVariant: threadVariant) }) { @@ -691,7 +653,11 @@ public extension Interaction { interactionInfoToMarkAsRead.forEach { info in db.addMessageEvent(id: info.id, threadId: threadId, type: .updated(.wasRead(true))) } - db.addConversationEvent(id: threadId, type: .updated(.unreadCountChanged)) + db.addConversationEvent( + id: threadId, + variant: threadVariant, + type: .updated(.unreadCount) + ) /// Need to trigger an unread message request count update as well if dependencies.mutate(cache: .libSession, { $0.isMessageRequest(threadId: threadId, threadVariant: threadVariant) }) { @@ -859,7 +825,7 @@ public extension Interaction { ) } .appending(Interaction.notificationIdentifier( - for: "0", + for: "0", // stringlint:ignore threadId: threadId, shouldGroupMessagesForThread: true )) @@ -898,7 +864,7 @@ public extension Interaction { let body: String } - struct TimestampInfo: FetchableRecord, Codable { + struct TimestampInfo: FetchableRecord, Sendable, Codable, Equatable { public let id: Int64 public let timestampMs: Int64 @@ -966,6 +932,73 @@ public extension Interaction { // MARK: - Functions + // stringlint:ignore_contents + static func attachments( + interactionId: Int64? + ) -> SQLRequest? { + guard let interactionId: Int64 = interactionId else { return nil } + + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return """ + SELECT * + FROM \(attachment) + JOIN \(interactionAttachment) ON \(attachment[.id]) = \(interactionAttachment[.attachmentId]) + WHERE \(interactionAttachment[.interactionId]) = \(interactionId) + ORDER BY \(interactionAttachment[.albumIndex]) + """ + } + + // stringlint:ignore_contents + static func attachmentDescription( + _ db: ObservingDatabase, + interactionId: Int64? + ) throws -> Attachment.DescriptionInfo? { + guard let interactionId: Int64 = interactionId else { return nil } + + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + let request: SQLRequest = """ + SELECT + \(attachment[.id]), + \(attachment[.variant]), + \(attachment[.contentType]), + \(attachment[.sourceFilename]) + FROM \(attachment) + JOIN \(interactionAttachment) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + WHERE \(interactionAttachment[.interactionId]) = \(interactionId) + ORDER BY \(interactionAttachment[.albumIndex]) + LIMIT 1 + """ + + return try request.fetchOne(db) + } + + // stringlint:ignore_contents + static func linkPreview( + url: String?, + timestampMs: Int64, + variants: [LinkPreview.Variant] = LinkPreview.Variant.allCases + ) -> SQLRequest? { + guard let url: String = url else { return nil } + + let linkPreview: TypedTableAlias = TypedTableAlias() + let minTimestamp: Int64 = (timestampMs - Int64(LinkPreview.timstampResolution * 1000)) + let maxTimestamp: Int64 = (timestampMs + Int64(LinkPreview.timstampResolution * 1000)) + + /// This logic **MUST** always match the `linkPreviewFilterLiteral` logic + return """ + SELECT * + FROM \(linkPreview) + WHERE ( + \(linkPreview[.url]) = \(url) AND + \(linkPreview[.timestamp]) BETWEEN (\(minTimestamp) AND \(maxTimestamp)) AND + \(linkPreview[.variant]) IN \(variants) + ) + """ + } + func notificationIdentifier(shouldGroupMessagesForThread: Bool) -> String { // When the app is in the background we want the notifications to be grouped to prevent spam return Interaction.notificationIdentifier( @@ -1043,47 +1076,7 @@ public extension Interaction { } } - /// Use the `Interaction.previewText` method directly where possible rather than this one to avoid database queries - static func notificationPreviewText( - _ db: ObservingDatabase, - interaction: Interaction, - using dependencies: Dependencies - ) -> String { - switch interaction.variant { - case .standardIncoming, .standardOutgoing: - return Interaction.previewText( - variant: interaction.variant, - body: interaction.body, - attachmentDescriptionInfo: try? interaction.attachments - .select(.id, .variant, .contentType, .sourceFilename) - .asRequest(of: Attachment.DescriptionInfo.self) - .fetchOne(db), - attachmentCount: try? interaction.attachments.fetchCount(db), - isOpenGroupInvitation: interaction.linkPreview - .filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation) - .isNotEmpty(db), - using: dependencies - ) - - case .infoMediaSavedNotification, .infoScreenshotNotification, .infoCall: - // Note: These should only occur in 'contact' threads so the `threadId` - // is the contact id - return Interaction.previewText( - variant: interaction.variant, - body: interaction.body, - authorDisplayName: Profile.displayName(db, id: interaction.threadId), - using: dependencies - ) - - default: return Interaction.previewText( - variant: interaction.variant, - body: interaction.body, - using: dependencies - ) - } - } - - /// This menthod generates the preview text for a given transaction + /// This function generates the preview text for a given interaction to be displayed in notification content or the conversation list static func previewText( variant: Variant, body: String?, @@ -1091,8 +1084,7 @@ public extension Interaction { authorDisplayName: String = "", attachmentDescriptionInfo: Attachment.DescriptionInfo? = nil, attachmentCount: Int? = nil, - isOpenGroupInvitation: Bool = false, - using dependencies: Dependencies + isOpenGroupInvitation: Bool = false ) -> String { switch variant { case ._legacyStandardIncomingDeleted, .standardIncomingDeleted, .standardIncomingDeletedLocally, @@ -1326,7 +1318,7 @@ public extension Interaction { } } - private struct InteractionVariantInfo: Codable, FetchableRecord { + struct VariantInfo: Codable, FetchableRecord { public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case id @@ -1407,10 +1399,10 @@ public extension Interaction { options: DeletionOption, using dependencies: Dependencies ) throws { - let interactionInfo: [InteractionVariantInfo] = try Interaction + let interactionInfo: [VariantInfo] = try Interaction .filter(ids: interactionIds) .select(.id, .variant, .serverHash) - .asRequest(of: InteractionVariantInfo.self) + .asRequest(of: VariantInfo.self) .fetchAll(db) /// Mark the messages as read just in case @@ -1446,10 +1438,15 @@ public extension Interaction { let interactionAttachments: [InteractionAttachment] = try InteractionAttachment .filter(interactionIds.contains(InteractionAttachment.Columns.interactionId)) .fetchAll(db) - let attachments: [Attachment] = try Attachment - .joining(required: Attachment.interaction.filter(interactionIds.contains(Interaction.Columns.id))) + let attachmentDownloadUrls: [String] = try Attachment + .select(.downloadUrl) + .filter(ids: interactionAttachments.map { $0.attachmentId }) + .filter(Attachment.Columns.downloadUrl != nil) + .asRequest(of: String.self) .fetchAll(db) - try attachments.forEach { try $0.delete(db) } + try Attachment + .filter(ids: interactionAttachments.map { $0.attachmentId }) + .deleteAll(db) /// Notify about the attachment deletion interactionAttachments.forEach { info in @@ -1457,10 +1454,20 @@ public extension Interaction { } /// Delete the reactions from the database + let reactionInfo: Set> = try Reaction + .select(Column.rowID, Reaction.Columns.interactionId, Reaction.Columns.emoji) + .filter(interactionIds.contains(Reaction.Columns.interactionId)) + .asRequest(of: FetchableTriple.self) + .fetchSet(db) _ = try Reaction .filter(interactionIds.contains(Reaction.Columns.interactionId)) .deleteAll(db) + /// Notify about the reaction deletion + reactionInfo.forEach { info in + db.addReactionEvent(id: info.first, messageId: info.second, change: .removed(info.third)) + } + /// Flag the `SnodeReceivedMessageInfo` records as invalid (otherwise we might try to poll for a hash which no longer /// exists, resulting in fetching the last 14 days of messages) let serverHashes: Set = interactionInfo.compactMap(\.serverHash).asSet() @@ -1481,6 +1488,10 @@ public extension Interaction { .asSet() try LoggingDatabaseRecordContext.$suppressLogs.withValue(true) { try Interaction.deleteAll(db, ids: infoMessageIds) + + infoMessageIds.forEach { id in + db.addMessageEvent(id: id, threadId: threadId, type: .deleted) + } } let localOnly: Bool = (options.contains(.local) && !options.contains(.network)) @@ -1508,6 +1519,11 @@ public extension Interaction { .filter(ids: info.map { $0.id }) .deleteAll(db) } + + /// Notify about the deletion + interactionIds.forEach { id in + db.addMessageEvent(id: id, threadId: threadId, type: .deleted) + } } else { try Interaction .filter(ids: info.map { $0.id }) @@ -1520,20 +1536,20 @@ public extension Interaction { Interaction.Columns.linkPreviewUrl.set(to: nil), Interaction.Columns.state.set(to: Interaction.State.deleted) ) + + /// Notify about the deletion + interactionIds.forEach { id in + db.addMessageEvent(id: id, threadId: threadId, type: .updated(.markedAsDeleted)) + } } } - /// Notify about the deletion - interactionIds.forEach { id in - db.addMessageEvent(id: id, threadId: threadId, type: .deleted) - } - /// If we had attachments then we want to try to delete their associated files immediately (in the next run loop) as that's the /// behaviour users would expect, if this fails for some reason then they will be cleaned up by the `GarbageCollectionJob` /// but we should still try to handle it immediately - if !attachments.isEmpty { - let attachmentPaths: [String] = attachments.compactMap { - try? dependencies[singleton: .attachmentManager].path(for: $0.downloadUrl) + if !attachmentDownloadUrls.isEmpty { + let attachmentPaths: [String] = attachmentDownloadUrls.compactMap { + try? dependencies[singleton: .attachmentManager].path(for: $0) } DispatchQueue.global(qos: .background).async { diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift index 05feb2132e..675e8c288e 100644 --- a/SessionMessagingKit/Database/Models/InteractionAttachment.swift +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -4,12 +4,8 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct InteractionAttachment: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct InteractionAttachment: Sendable, Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "interactionAttachment" } - internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) - internal static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) - public static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) - internal static let attachment = belongsTo(Attachment.self, using: attachmentForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -22,16 +18,6 @@ public struct InteractionAttachment: Codable, Equatable, FetchableRecord, Persis public let interactionId: Int64 public let attachmentId: String - // MARK: - Relationships - - public var interaction: QueryInterfaceRequest { - request(for: InteractionAttachment.interaction) - } - - public var attachment: QueryInterfaceRequest { - request(for: InteractionAttachment.attachment) - } - // MARK: - Initialization public init( diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index bfd479f168..827fc0e584 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -10,17 +10,11 @@ import SessionUIKit import SessionUtilitiesKit import SessionNetworkingKit -public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct LinkPreview: Sendable, Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "linkPreview" } - internal static let interactionForeignKey = ForeignKey( - [Columns.url], - to: [Interaction.Columns.linkPreviewUrl] - ) - internal static let interactions = hasMany(Interaction.self, using: Interaction.linkPreviewForeignKey) - public static let attachment = hasOne(Attachment.self, using: Attachment.linkPreviewForeignKey) /// We want to cache url previews to the nearest 100,000 seconds (~28 hours - simpler than 86,400) to ensure the user isn't shown a preview that is too stale - internal static let timstampResolution: Double = 100000 + public static let timstampResolution: Double = 100000 internal static let maxImageDimension: CGFloat = 600 public typealias Columns = CodingKeys @@ -32,7 +26,7 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis case attachmentId } - public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { + public enum Variant: Int, Sendable, Codable, Hashable, CaseIterable, DatabaseValueConvertible { case standard case openGroupInvitation } @@ -53,12 +47,6 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis /// The id for the attachment for the link preview image public let attachmentId: String? - // MARK: - Relationships - - public var attachment: QueryInterfaceRequest { - request(for: LinkPreview.attachment) - } - // MARK: - Initialization public init( @@ -82,15 +70,17 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis // MARK: - Protobuf public extension LinkPreview { - init?(_ db: ObservingDatabase, proto: SNProtoDataMessage, sentTimestampMs: TimeInterval) throws { - guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview } - guard URL(string: previewProto.url) != nil else { throw LinkPreviewError.invalidInput } - guard LinkPreviewManager.isValidLinkUrl(previewProto.url) else { throw LinkPreviewError.invalidInput } + init?( + _ db: ObservingDatabase, + linkPreview: VisibleMessage.VMLinkPreview, + sentTimestampMs: UInt64 + ) throws { + guard LinkPreviewManager.isValidLinkUrl(linkPreview.url) else { throw LinkPreviewError.invalidInput } // Try to get an existing link preview first let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: sentTimestampMs) let maybeLinkPreview: LinkPreview? = try? LinkPreview - .filter(LinkPreview.Columns.url == previewProto.url) + .filter(LinkPreview.Columns.url == linkPreview.url) .filter(LinkPreview.Columns.timestamp == timestamp) .fetchOne(db) @@ -99,13 +89,12 @@ public extension LinkPreview { return } - self.url = previewProto.url + self.url = linkPreview.url self.timestamp = timestamp self.variant = .standard - self.title = LinkPreviewManager.normalizeTitle(title: previewProto.title) + self.title = LinkPreviewManager.normalizeTitle(title: linkPreview.title) - if let imageProto = previewProto.image { - let attachment: Attachment = Attachment(proto: imageProto) + if let attachment: Attachment = linkPreview.nonInsertedAttachment { try attachment.insert(db) self.attachmentId = attachment.id @@ -122,10 +111,15 @@ public extension LinkPreview { // MARK: - Convenience public extension LinkPreview { - static func timestampFor(sentTimestampMs: Double) -> TimeInterval { + struct URLMatchResult { + let urlString: String + let matchRange: NSRange + } + + static func timestampFor(sentTimestampMs: UInt64) -> TimeInterval { // We want to round the timestamp down to the nearest 100,000 seconds (~28 hours - simpler // than 86,400) to optimise LinkPreview storage without having too stale data - return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) + return (floor(Double(sentTimestampMs) / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) } static func prepareAttachmentIfPossible( diff --git a/SessionMessagingKit/Database/Models/MessageDeduplication.swift b/SessionMessagingKit/Database/Models/MessageDeduplication.swift index e8b5a8f531..8d4edffc26 100644 --- a/SessionMessagingKit/Database/Models/MessageDeduplication.swift +++ b/SessionMessagingKit/Database/Models/MessageDeduplication.swift @@ -228,7 +228,7 @@ public extension MessageDeduplication { threadId: threadId, uniqueIdentifier: uniqueIdentifier ) { - throw MessageReceiverError.duplicateMessage + throw MessageError.duplicateMessage } /// Also check for a dedupe file using the legacy identifier @@ -238,7 +238,7 @@ public extension MessageDeduplication { threadId: threadId, uniqueIdentifier: legacyIdentifier ) { - throw MessageReceiverError.duplicateMessage + throw MessageError.duplicateMessage } } } @@ -338,7 +338,7 @@ public extension MessageDeduplication { using: dependencies ) } - catch { throw MessageReceiverError.duplicatedCall } + catch { throw MessageError.duplicatedCall } } } @@ -354,8 +354,8 @@ public extension MessageDeduplication { /// We don't actually want to dedupe config messages as `libSession` will take care of that logic and if we do anything /// special then it could result in unexpected behaviours where config messages don't get merged correctly switch processedMessage { - case .config, .invalid: return - case .standard(_, let threadVariant, _, let messageInfo, _): + case .config: return + case .standard(_, let threadVariant, let messageInfo, _): try insert( db, threadId: processedMessage.threadId, @@ -377,7 +377,7 @@ public extension MessageDeduplication { /// We don't actually want to dedupe config messages as `libSession` will take care of that logic and if we do anything /// special then it could result in unexpected behaviours where config messages don't get merged correctly switch processedMessage { - case .config, .invalid: return + case .config: return case .standard: try createDedupeFile( threadId: processedMessage.threadId, @@ -490,8 +490,8 @@ private extension MessageDeduplication { @available(*, deprecated, message: "⚠️ Remove this code once once enough time has passed since it's release (at least 1 month)") static func getLegacyIdentifier(for processedMessage: ProcessedMessage) -> String? { switch processedMessage { - case .config, .invalid: return nil - case .standard(_, _, _, let messageInfo, _): + case .config: return nil + case .standard(_, _, let messageInfo, _): guard let timestampMs: UInt64 = messageInfo.message.sentTimestampMs, let variant: _040_MessageDeduplicationTable.ControlMessageProcessRecordVariant = getLegacyVariant(for: Message.Variant(from: messageInfo.message)) diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 977887c1a1..df4faf8d26 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -7,11 +7,8 @@ import GRDB import SessionNetworkingKit import SessionUtilitiesKit -public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct OpenGroup: Sendable, Codable, Equatable, Hashable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "openGroup" } - internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) - private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) - public static let members = hasMany(GroupMember.self, using: GroupMember.openGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -20,7 +17,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe case roomToken case publicKey case name - case isActive + case shouldPoll case roomDescription = "description" case imageId case userCount @@ -34,13 +31,23 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe case displayPictureOriginalUrl } - public struct Permissions: OptionSet, Codable, DatabaseValueConvertible, Hashable { + public struct Permissions: OptionSet, Sendable, Codable, DatabaseValueConvertible, Hashable { public let rawValue: UInt16 public init(rawValue: UInt16) { self.rawValue = rawValue } + public init(read: Bool, write: Bool, upload: Bool) { + var permissions: Permissions = [] + + if read { permissions.insert(.read) } + if write { permissions.insert(.write) } + if upload { permissions.insert(.upload) } + + self.init(rawValue: permissions.rawValue) + } + public init(roomInfo: Network.SOGS.RoomPollInfo) { var permissions: Permissions = [] @@ -62,6 +69,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe static let write: Permissions = Permissions(rawValue: 1 << 1) static let upload: Permissions = Permissions(rawValue: 1 << 2) + static let noPermissions: Permissions = [] static let all: Permissions = [ .read, .write, .upload ] } @@ -91,9 +99,8 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe /// The public key for the group public let publicKey: String - /// Flag indicating whether this is an `OpenGroup` the user has actively joined (we store inactive - /// open groups so we can display them in the UI but they won't be polled for) - public let isActive: Bool + /// A flag indicating whether we should poll for messages in this community + public let shouldPoll: Bool /// The name for the group public let name: String @@ -137,22 +144,6 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe /// a different hash being generated for existing files - this value also won't be updated until the display picture has actually /// been downloaded public let displayPictureOriginalUrl: String? - - // MARK: - Relationships - - public var thread: QueryInterfaceRequest { - request(for: OpenGroup.thread) - } - - public var moderatorIds: QueryInterfaceRequest { - request(for: OpenGroup.members) - .filter(GroupMember.Columns.role == GroupMember.Role.moderator) - } - - public var adminIds: QueryInterfaceRequest { - request(for: OpenGroup.members) - .filter(GroupMember.Columns.role == GroupMember.Role.admin) - } // MARK: - Initialization @@ -160,7 +151,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe server: String, roomToken: String, publicKey: String, - isActive: Bool, + shouldPoll: Bool, name: String, roomDescription: String? = nil, imageId: String? = nil, @@ -177,7 +168,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe self.server = server.lowercased() self.roomToken = roomToken self.publicKey = publicKey - self.isActive = isActive + self.shouldPoll = shouldPoll self.name = name self.roomDescription = roomDescription self.imageId = imageId @@ -206,7 +197,7 @@ public extension OpenGroup { server: server, roomToken: roomToken, publicKey: publicKey, - isActive: false, + shouldPoll: false, name: roomToken, // Default the name to the `roomToken` until we get retrieve the actual name roomDescription: nil, imageId: nil, @@ -239,6 +230,33 @@ public extension OpenGroup { // Always force the server to lowercase return "\(server.lowercased()).\(roomToken)" } + + func with( + name: Update = .useExisting, + roomDescription: Update = .useExisting, + shouldPoll: Update = .useExisting, + sequenceNumber: Update = .useExisting, + permissions: Update = .useExisting, + displayPictureOriginalUrl: Update = .useExisting + ) -> OpenGroup { + return OpenGroup( + server: server, + roomToken: roomToken, + publicKey: publicKey, + shouldPoll: shouldPoll.or(self.shouldPoll), + name: name.or(self.name), + roomDescription: roomDescription.or(self.roomDescription), + imageId: imageId, + userCount: userCount, + infoUpdates: infoUpdates, + sequenceNumber: sequenceNumber.or(self.sequenceNumber), + inboxLatestMessageId: inboxLatestMessageId, + outboxLatestMessageId: outboxLatestMessageId, + pollFailureCount: pollFailureCount, + permissions: permissions.or(self.permissions), + displayPictureOriginalUrl: displayPictureOriginalUrl.or(self.displayPictureOriginalUrl) + ) + } } extension OpenGroup: CustomStringConvertible, CustomDebugStringConvertible { @@ -250,7 +268,7 @@ extension OpenGroup: CustomStringConvertible, CustomDebugStringConvertible { roomToken: \"\(roomToken)\", id: \"\(id)\", publicKey: \"\(publicKey)\", - isActive: \(isActive), + shouldPoll: \(shouldPoll), name: \"\(name)\", roomDescription: \(roomDescription.map { "\"\($0)\"" } ?? "null"), imageId: \(imageId ?? "null"), diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 9f39789de2..2c8e82b2bc 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -10,11 +10,6 @@ import SessionUtilitiesKit /// `updateAllAndConfig` function. Updating it elsewhere could result in issues with syncing data between devices public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, Differentiable { public static var databaseTableName: String { "profile" } - internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId]) - internal static let contactForeignKey = ForeignKey([Columns.id], to: [Contact.Columns.id]) - internal static let groupMemberForeignKey = ForeignKey([GroupMember.Columns.profileId], to: [Columns.id]) - public static let contact = hasOne(Contact.self, using: contactForeignKey) - public static let groupMembers = hasMany(GroupMember.self, using: groupMemberForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -29,6 +24,10 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet case profileLastUpdated case blocksCommunityMessageRequests + + case proFeatures + case proExpiryUnixTimestampMs + case proGenIndexHashHex } /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant) @@ -54,14 +53,18 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet /// A flag indicating whether this profile has reported that it blocks community message requests public let blocksCommunityMessageRequests: Bool? - /// The Pro Proof for when this profile is updated - // TODO: Implement these when the structure of Session Pro Proof is determined - public let sessionProProof: String? - public var showProBadge: Bool? + /// The Session Pro features enabled for this profile + public let proFeatures: SessionPro.ProfileFeatures + + /// The unix timestamp (in milliseconds) when Session Pro expires for this profile + public let proExpiryUnixTimestampMs: UInt64 + + /// Hash of the generation index for this users Session Pro + public let proGenIndexHashHex: String? // MARK: - Initialization - public init( + public static func with( id: String, name: String, nickname: String? = nil, @@ -69,18 +72,22 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet displayPictureEncryptionKey: Data? = nil, profileLastUpdated: TimeInterval? = nil, blocksCommunityMessageRequests: Bool? = nil, - sessionProProof: String? = nil, - showProBadge: Bool? = nil - ) { - self.id = id - self.name = name - self.nickname = nickname - self.displayPictureUrl = displayPictureUrl - self.displayPictureEncryptionKey = displayPictureEncryptionKey - self.profileLastUpdated = profileLastUpdated - self.blocksCommunityMessageRequests = blocksCommunityMessageRequests - self.sessionProProof = sessionProProof - self.showProBadge = showProBadge + proFeatures: SessionPro.ProfileFeatures = .none, + proExpiryUnixTimestampMs: UInt64 = 0, + proGenIndexHashHex: String? = nil + ) -> Profile { + return Profile( + id: id, + name: name, + nickname: nickname, + displayPictureUrl: displayPictureUrl, + displayPictureEncryptionKey: displayPictureEncryptionKey, + profileLastUpdated: profileLastUpdated, + blocksCommunityMessageRequests: blocksCommunityMessageRequests, + proFeatures: proFeatures, + proExpiryUnixTimestampMs: proExpiryUnixTimestampMs, + proGenIndexHashHex: proGenIndexHashHex + ) } } @@ -106,7 +113,10 @@ extension Profile: CustomStringConvertible, CustomDebugStringConvertible { displayPictureUrl: \(displayPictureUrl.map { "\"\($0)\"" } ?? "null"), displayPictureEncryptionKey: \(displayPictureEncryptionKey?.toHexString() ?? "null"), profileLastUpdated: \(profileLastUpdated.map { "\($0)" } ?? "null"), - blocksCommunityMessageRequests: \(blocksCommunityMessageRequests.map { "\($0)" } ?? "null") + blocksCommunityMessageRequests: \(blocksCommunityMessageRequests.map { "\($0)" } ?? "null"), + proFeatures: \(proFeatures), + proExpiryUnixTimestampMs: \(proExpiryUnixTimestampMs), + proGenIndexHashHex: \(proGenIndexHashHex.map { "\($0)" } ?? "null") ) """ } @@ -137,7 +147,10 @@ public extension Profile { displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: displayPictureKey, profileLastUpdated: try container.decodeIfPresent(TimeInterval.self, forKey: .profileLastUpdated), - blocksCommunityMessageRequests: try container.decodeIfPresent(Bool.self, forKey: .blocksCommunityMessageRequests) + blocksCommunityMessageRequests: try container.decodeIfPresent(Bool.self, forKey: .blocksCommunityMessageRequests), + proFeatures: try container.decode(SessionPro.ProfileFeatures.self, forKey: .proFeatures), + proExpiryUnixTimestampMs: try container.decode(UInt64.self, forKey: .proExpiryUnixTimestampMs), + proGenIndexHashHex: try container.decodeIfPresent(String.self, forKey: .proGenIndexHashHex) ) } @@ -151,6 +164,9 @@ public extension Profile { try container.encodeIfPresent(displayPictureEncryptionKey, forKey: .displayPictureEncryptionKey) try container.encodeIfPresent(profileLastUpdated, forKey: .profileLastUpdated) try container.encodeIfPresent(blocksCommunityMessageRequests, forKey: .blocksCommunityMessageRequests) + try container.encode(proFeatures, forKey: .proFeatures) + try container.encode(proExpiryUnixTimestampMs, forKey: .proExpiryUnixTimestampMs) + try container.encodeIfPresent(proGenIndexHashHex, forKey: .proGenIndexHashHex) } } @@ -168,7 +184,6 @@ public extension Profile { { dataMessageProto.setProfileKey(displayPictureEncryptionKey) profileProto.setProfilePicture(displayPictureUrl) - // TODO: Add ProProof if needed } if let profileLastUpdated: TimeInterval = profileLastUpdated { @@ -192,12 +207,9 @@ public extension Profile { static func displayName( _ db: ObservingDatabase, id: ID, - threadVariant: SessionThread.Variant = .contact, - suppressId: Bool = false, customFallback: String? = nil ) -> String { - let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))? - .displayName(for: threadVariant, suppressId: suppressId) + let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))?.displayName() return (existingDisplayName ?? (customFallback ?? id)) } @@ -208,8 +220,7 @@ public extension Profile { threadVariant: SessionThread.Variant = .contact, suppressId: Bool = false ) -> String? { - return (try? Profile.fetchOne(db, id: id))? - .displayName(for: threadVariant, suppressId: suppressId) + return (try? Profile.fetchOne(db, id: id))?.displayName() } // MARK: - Fetch or Create @@ -223,8 +234,9 @@ public extension Profile { displayPictureEncryptionKey: nil, profileLastUpdated: nil, blocksCommunityMessageRequests: nil, - sessionProProof: nil, - showProBadge: nil + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) } @@ -240,73 +252,6 @@ public extension Profile { } } -// MARK: - Deprecated GRDB Interactions - -public extension Profile { - @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") - static func displayName( - id: ID, - threadVariant: SessionThread.Variant = .contact, - suppressId: Bool = false, - customFallback: String? = nil, - using dependencies: Dependencies - ) -> String { - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - var displayName: String? - dependencies[singleton: .storage].readAsync( - retrieve: { db in Profile.displayName(db, id: id, threadVariant: threadVariant, suppressId: suppressId) }, - completion: { result in - switch result { - case .failure: break - case .success(let name): displayName = name - } - semaphore.signal() - } - ) - semaphore.wait() - return (displayName ?? (customFallback ?? id)) - } - - @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") - static func displayNameNoFallback( - id: ID, - threadVariant: SessionThread.Variant = .contact, - suppressId: Bool = false, - using dependencies: Dependencies - ) -> String? { - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - var displayName: String? - dependencies[singleton: .storage].readAsync( - retrieve: { db in Profile.displayNameNoFallback(db, id: id, threadVariant: threadVariant, suppressId: suppressId) }, - completion: { result in - switch result { - case .failure: break - case .success(let name): displayName = name - } - semaphore.signal() - } - ) - semaphore.wait() - return displayName - } - - @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") - static func defaultDisplayNameRetriever( - threadVariant: SessionThread.Variant = .contact, - using dependencies: Dependencies - ) -> ((String, Bool) -> String?) { - // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) - return { sessionId, _ in - Profile.displayNameNoFallback( - id: sessionId, - threadVariant: threadVariant, - using: dependencies - ) - } - } -} - - // MARK: - Search Queries public extension Profile { @@ -325,54 +270,53 @@ public extension Profile { // MARK: - Convenience public extension Profile { - func displayNameForMention( - for threadVariant: SessionThread.Variant = .contact, - ignoringNickname: Bool = false, - currentUserSessionIds: Set = [] - ) -> String { - guard !currentUserSessionIds.contains(id) else { - return "you".localized() - } - return displayName(for: threadVariant, ignoringNickname: ignoringNickname) - } - /// The name to display in the UI for a given thread variant func displayName( - for threadVariant: SessionThread.Variant = .contact, messageProfile: VisibleMessage.VMProfile? = nil, - ignoringNickname: Bool = false, - suppressId: Bool = false + ignoreNickname: Bool = false, + showYouForCurrentUser: Bool = true, + currentUserSessionIds: Set = [], + includeSessionIdSuffix: Bool = false ) -> String { return Profile.displayName( - for: threadVariant, id: id, name: (messageProfile?.displayName?.nullIfEmpty ?? name), - nickname: (ignoringNickname ? nil : nickname), - suppressId: suppressId + nickname: (ignoreNickname ? nil : nickname), + showYouForCurrentUser: showYouForCurrentUser, + currentUserSessionIds: currentUserSessionIds, + includeSessionIdSuffix: includeSessionIdSuffix ) } static func displayName( - for threadVariant: SessionThread.Variant, id: String, name: String?, nickname: String?, - suppressId: Bool, + showYouForCurrentUser: Bool = true, + currentUserSessionIds: Set = [], + includeSessionIdSuffix: Bool = false, customFallback: String? = nil ) -> String { - if let nickname: String = nickname, !nickname.isEmpty { return nickname } - - guard let name: String = name, name != id, !name.isEmpty else { - return (customFallback ?? id.truncated(threadVariant: threadVariant)) + if showYouForCurrentUser && currentUserSessionIds.contains(id) { + return "you".localized() } - switch (threadVariant, suppressId) { - case (.contact, _), (.legacyGroup, _), (.group, _), (.community, true): return name + // stringlint:ignore_contents + switch (nickname, name, customFallback, includeSessionIdSuffix) { + case (.some(let value), _, _, false) where !value.isEmpty && value != id, + (_, .some(let value), _, false) where !value.isEmpty && value != id, + (_, _, .some(let value), false) where !value.isEmpty && value != id: + return value - case (.community, false): - // In open groups, where it's more likely that multiple users have the same name, - // we display a bit of the Session ID after a user's display name for added context - return "\(name) (\(id.truncated()))" + case (.some(let value), _, _, true) where !value.isEmpty && value != id, + (_, .some(let value), _, true) where !value.isEmpty && value != id, + (_, _, .some(let value), true) where !value.isEmpty && value != id: + return (Dependencies.isRTL ? + "(\(id.truncated(prefix: 4, suffix: 4))) \(value)" : + "​\(value) (\(id.truncated(prefix: 4, suffix: 4)))" + ) + + default: return id.truncated(prefix: 4, suffix: 4) } } } @@ -450,7 +394,10 @@ public extension Profile { displayPictureUrl: Update = .useExisting, displayPictureEncryptionKey: Update = .useExisting, profileLastUpdated: Update = .useExisting, - blocksCommunityMessageRequests: Update = .useExisting + blocksCommunityMessageRequests: Update = .useExisting, + proFeatures: Update = .useExisting, + proExpiryUnixTimestampMs: Update = .useExisting, + proGenIndexHashHex: Update = .useExisting ) -> Profile { return Profile( id: id, @@ -460,7 +407,9 @@ public extension Profile { displayPictureEncryptionKey: displayPictureEncryptionKey.or(self.displayPictureEncryptionKey), profileLastUpdated: profileLastUpdated.or(self.profileLastUpdated), blocksCommunityMessageRequests: blocksCommunityMessageRequests.or(self.blocksCommunityMessageRequests), - sessionProProof: self.sessionProProof + proFeatures: proFeatures.or(self.proFeatures), + proExpiryUnixTimestampMs: proExpiryUnixTimestampMs.or(self.proExpiryUnixTimestampMs), + proGenIndexHashHex: proGenIndexHashHex.or(self.proGenIndexHashHex) ) } } diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index c13fde3ebc..d66c083a90 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Quote: Sendable, Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "quote" } public typealias Columns = CodingKeys @@ -59,19 +59,3 @@ public extension Quote { ) } } - -// MARK: - Protobuf - -public extension Quote { - init?(proto: SNProtoDataMessage, interactionId: Int64, thread: SessionThread) throws { - guard - let quoteProto = proto.quote, - quoteProto.id != 0, - !quoteProto.author.isEmpty - else { return nil } - - self.interactionId = interactionId - self.timestampMs = Int64(quoteProto.id) - self.authorId = quoteProto.author - } -} diff --git a/SessionMessagingKit/Database/Models/Reaction.swift b/SessionMessagingKit/Database/Models/Reaction.swift index 4c5557a5d8..6fb4a1ab5c 100644 --- a/SessionMessagingKit/Database/Models/Reaction.swift +++ b/SessionMessagingKit/Database/Models/Reaction.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Reaction: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Reaction: Sendable, Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "reaction" } internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 9cbc34aecc..41d13de822 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -5,23 +5,11 @@ import GRDB import SessionUtilitiesKit import SessionNetworkingKit -public struct SessionThread: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, IdentifiableTableRecord { +public struct SessionThread: Sendable, Codable, Identifiable, Equatable, Hashable, PagableRecord, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, IdentifiableTableRecord { + public typealias PagedDataType = SessionThread public static var databaseTableName: String { "thread" } public static let idColumn: ColumnExpression = Columns.id - public static let contact = hasOne(Contact.self, using: Contact.threadForeignKey) - public static let closedGroup = hasOne(ClosedGroup.self, using: ClosedGroup.threadForeignKey) - public static let openGroup = hasOne(OpenGroup.self, using: OpenGroup.threadForeignKey) - public static let disappearingMessagesConfiguration = hasOne( - DisappearingMessagesConfiguration.self, - using: DisappearingMessagesConfiguration.threadForeignKey - ) - public static let interactions = hasMany(Interaction.self, using: Interaction.threadForeignKey) - public static let typingIndicator = hasOne( - ThreadTypingIndicator.self, - using: ThreadTypingIndicator.threadForeignKey - ) - public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case id @@ -84,32 +72,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, Hashable, Fetchab /// A value indicating whether this conversation is a draft conversation (ie. hasn't sent a message yet and should auto-delete) public let isDraft: Bool? - // MARK: - Relationships - - public var contact: QueryInterfaceRequest { - request(for: SessionThread.contact) - } - - public var closedGroup: QueryInterfaceRequest { - request(for: SessionThread.closedGroup) - } - - public var openGroup: QueryInterfaceRequest { - request(for: SessionThread.openGroup) - } - - public var disappearingMessagesConfiguration: QueryInterfaceRequest { - request(for: SessionThread.disappearingMessagesConfiguration) - } - - public var interactions: QueryInterfaceRequest { - request(for: SessionThread.interactions) - } - - public var typingIndicator: QueryInterfaceRequest { - request(for: SessionThread.typingIndicator) - } - // MARK: - Initialization public init( @@ -153,7 +115,11 @@ public struct SessionThread: Codable, Identifiable, Equatable, Hashable, Fetchab case .none: Log.error("[SessionThread] Could not process 'aroundInsert' due to missing observingDb.") case .some(let observingDb): observingDb.dependencies.setAsync(.hasSavedThread, true) - observingDb.addConversationEvent(id: id, type: .created) + observingDb.addConversationEvent( + id: id, + variant: variant, + type: .created + ) } } } @@ -362,14 +328,35 @@ public extension SessionThread { ).upserted(db) } + return try result.update(db, values: values, using: dependencies) + } + + @discardableResult static func update( + _ db: ObservingDatabase, + id: ID, + values: TargetValues, + using dependencies: Dependencies + ) throws -> SessionThread? { + guard let thread: SessionThread = try? fetchOne(db, id: id) else { + return nil + } + + return try thread.update(db, values: values, using: dependencies) + } + + private func update( + _ db: ObservingDatabase, + values: TargetValues, + using dependencies: Dependencies + ) throws -> SessionThread { /// Apply any changes if the provided `values` don't match the current or default settings var requiredChanges: [ConfigColumnAssignment] = [] - var finalCreationDateTimestamp: TimeInterval = result.creationDateTimestamp - var finalShouldBeVisible: Bool = result.shouldBeVisible - var finalPinnedPriority: Int32? = result.pinnedPriority - var finalIsDraft: Bool? = result.isDraft - var finalMutedUntilTimestamp: TimeInterval? = result.mutedUntilTimestamp - var finalOnlyNotifyForMentions: Bool = result.onlyNotifyForMentions + var finalCreationDateTimestamp: TimeInterval = creationDateTimestamp + var finalShouldBeVisible: Bool = shouldBeVisible + var finalPinnedPriority: Int32? = pinnedPriority + var finalIsDraft: Bool? = isDraft + var finalMutedUntilTimestamp: TimeInterval? = mutedUntilTimestamp + var finalOnlyNotifyForMentions: Bool = onlyNotifyForMentions /// Resolve any settings which should be sourced from `libSession` let resolvedValues: TargetValues = values.resolveLibSessionValues( @@ -396,6 +383,13 @@ public extension SessionThread { threadVariant: variant, using: dependencies ) + + /// Notify of update + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.disappearingMessageConfiguration(config)) + ) case (_, .useExistingOrSetTo(let config)): // Update if we don't have an existing entry guard (try? DisappearingMessagesConfiguration.exists(db, id: id)) == false else { break } @@ -407,23 +401,38 @@ public extension SessionThread { threadVariant: variant, using: dependencies ) + + /// Notify of update + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.disappearingMessageConfiguration(config)) + ) case (_, .useLibSession): break // Shouldn't happen } /// And update any explicit `setTo` cases - if case .setTo(let value) = values.creationDateTimestamp, value != result.creationDateTimestamp { + if case .setTo(let value) = values.creationDateTimestamp, value != creationDateTimestamp { requiredChanges.append(SessionThread.Columns.creationDateTimestamp.set(to: value)) finalCreationDateTimestamp = value } - if case .setTo(let value) = values.shouldBeVisible, value != result.shouldBeVisible { + if case .setTo(let value) = values.shouldBeVisible, value != shouldBeVisible { requiredChanges.append(SessionThread.Columns.shouldBeVisible.set(to: value)) finalShouldBeVisible = value - db.addConversationEvent(id: id, type: .updated(.shouldBeVisible(value))) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.shouldBeVisible(value)) + ) /// Toggling visibility is the same as "creating"/"deleting" a conversation so send those events as well - db.addConversationEvent(id: id, type: (value ? .created : .deleted)) + db.addConversationEvent( + id: id, + variant: variant, + type: (value ? .created : .deleted) + ) /// Need an explicit event for deleting a message request to trigger a home screen update if !value && dependencies.mutate(cache: .libSession, { $0.isMessageRequest(threadId: id, threadVariant: variant) }) { @@ -431,31 +440,48 @@ public extension SessionThread { } } - if case .setTo(let value) = values.pinnedPriority, value != result.pinnedPriority { + if case .setTo(let value) = values.pinnedPriority, value != pinnedPriority { requiredChanges.append(SessionThread.Columns.pinnedPriority.set(to: value)) finalPinnedPriority = value - db.addConversationEvent(id: id, type: .updated(.pinnedPriority(value))) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.pinnedPriority(value)) + ) } - if case .setTo(let value) = values.isDraft, value != result.isDraft { + if case .setTo(let value) = values.isDraft, value != isDraft { requiredChanges.append(SessionThread.Columns.isDraft.set(to: value)) finalIsDraft = value + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.isDraft(value)) + ) } - if case .setTo(let value) = values.mutedUntilTimestamp, value != result.mutedUntilTimestamp { + if case .setTo(let value) = values.mutedUntilTimestamp, value != mutedUntilTimestamp { requiredChanges.append(SessionThread.Columns.mutedUntilTimestamp.set(to: value)) finalMutedUntilTimestamp = value - db.addConversationEvent(id: id, type: .updated(.mutedUntilTimestamp(value))) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.mutedUntilTimestamp(value)) + ) } - if case .setTo(let value) = values.onlyNotifyForMentions, value != result.onlyNotifyForMentions { + if case .setTo(let value) = values.onlyNotifyForMentions, value != onlyNotifyForMentions { requiredChanges.append(SessionThread.Columns.onlyNotifyForMentions.set(to: value)) finalOnlyNotifyForMentions = value - db.addConversationEvent(id: id, type: .updated(.onlyNotifyForMentions(value))) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.onlyNotifyForMentions(value)) + ) } /// If no changes were needed we can just return the existing/default thread - guard !requiredChanges.isEmpty else { return result } + guard !requiredChanges.isEmpty else { return self } /// Otherwise save the changes try SessionThread @@ -466,24 +492,15 @@ public extension SessionThread { using: dependencies ) - /// We need to re-fetch the updated thread as the changes wouldn't have been applied to `result`, it's also possible additional - /// changes could have happened to the thread during the database operations - /// - /// Since we want to avoid returning a nullable `SessionThread` here we need to fallback to a non-null instance, but it should - /// never be called - return try fetchOne(db, id: id) - .defaulting( - toThrowing: try SessionThread( - id: id, - variant: variant, - creationDateTimestamp: finalCreationDateTimestamp, - shouldBeVisible: finalShouldBeVisible, - mutedUntilTimestamp: finalMutedUntilTimestamp, - onlyNotifyForMentions: finalOnlyNotifyForMentions, - pinnedPriority: finalPinnedPriority, - isDraft: finalIsDraft - ).upserted(db) - ) + /// Return and updated instance + return self.with( + creationDateTimestamp: .set(to: finalCreationDateTimestamp), + shouldBeVisible: .set(to: finalShouldBeVisible), + mutedUntilTimestamp: .set(to: finalMutedUntilTimestamp), + onlyNotifyForMentions: .set(to: finalOnlyNotifyForMentions), + pinnedPriority: .set(to: finalPinnedPriority), + isDraft: .set(to: finalIsDraft) + ) } static func canSendReadReceipt( @@ -534,6 +551,35 @@ public extension SessionThread { ) """) } + + // stringlint:ignore_contents + static func interactionInfoWithAttachments( + threadId: String, + beforeTimestampMs: Int64? = nil, + attachmentVariants: [Attachment.Variant] = Attachment.Variant.allCases + ) throws -> SQLRequest { + let interaction: TypedTableAlias = TypedTableAlias() + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return """ + SELECT + \(interaction[.id]), + \(interaction[.variant]), + \(interaction[.serverHash]) + FROM \(interaction) + JOIN \(interactionAttachment) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) + JOIN \(attachment) ON ( + \(attachment[.id]) = \(interactionAttachment[.attachmentId]) AND + \(attachment[.variant]) IN \(attachmentVariants) + ) + WHERE ( + \(interaction[.threadId]) = \(threadId) AND + \(interaction[.timestampMs]) < \(beforeTimestampMs ?? Int64.max) + ) + GROUP BY \(interaction[.id]) + """ + } } // MARK: - Deletion @@ -577,12 +623,16 @@ public extension SessionThread { switch type { case .hideContactConversation: - try SessionThread.updateVisibility( - db, - threadIds: threadIds, - isVisible: false, - using: dependencies - ) + try threadIds.forEach { id in + try SessionThread.update( + db, + id: id, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(false) + ), + using: dependencies + ) + } case .hideContactConversationAndDeleteContentDirectly: // Clear any interactions for the deleted thread @@ -592,12 +642,16 @@ public extension SessionThread { ) // Hide the threads - try SessionThread.updateVisibility( - db, - threadIds: threadIds, - isVisible: false, - using: dependencies - ) + try threadIds.forEach { id in + try SessionThread.update( + db, + id: id, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(false) + ), + using: dependencies + ) + } // Remove desired deduplication records try MessageDeduplication.deleteIfNeeded(db, threadIds: threadIds, using: dependencies) @@ -619,7 +673,11 @@ public extension SessionThread { .reduce(into: [:]) { result, next in result[next.0] = next.1 } } remainingThreadIds.forEach { id in - db.addConversationEvent(id: id, type: .deleted) + db.addConversationEvent( + id: id, + variant: threadVariant, + type: .deleted + ) /// Need an explicit event for deleting a message request to trigger a home screen update if messageRequestMap[id] == true { @@ -636,12 +694,16 @@ public extension SessionThread { .filter(Interaction.Columns.threadId == userSessionId.hexString) ) - try SessionThread.updateVisibility( - db, - threadIds: threadIds, - isVisible: false, - using: dependencies - ) + try threadIds.forEach { id in + try SessionThread.update( + db, + id: id, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(false) + ), + using: dependencies + ) + } } // Remove desired deduplication records @@ -678,7 +740,11 @@ public extension SessionThread { .reduce(into: [:]) { result, next in result[next.0] = next.1 } } remainingThreadIds.forEach { id in - db.addConversationEvent(id: id, type: .deleted) + db.addConversationEvent( + id: id, + variant: threadVariant, + type: .deleted + ) /// Need an explicit event for deleting a message request to trigger a home screen update if messageRequestMap[id] == true { @@ -704,7 +770,7 @@ public extension SessionThread { case .deleteCommunityAndContent: try threadIds.forEach { threadId in - try dependencies[singleton: .openGroupManager].delete( + try dependencies[singleton: .communityManager].delete( db, openGroupId: threadId, skipLibSessionUpdate: false @@ -717,81 +783,30 @@ public extension SessionThread { // MARK: - Convenience public extension SessionThread { - static func updateVisibility( - _ db: ObservingDatabase, - threadId: String, - isVisible: Bool, - customPriority: Int32? = nil, - additionalChanges: [ConfigColumnAssignment] = [], - using dependencies: Dependencies - ) throws { - try updateVisibility( - db, - threadIds: [threadId], - isVisible: isVisible, - customPriority: customPriority, - additionalChanges: additionalChanges, - using: dependencies + func with( + creationDateTimestamp: Update = .useExisting, + shouldBeVisible: Update = .useExisting, + messageDraft: Update = .useExisting, + mutedUntilTimestamp: Update = .useExisting, + onlyNotifyForMentions: Update = .useExisting, + markedAsUnread: Update = .useExisting, + pinnedPriority: Update = .useExisting, + isDraft: Update = .useExisting + ) -> SessionThread { + return SessionThread( + id: id, + variant: variant, + creationDateTimestamp: creationDateTimestamp.or(self.creationDateTimestamp), + shouldBeVisible: shouldBeVisible.or(self.shouldBeVisible), + messageDraft: messageDraft.or(self.messageDraft), + mutedUntilTimestamp: mutedUntilTimestamp.or(self.mutedUntilTimestamp), + onlyNotifyForMentions: onlyNotifyForMentions.or(self.onlyNotifyForMentions), + markedAsUnread: markedAsUnread.or(self.markedAsUnread), + pinnedPriority: pinnedPriority.or(self.pinnedPriority), + isDraft: isDraft.or(self.isDraft) ) } - static func updateVisibility( - _ db: ObservingDatabase, - threadIds: [String], - isVisible: Bool, - customPriority: Int32? = nil, - additionalChanges: [ConfigColumnAssignment] = [], - using dependencies: Dependencies - ) throws { - struct ThreadInfo: Decodable, FetchableRecord { - var id: String - var shouldBeVisible: Bool - var pinnedPriority: Int32 - } - - let targetPriority: Int32 - - switch (customPriority, isVisible) { - case (.some(let priority), _): targetPriority = priority - case (.none, true): targetPriority = LibSession.visiblePriority - case (.none, false): targetPriority = LibSession.hiddenPriority - } - - let currentInfo: [String: ThreadInfo] = try SessionThread - .select(.id, .shouldBeVisible, .pinnedPriority) - .filter(ids: threadIds) - .asRequest(of: ThreadInfo.self) - .fetchAll(db) - .reduce(into: [:]) { result, next in - result[next.id] = next - } - - _ = try SessionThread - .filter(ids: threadIds) - .updateAllAndConfig( - db, - [ - SessionThread.Columns.pinnedPriority.set(to: targetPriority), - SessionThread.Columns.shouldBeVisible.set(to: isVisible) - ].appending(contentsOf: additionalChanges), - using: dependencies - ) - - /// Emit events for any changes - threadIds.forEach { id in - if currentInfo[id]?.shouldBeVisible != isVisible { - db.addConversationEvent(id: id, type: .updated(.shouldBeVisible(isVisible))) - - /// Toggling visibility is the same as "creating"/"deleting" a conversation - db.addConversationEvent(id: id, type: (isVisible ? .created : .deleted)) - } - - if currentInfo[id]?.pinnedPriority != targetPriority { - db.addConversationEvent(id: id, type: .updated(.pinnedPriority(targetPriority))) - } - } - } - static func unreadMessageRequestsQuery(messageRequestThreadIds: Set) -> SQLRequest { let thread: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -818,20 +833,20 @@ public extension SessionThread { static func displayName( threadId: String, variant: Variant, - closedGroupName: String?, - openGroupName: String?, + groupName: String?, + communityName: String?, isNoteToSelf: Bool, - ignoringNickname: Bool, + ignoreNickname: Bool, profile: Profile? ) -> String { switch variant { - case .legacyGroup, .group: return (closedGroupName ?? "groupUnknown".localized()) - case .community: return (openGroupName ?? "communityUnknown".localized()) + case .legacyGroup, .group: return (groupName?.nullIfEmpty ?? "groupUnknown".localized()) + case .community: return (communityName?.nullIfEmpty ?? "communityUnknown".localized()) case .contact: guard !isNoteToSelf else { return "noteToSelf".localized() } guard let profile: Profile = profile else { return threadId.truncated() } - return profile.displayName(ignoringNickname: ignoringNickname) + return profile.displayName(ignoreNickname: ignoreNickname) } } @@ -847,18 +862,29 @@ public extension SessionThread { let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = openGroupCapabilityInfo else { return nil } - // Check the capabilities to ensure the SOGS is blinded (or whether we have no capabilities) - guard - openGroupCapabilityInfo.capabilities.isEmpty || - openGroupCapabilityInfo.capabilities.contains(.blind) - else { return nil } + return getCurrentUserBlindedSessionId( + publicKey: openGroupCapabilityInfo.publicKey, + blindingPrefix: blindingPrefix, + capabilities: openGroupCapabilityInfo.capabilities, + using: dependencies + ) + } + + static func getCurrentUserBlindedSessionId( + publicKey: String, + blindingPrefix: SessionId.Prefix, + capabilities: Set, + using dependencies: Dependencies + ) -> SessionId? { + /// Check the capabilities to ensure the SOGS is blinded (or whether we have no capabilities) + guard capabilities.isEmpty || capabilities.contains(.blind) else { return nil } switch blindingPrefix { case .blinded15: return dependencies[singleton: .crypto] .generate( .blinded15KeyPair( - serverPublicKey: openGroupCapabilityInfo.publicKey, + serverPublicKey: publicKey, ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) ) @@ -868,7 +894,7 @@ public extension SessionThread { return dependencies[singleton: .crypto] .generate( .blinded25KeyPair( - serverPublicKey: openGroupCapabilityInfo.publicKey, + serverPublicKey: publicKey, ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) ) diff --git a/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift b/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift deleted file mode 100644 index bad5e96dde..0000000000 --- a/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtilitiesKit - -/// This record is created for an incoming typing indicator message -/// -/// **Note:** Currently we only support typing indicator on contact thread (one-to-one), to support groups we would need -/// to change the structure of this table (since it’s primary key is the threadId) -public struct ThreadTypingIndicator: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { - public static var databaseTableName: String { "threadTypingIndicator" } - internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) - private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) - - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression { - case threadId - case timestampMs - } - - public let threadId: String - public let timestampMs: Int64 - - // MARK: - Relationships - - public var thread: QueryInterfaceRequest { - request(for: ThreadTypingIndicator.thread) - } -} diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index d6f8402365..ebf84a9123 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -121,7 +121,7 @@ public enum AttachmentDownloadJob: JobExecutor { let request: Network.PreparedRequest switch maybeAuthMethod { - case let authMethod as Authentication.community: + case let authMethod as Authentication.Community: request = try Network.SOGS.preparedDownload( url: downloadUrl, roomToken: authMethod.roomToken, diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 4bc30d3c23..876ffb9126 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -130,7 +130,7 @@ public enum AttachmentUploadJob: JobExecutor { threadId: threadId, message: details.message, destination: nil, - error: .other(.cat, "Failed", error), + error: .sendFailure(.cat, "Failed", error), interactionId: interactionId, using: dependencies ) @@ -242,7 +242,7 @@ public extension AttachmentUploadJob { ) async throws -> (attachment: Attachment, response: FileUploadResponse) { let shouldEncrypt: Bool = { switch authMethod { - case is Authentication.community: return false + case is Authentication.Community: return false default: return true } }() @@ -305,7 +305,7 @@ public extension AttachmentUploadJob { /// Return the request and the prepared attachment switch authMethod { - case let communityAuth as Authentication.community: + case let communityAuth as Authentication.Community: request = try Network.SOGS.preparedUpload( data: preparedData, roomToken: communityAuth.roomToken, @@ -339,7 +339,7 @@ public extension AttachmentUploadJob { switch (attachment.downloadUrl, isPlaceholderUploadUrl, authMethod) { case (.some(let downloadUrl), false, _): return downloadUrl - case (_, _, let community as Authentication.community): + case (_, _, let community as Authentication.Community): return Network.SOGS.downloadUrlString( for: response.id, server: community.server, diff --git a/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift b/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift index 53cd2a73a5..fff1cb0a4a 100644 --- a/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift @@ -116,7 +116,7 @@ extension ConfigMessageReceiveJob { self.messages = messages .compactMap { processedMessage -> MessageInfo? in switch processedMessage { - case .standard, .invalid: return nil + case .standard: return nil case .config(_, let namespace, let serverHash, let serverTimestampMs, let data, _): return MessageInfo( namespace: namespace, diff --git a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift index f1266f72a1..774a6a5271 100644 --- a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift @@ -91,12 +91,17 @@ public enum ConfigurationSyncJob: JobExecutor { let additionalTransientData: AdditionalTransientData? = (job.transientData as? AdditionalTransientData) Log.info(.cat, "For \(swarmPublicKey) started with changes: \(pendingPushes.pushData.count), old hashes: \(pendingPushes.obsoleteHashes.count)") - dependencies[singleton: .storage] - .readPublisher { db -> AuthenticationMethod in - try Authentication.with(db, swarmPublicKey: swarmPublicKey, using: dependencies) - } - .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Network.BatchResponse), Error> in - try Network.SnodeAPI.preparedSequence( + AnyPublisher + .lazy { () -> Network.PreparedRequest in + let authMethod: AuthenticationMethod = try ( + additionalTransientData?.customAuthMethod ?? + Authentication.with( + swarmPublicKey: swarmPublicKey, + using: dependencies + ) + ) + + return try Network.SnodeAPI.preparedSequence( requests: [] .appending(contentsOf: additionalTransientData?.beforeSequenceRequests) .appending( @@ -134,8 +139,9 @@ public enum ConfigurationSyncJob: JobExecutor { snodeRetrievalRetryCount: 0, // This job has it's own retry mechanism requestAndPathBuildTimeout: Network.defaultTimeout, using: dependencies - ).send(using: dependencies) + ) } + .flatMap { request in request.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .tryMap { (_: ResponseInfoType, response: Network.BatchResponse) -> [ConfigDump] in @@ -334,24 +340,28 @@ extension ConfigurationSyncJob { public let afterSequenceRequests: [any ErasedPreparedRequest] public let requireAllBatchResponses: Bool public let requireAllRequestsSucceed: Bool + public let customAuthMethod: AuthenticationMethod? init?( beforeSequenceRequests: [any ErasedPreparedRequest], afterSequenceRequests: [any ErasedPreparedRequest], requireAllBatchResponses: Bool, - requireAllRequestsSucceed: Bool + requireAllRequestsSucceed: Bool, + customAuthMethod: AuthenticationMethod? ) { guard !beforeSequenceRequests.isEmpty || !afterSequenceRequests.isEmpty || requireAllBatchResponses || - requireAllRequestsSucceed + requireAllRequestsSucceed || + customAuthMethod != nil else { return nil } self.beforeSequenceRequests = beforeSequenceRequests self.afterSequenceRequests = afterSequenceRequests self.requireAllBatchResponses = requireAllBatchResponses self.requireAllRequestsSucceed = requireAllRequestsSucceed + self.customAuthMethod = customAuthMethod } } } @@ -411,6 +421,7 @@ public extension ConfigurationSyncJob { afterSequenceRequests: [any ErasedPreparedRequest] = [], requireAllBatchResponses: Bool = false, requireAllRequestsSucceed: Bool = false, + customAuthMethod: AuthenticationMethod? = nil, using dependencies: Dependencies ) -> AnyPublisher { return Deferred { @@ -425,7 +436,8 @@ public extension ConfigurationSyncJob { beforeSequenceRequests: beforeSequenceRequests, afterSequenceRequests: afterSequenceRequests, requireAllBatchResponses: requireAllBatchResponses, - requireAllRequestsSucceed: requireAllRequestsSucceed + requireAllRequestsSucceed: requireAllRequestsSucceed, + customAuthMethod: customAuthMethod ) ) else { return resolver(Result.failure(NetworkError.parsingFailed)) } diff --git a/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift index e6689d73b0..8a21eae62d 100644 --- a/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift @@ -206,6 +206,13 @@ public extension DisappearingMessagesJob { // If there were no changes then none of the provided `interactionIds` are expiring messages guard (changeCount ?? 0) > 0 else { return nil } + interactionExpirationInfosByExpiresInSeconds.flatMap { _, value in value }.forEach { info in + db.addMessageEvent( + id: info.id, + threadId: threadId, + type: .updated(.expirationTimerStarted(info.expiresInSeconds, startedAtMs))) + } + interactionExpirationInfosByExpiresInSeconds.forEach { expiresInSeconds, expirationInfos in let expirationTimestampMs: Int64 = Int64(startedAtMs + expiresInSeconds * 1000) dependencies[singleton: .jobRunner].add( diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index 483be09a87..c85045a927 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -57,7 +57,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { return try Network.SOGS.preparedDownload( fileId: fileId, roomToken: roomToken, - authMethod: Authentication.community(info: info), + authMethod: Authentication.Community(info: info), skipAuthentication: skipAuthentication, using: dependencies ) @@ -264,7 +264,11 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) db.addProfileEvent(id: id, change: .displayPictureUrl(url)) - db.addConversationEvent(id: id, type: .updated(.displayPictureUrl(url))) + db.addConversationEvent( + id: id, + variant: .contact, + type: .updated(.displayPictureUrl(url)) + ) case .group(let id, let url, let encryptionKey): _ = try? ClosedGroup @@ -275,7 +279,11 @@ public enum DisplayPictureDownloadJob: JobExecutor { ClosedGroup.Columns.displayPictureEncryptionKey.set(to: encryptionKey), using: dependencies ) - db.addConversationEvent(id: id, type: .updated(.displayPictureUrl(url))) + db.addConversationEvent( + id: id, + variant: .group, + type: .updated(.displayPictureUrl(url)) + ) case .community(_, let roomToken, let server, _): _ = try? OpenGroup @@ -287,6 +295,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) db.addConversationEvent( id: OpenGroup.idFor(roomToken: roomToken, server: server), + variant: .community, type: .updated(.displayPictureUrl(downloadUrl)) ) } @@ -374,51 +383,6 @@ extension DisplayPictureDownloadJob { self.timestamp = timestamp } - public init?(owner: DisplayPictureManager.Owner) { - switch owner { - case .user(let profile): - guard - let url: String = profile.displayPictureUrl, - let key: Data = profile.displayPictureEncryptionKey, - let details: Details = Details( - target: .profile(id: profile.id, url: url, encryptionKey: key), - timestamp: profile.profileLastUpdated - ) - else { return nil } - - self = details - - case .group(let group): - guard - let url: String = group.displayPictureUrl, - let key: Data = group.displayPictureEncryptionKey, - let details: Details = Details( - target: .group(id: group.id, url: url, encryptionKey: key), - timestamp: nil - ) - else { return nil } - - self = details - - case .community(let openGroup): - guard - let imageId: String = openGroup.imageId, - let details: Details = Details( - target: .community( - imageId: imageId, - roomToken: openGroup.roomToken, - server: openGroup.server - ), - timestamp: nil - ) - else { return nil } - - self = details - - case .file: return nil - } - } - // MARK: - Functions fileprivate func ensureValidUpdate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { diff --git a/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift b/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift index ef3cdb9d7a..1d43425827 100644 --- a/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift +++ b/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift @@ -24,15 +24,14 @@ public enum ExpirationUpdateJob: JobExecutor { let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - dependencies[singleton: .storage] - .readPublisher { db in + AnyPublisher + .lazy { try Network.SnodeAPI .preparedUpdateExpiry( serverHashes: details.serverHashes, updatedExpiryMs: details.expirationTimestampMs, shortenOnly: true, authMethod: try Authentication.with( - db, swarmPublicKey: dependencies[cache: .general].sessionId.hexString, using: dependencies ), diff --git a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift index 9a85a06a1b..451e95dace 100644 --- a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift @@ -56,42 +56,18 @@ public enum GarbageCollectionJob: JobExecutor { /// are shown) let lastGarbageCollection: Date = dependencies[defaults: .standard, key: .lastGarbageCollection] .defaulting(to: Date.distantPast) - let finalTypesToCollect: Set = { - guard - job.behaviour != .recurringOnActive || - dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) - else { - // Note: This should only contain the `Types` which are unlikely to ever cause - // a startup delay (ie. avoid mass deletions and file management) - return typesToCollect.asSet() - .intersection([ - .threadTypingIndicators - ]) - } - - return typesToCollect.asSet() - }() + + guard + job.behaviour != .recurringOnActive || + dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) + else { return } dependencies[singleton: .storage].writeAsync( updates: { db -> FileInfo in let userSessionId: SessionId = dependencies[cache: .general].sessionId - /// Remove any typing indicators - if finalTypesToCollect.contains(.threadTypingIndicators) { - let threadIds: Set = try ThreadTypingIndicator - .select(.threadId) - .asRequest(of: String.self) - .fetchSet(db) - _ = try ThreadTypingIndicator.deleteAll(db) - - /// Just in case we should emit events for each typing indicator to indicate that it should have stopped typing - threadIds.forEach { id in - db.addTypingIndicatorEvent(threadId: id, change: .stopped) - } - } - /// Remove any old open group messages - open group messages which are older than six months - if finalTypesToCollect.contains(.oldOpenGroupMessages) && dependencies.mutate(cache: .libSession, { $0.get(.trimOpenGroupMessagesOlderThanSixMonths) }) { + if typesToCollect.contains(.oldOpenGroupMessages) && dependencies.mutate(cache: .libSession, { $0.get(.trimOpenGroupMessagesOlderThanSixMonths) }) { let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) @@ -122,7 +98,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned jobs - jobs which have had their threads or interactions removed - if finalTypesToCollect.contains(.orphanedJobs) { + if typesToCollect.contains(.orphanedJobs) { let job: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -150,7 +126,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned link previews - link previews which have no interactions with matching url & rounded timestamps - if finalTypesToCollect.contains(.orphanedLinkPreviews) { + if typesToCollect.contains(.orphanedLinkPreviews) { let linkPreview: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -170,7 +146,7 @@ public enum GarbageCollectionJob: JobExecutor { /// Orphaned open groups - open groups which are no longer associated to a thread (except for the session-run ones for which /// we want cached image data even if the user isn't in the group) - if finalTypesToCollect.contains(.orphanedOpenGroups) { + if typesToCollect.contains(.orphanedOpenGroups) { let openGroup: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() @@ -189,7 +165,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned open group capabilities - capabilities which have no existing open groups with the same server - if finalTypesToCollect.contains(.orphanedOpenGroupCapabilities) { + if typesToCollect.contains(.orphanedOpenGroupCapabilities) { let capability: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() @@ -205,7 +181,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned blinded id lookups - lookups which have no existing threads or approval/block settings for either blinded/un-blinded id - if finalTypesToCollect.contains(.orphanedBlindedIdLookups) { + if typesToCollect.contains(.orphanedBlindedIdLookups) { let blindedIdLookup: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() @@ -233,7 +209,7 @@ public enum GarbageCollectionJob: JobExecutor { /// Approved blinded contact records - once a blinded contact has been approved there is no need to keep the blinded /// contact record around anymore - if finalTypesToCollect.contains(.approvedBlindedContactRecords) { + if typesToCollect.contains(.approvedBlindedContactRecords) { let contact: TypedTableAlias = TypedTableAlias() let blindedIdLookup: TypedTableAlias = TypedTableAlias() @@ -252,7 +228,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned attachments - attachments which have no related interactions, quotes or link previews - if finalTypesToCollect.contains(.orphanedAttachments) { + if typesToCollect.contains(.orphanedAttachments) { let attachment: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() @@ -272,7 +248,7 @@ public enum GarbageCollectionJob: JobExecutor { """) } - if finalTypesToCollect.contains(.orphanedProfiles) { + if typesToCollect.contains(.orphanedProfiles) { let profile: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -308,7 +284,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Remove interactions which should be disappearing after read but never be read within 14 days - if finalTypesToCollect.contains(.expiredUnreadDisappearingMessages) { + if typesToCollect.contains(.expiredUnreadDisappearingMessages) { try Interaction.deleteWhere( db, .filter(Interaction.Columns.expiresInSeconds != 0), @@ -317,13 +293,13 @@ public enum GarbageCollectionJob: JobExecutor { ) } - if finalTypesToCollect.contains(.expiredPendingReadReceipts) { + if typesToCollect.contains(.expiredPendingReadReceipts) { _ = try PendingReadReceipt .filter(PendingReadReceipt.Columns.serverExpirationTimestamp <= timestampNow) .deleteAll(db) } - if finalTypesToCollect.contains(.shadowThreads) { + if typesToCollect.contains(.shadowThreads) { // Shadow threads are thread records which were created to start a conversation that // didn't actually get turned into conversations (ie. the app was closed or crashed // before the user sent a message) @@ -351,7 +327,7 @@ public enum GarbageCollectionJob: JobExecutor { """) } - if finalTypesToCollect.contains(.pruneExpiredLastHashRecords) { + if typesToCollect.contains(.pruneExpiredLastHashRecords) { // Delete any expired SnodeReceivedMessageInfo values associated to a specific node try SnodeReceivedMessageInfo .select(Column.rowID) @@ -365,7 +341,7 @@ public enum GarbageCollectionJob: JobExecutor { var messageDedupeRecords: [MessageDeduplication] = [] /// Orphaned attachment files - attachment files which don't have an associated record in the database - if finalTypesToCollect.contains(.orphanedAttachmentFiles) { + if typesToCollect.contains(.orphanedAttachmentFiles) { /// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage /// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow /// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running) @@ -378,7 +354,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned display picture files - profile avatar files which don't have an associated record in the database - if finalTypesToCollect.contains(.orphanedDisplayPictures) { + if typesToCollect.contains(.orphanedDisplayPictures) { displayPictureFilePaths.insert( contentsOf: Set(try Profile .select(.displayPictureUrl) @@ -405,7 +381,7 @@ public enum GarbageCollectionJob: JobExecutor { ) } - if finalTypesToCollect.contains(.pruneExpiredDeduplicationRecords) { + if typesToCollect.contains(.pruneExpiredDeduplicationRecords) { messageDedupeRecords = try MessageDeduplication .filter( MessageDeduplication.Columns.expirationTimestampSeconds != nil && @@ -430,7 +406,7 @@ public enum GarbageCollectionJob: JobExecutor { var deletionErrors: [Error] = [] /// Orphaned attachment files (actual deletion) - if finalTypesToCollect.contains(.orphanedAttachmentFiles) { + if typesToCollect.contains(.orphanedAttachmentFiles) { let attachmentDirPath: String = dependencies[singleton: .attachmentManager] .sharedDataAttachmentsDirPath() let allAttachmentFilePaths: Set = (Set((try? dependencies[singleton: .fileManager] @@ -457,7 +433,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned display picture files (actual deletion) - if finalTypesToCollect.contains(.orphanedDisplayPictures) { + if typesToCollect.contains(.orphanedDisplayPictures) { let allDisplayPictureFilePaths: Set = (try? dependencies[singleton: .fileManager] .contentsOfDirectory(atPath: dependencies[singleton: .displayPictureManager].sharedDataDisplayPictureDirPath())) .defaulting(to: []) @@ -482,7 +458,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Explicit deduplication records that we want to delete - if finalTypesToCollect.contains(.pruneExpiredDeduplicationRecords) { + if typesToCollect.contains(.pruneExpiredDeduplicationRecords) { fileInfo.messageDedupeRecords.forEach { record in /// We don't want a single deletion failure to block deletion of the other files so try each one and store /// the error to be used to determine success/failure of the job @@ -543,7 +519,6 @@ public enum GarbageCollectionJob: JobExecutor { extension GarbageCollectionJob { public enum Types: Codable, CaseIterable { - case threadTypingIndicators case oldOpenGroupMessages case orphanedJobs case orphanedLinkPreviews diff --git a/SessionMessagingKit/Jobs/GetExpirationJob.swift b/SessionMessagingKit/Jobs/GetExpirationJob.swift index 00be1730b7..26c17b1f5b 100644 --- a/SessionMessagingKit/Jobs/GetExpirationJob.swift +++ b/SessionMessagingKit/Jobs/GetExpirationJob.swift @@ -12,6 +12,13 @@ public enum GetExpirationJob: JobExecutor { public static var requiresInteractionId: Bool = false private static let minRunFrequency: TimeInterval = 5 + private struct ExpirationInteractionInfo: Codable, Hashable, FetchableRecord { + let id: Int64 + let threadId: String + let expiresInSeconds: TimeInterval + let expiresStartedAtMs: Double + } + public static func run( _ job: Job, scheduler: S, @@ -37,12 +44,11 @@ public enum GetExpirationJob: JobExecutor { return success(job, false) } - dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in + AnyPublisher + .lazy { try Network.SnodeAPI.preparedGetExpiries( of: expirationInfo.map { $0.key }, authMethod: try Authentication.with( - db, swarmPublicKey: dependencies[cache: .general].sessionId.hexString, using: dependencies ), @@ -69,6 +75,7 @@ public enum GetExpirationJob: JobExecutor { var hashesWithNoExiprationInfo: Set = Set(expirationInfo.keys) .subtracting(serverSpecifiedExpirationStartTimesMs.keys) + dependencies[singleton: .storage].write { db in try serverSpecifiedExpirationStartTimesMs.forEach { hash, expiresStartedAtMs in try Interaction @@ -104,6 +111,25 @@ public enum GetExpirationJob: JobExecutor { Interaction.Columns.expiresStartedAtMs.set(to: details.startedAtTimestampMs) ) + /// Send events that the expiration started + let allHashes: Set = hashesWithNoExiprationInfo + .inserting(contentsOf: Set(serverSpecifiedExpirationStartTimesMs.keys)) + let interactionInfo: [ExpirationInteractionInfo] = ((try? Interaction + .select(.id, .threadId, .expiresInSeconds, .expiresStartedAtMs) + .filter(allHashes.contains(Interaction.Columns.serverHash)) + .filter(Interaction.Columns.expiresInSeconds != nil) + .filter(Interaction.Columns.expiresStartedAtMs != nil) + .asRequest(of: ExpirationInteractionInfo.self) + .fetchAll(db)) ?? []) + + interactionInfo.forEach { info in + db.addMessageEvent( + id: info.id, + threadId: info.threadId, + type: .updated(.expirationTimerStarted(info.expiresInSeconds, info.expiresStartedAtMs)) + ) + } + dependencies[singleton: .jobRunner].upsert( db, job: DisappearingMessagesJob.updateNextRunIfNeeded(db, using: dependencies), diff --git a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift index e652f75f13..f28969b605 100644 --- a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift @@ -46,7 +46,7 @@ public enum GroupInviteMemberJob: JobExecutor { /// Perform the actual message sending dependencies[singleton: .storage] - .writePublisher { db -> (AuthenticationMethod, AuthenticationMethod) in + .writePublisher { db in _ = try? GroupMember .filter(GroupMember.Columns.groupId == threadId) .filter(GroupMember.Columns.profileId == details.memberSessionIdHexString) @@ -56,28 +56,24 @@ public enum GroupInviteMemberJob: JobExecutor { GroupMember.Columns.roleStatus.set(to: GroupMember.RoleStatus.sending), using: dependencies ) - - return ( - try Authentication.with(db, swarmPublicKey: threadId, using: dependencies), - try Authentication.with( - db, - swarmPublicKey: details.memberSessionIdHexString, - using: dependencies - ) - ) } - .tryFlatMap { groupAuthMethod, memberAuthMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in - try MessageSender.preparedSend( + .tryFlatMap { _ -> AnyPublisher<(ResponseInfoType, Message), Error> in + let groupAuthMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: threadId, + using: dependencies + ) + let memberAuthMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: details.memberSessionIdHexString, + using: dependencies + ) + + return try MessageSender.preparedSend( message: try GroupUpdateInviteMessage( inviteeSessionIdHexString: details.memberSessionIdHexString, groupSessionId: SessionId(.group, hex: threadId), groupName: groupName, memberAuthData: details.memberAuthData, - profile: VisibleMessage.VMProfile( - displayName: adminProfile.name, - profileKey: adminProfile.displayPictureEncryptionKey, - profilePictureUrl: adminProfile.displayPictureUrl - ), + profile: VisibleMessage.VMProfile(profile: adminProfile), sentTimestampMs: UInt64(sentTimestampMs), authMethod: groupAuthMethod, using: dependencies @@ -141,11 +137,8 @@ public enum GroupInviteMemberJob: JobExecutor { // Register the failure switch error { - case let senderError as MessageSenderError where !senderError.isRetryable: - failure(job, error, true) - - case SnodeAPIError.rateLimited: - failure(job, error, true) + case is MessageError: failure(job, error, true) + case SnodeAPIError.rateLimited: failure(job, error, true) case SnodeAPIError.clockOutOfSync: Log.error(.cat, "Permanently Failing to send due to clock out of sync issue.") @@ -160,7 +153,7 @@ public enum GroupInviteMemberJob: JobExecutor { public static func failureMessage(groupName: String, memberIds: [String], profileInfo: [String: Profile]) -> ThemedAttributedString { let memberZeroName: String = memberIds.first - .map { profileInfo[$0]?.displayName(for: .group) ?? $0.truncated() } + .map { profileInfo[$0]?.displayName() ?? $0.truncated() } .defaulting(to: "anonymous".localized()) switch memberIds.count { @@ -172,7 +165,7 @@ public enum GroupInviteMemberJob: JobExecutor { case 2: let memberOneName: String = ( - profileInfo[memberIds[1]]?.displayName(for: .group) ?? + profileInfo[memberIds[1]]?.displayName() ?? memberIds[1].truncated() ) @@ -266,7 +259,7 @@ public extension GroupInviteMemberJob { } let sortedFailedMemberIds: [String] = failedMemberIds.sorted { lhs, rhs in // Sort by name, followed by id if names aren't present - switch (profileMap[lhs]?.displayName(for: .group), profileMap[rhs]?.displayName(for: .group)) { + switch (profileMap[lhs]?.displayName(), profileMap[rhs]?.displayName()) { case (.some(let lhsName), .some(let rhsName)): return lhsName < rhsName case (.some, .none): return true case (.none, .some): return false @@ -339,7 +332,7 @@ extension GroupInviteMemberJob { switch authInfo { case .groupMember(_, let authData): self.memberAuthData = authData - default: throw MessageSenderError.invalidMessage + default: throw MessageError.requiredSignatureMissing } } } diff --git a/SessionMessagingKit/Jobs/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/GroupLeavingJob.swift index 208072da67..d5cbd2e6b5 100644 --- a/SessionMessagingKit/Jobs/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/GroupLeavingJob.swift @@ -34,13 +34,13 @@ public enum GroupLeavingJob: JobExecutor { let interactionId: Int64 = job.interactionId else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - let destination: Message.Destination = .closedGroup(groupPublicKey: threadId) + let destination: Message.Destination = .group(publicKey: threadId) dependencies[singleton: .storage] .writePublisher(updates: { db -> RequestType in guard (try? ClosedGroup.exists(db, id: threadId)) == true else { Log.error(.cat, "Failed due to non-existent group") - throw MessageSenderError.invalidClosedGroupUpdate + throw MessageError.invalidGroupUpdate("Could not retrieve group") } let userSessionId: SessionId = dependencies[cache: .general].sessionId @@ -69,7 +69,7 @@ public enum GroupLeavingJob: JobExecutor { switch (finalBehaviour, isAdminUser, (isAdminUser && numAdminUsers == 1)) { case (.leave, _, false): let disappearingConfig: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration.fetchOne(db, id: threadId) - let authMethod: AuthenticationMethod = try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) + let authMethod: AuthenticationMethod = try Authentication.with(swarmPublicKey: threadId, using: dependencies) return .sendLeaveMessage(authMethod, disappearingConfig) @@ -86,7 +86,7 @@ public enum GroupLeavingJob: JobExecutor { return .configSync case (.delete, false, _): return .configSync - default: throw MessageSenderError.invalidClosedGroupUpdate + default: throw MessageError.invalidGroupUpdate("Unsupported group leaving configuration") } }) .tryFlatMap { requestType -> AnyPublisher in @@ -138,8 +138,8 @@ public enum GroupLeavingJob: JobExecutor { /// If it failed due to one of these errors then clear out any associated data (as the `SessionThread` exists but /// either the data required to send the `MEMBER_LEFT` message doesn't or the user has had their access to the /// group revoked which would leave the user in a state where they can't leave the group) - switch (error as? MessageSenderError, error as? SnodeAPIError, error as? CryptoError) { - case (.invalidClosedGroupUpdate, _, _), (.noKeyPair, _, _), (.encryptionFailed, _, _), + switch (error as? MessageError, error as? SnodeAPIError, error as? CryptoError) { + case (.invalidGroupUpdate, _, _), (.encodingFailed, _, _), (_, .unauthorised, _), (_, _, .invalidAuthentication): return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() diff --git a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift index d46639ac78..1a916ece9f 100644 --- a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift @@ -61,7 +61,7 @@ public enum GroupPromoteMemberJob: JobExecutor { /// Perform the actual message sending dependencies[singleton: .storage] - .writePublisher { db -> AuthenticationMethod in + .writePublisher { db in _ = try? GroupMember .filter(GroupMember.Columns.groupId == threadId) .filter(GroupMember.Columns.profileId == details.memberSessionIdHexString) @@ -72,10 +72,15 @@ public enum GroupPromoteMemberJob: JobExecutor { using: dependencies ) - return try Authentication.with(db, swarmPublicKey: details.memberSessionIdHexString, using: dependencies) + return try Authentication.with(swarmPublicKey: details.memberSessionIdHexString, using: dependencies) } - .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in - try MessageSender.preparedSend( + .tryFlatMap { _ -> AnyPublisher<(ResponseInfoType, Message), Error> in + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: details.memberSessionIdHexString, + using: dependencies + ) + + return try MessageSender.preparedSend( message: message, to: .contact(publicKey: details.memberSessionIdHexString), namespace: .default, @@ -136,11 +141,8 @@ public enum GroupPromoteMemberJob: JobExecutor { // Register the failure switch error { - case let senderError as MessageSenderError where !senderError.isRetryable: - failure(job, error, true) - - case SnodeAPIError.rateLimited: - failure(job, error, true) + case is MessageError: failure(job, error, true) + case SnodeAPIError.rateLimited: failure(job, error, true) case SnodeAPIError.clockOutOfSync: Log.error(.cat, "Permanently Failing to send due to clock out of sync issue.") @@ -155,7 +157,7 @@ public enum GroupPromoteMemberJob: JobExecutor { public static func failureMessage(groupName: String, memberIds: [String], profileInfo: [String: Profile]) -> ThemedAttributedString { let memberZeroName: String = memberIds.first - .map { profileInfo[$0]?.displayName(for: .group) ?? $0.truncated() } + .map { profileInfo[$0]?.displayName() ?? $0.truncated() } .defaulting(to: "anonymous".localized()) switch memberIds.count { @@ -167,7 +169,7 @@ public enum GroupPromoteMemberJob: JobExecutor { case 2: let memberOneName: String = ( - profileInfo[memberIds[1]]?.displayName(for: .group) ?? + profileInfo[memberIds[1]]?.displayName() ?? memberIds[1].truncated() ) @@ -263,7 +265,7 @@ public extension GroupPromoteMemberJob { } let sortedFailedMemberIds: [String] = failedMemberIds.sorted { lhs, rhs in // Sort by name, followed by id if names aren't present - switch (profileMap[lhs]?.displayName(for: .group), profileMap[rhs]?.displayName(for: .group)) { + switch (profileMap[lhs]?.displayName(), profileMap[rhs]?.displayName()) { case (.some(let lhsName), .some(let rhsName)): return lhsName < rhsName case (.some, .none): return true case (.none, .some): return false diff --git a/SessionMessagingKit/Jobs/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/MessageReceiveJob.swift index d1b779bd17..4d55c0fad7 100644 --- a/SessionMessagingKit/Jobs/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/MessageReceiveJob.swift @@ -32,108 +32,116 @@ public enum MessageReceiveJob: JobExecutor { let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - var updatedJob: Job = job - var lastError: Error? - var remainingMessagesToProcess: [Details.MessageInfo] = [] - let messageData: [(info: Details.MessageInfo, proto: SNProtoContent)] = details.messages - .compactMap { messageInfo -> (info: Details.MessageInfo, proto: SNProtoContent)? in - do { - return (messageInfo, try SNProtoContent.parseData(messageInfo.serializedProtoData)) - } - catch { - Log.error(.cat, "Couldn't receive message due to error: \(error)") - lastError = error - - // We failed to process this message but it is a retryable error - // so add it to the list to re-process - remainingMessagesToProcess.append(messageInfo) - return nil - } - } - - dependencies[singleton: .storage].writeAsync( - updates: { db -> Error? in - for (messageInfo, protoContent) in messageData { - do { - let info: MessageReceiver.InsertedInteractionInfo? = try MessageReceiver.handle( - db, - threadId: threadId, - threadVariant: messageInfo.threadVariant, - message: messageInfo.message, - serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: protoContent, - suppressNotifications: false, - using: dependencies - ) - - /// Notify about the received message - MessageReceiver.prepareNotificationsForInsertedInteractions( - db, - insertedInteractionInfo: info, - isMessageRequest: dependencies.mutate(cache: .libSession) { cache in - cache.isMessageRequest(threadId: threadId, threadVariant: messageInfo.threadVariant) - }, - using: dependencies - ) + Task { + typealias Result = ( + updatedJob: Job, + lastError: Error?, + remainingMessagesToProcess: [Details.MessageInfo] + ) + + do { + let currentUserSessionIds: Set = try await { + switch details.messages.first?.threadVariant { + case .none: throw JobRunnerError.missingRequiredDetails + case .contact, .group, .legacyGroup: + return [dependencies[cache: .general].sessionId.hexString] + + case .community: + guard let server: CommunityManager.Server = await dependencies[singleton: .communityManager].server(threadId: threadId) else { + return [dependencies[cache: .general].sessionId.hexString] + } + + return server.currentUserSessionIds } - catch { - // If the current message is a permanent failure then override it with the - // new error (we want to retry if there is a single non-permanent error) - switch error { - // Ignore duplicate and self-send errors (these will usually be caught during - // parsing but sometimes can get past and conflict at database insertion - eg. - // for open group messages) we also don't bother logging as it results in - // excessive logging which isn't useful) - case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, - DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE - MessageReceiverError.duplicateMessage, - MessageReceiverError.selfSend: - break - - case let receiverError as MessageReceiverError where !receiverError.isRetryable: - Log.error(.cat, "Permanently failed message due to error: \(error)") - continue - - default: - Log.error(.cat, "Couldn't receive message due to error: \(error)") - lastError = error - - // We failed to process this message but it is a retryable error - // so add it to the list to re-process - remainingMessagesToProcess.append(messageInfo) + }() + + let result: Result = try await dependencies[singleton: .storage].writeAsync { db in + var lastError: Error? + var remainingMessagesToProcess: [Details.MessageInfo] = [] + + for messageInfo in details.messages { + do { + let info: MessageReceiver.InsertedInteractionInfo? = try MessageReceiver.handle( + db, + threadId: threadId, + threadVariant: messageInfo.threadVariant, + message: messageInfo.message, + decodedMessage: messageInfo.decodedMessage, + serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, + suppressNotifications: false, + currentUserSessionIds: currentUserSessionIds, + using: dependencies + ) + + /// Notify about the received message + MessageReceiver.prepareNotificationsForInsertedInteractions( + db, + insertedInteractionInfo: info, + isMessageRequest: dependencies.mutate(cache: .libSession) { cache in + cache.isMessageRequest(threadId: threadId, threadVariant: messageInfo.threadVariant) + }, + using: dependencies + ) + } + catch { + // If the current message is a permanent failure then override it with the + // new error (we want to retry if there is a single non-permanent error) + switch error { + // Ignore duplicate and self-send errors (these will usually be caught during + // parsing but sometimes can get past and conflict at database insertion - eg. + // for open group messages) we also don't bother logging as it results in + // excessive logging which isn't useful) + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, + DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE + MessageError.duplicateMessage, + MessageError.selfSend: + break + + case is MessageError: + Log.error(.cat, "Permanently failed message due to error: \(error)") + continue + + default: + Log.error(.cat, "Couldn't receive message due to error: \(error)") + lastError = error + + // We failed to process this message but it is a retryable error + // so add it to the list to re-process + remainingMessagesToProcess.append(messageInfo) + } } } + + /// If any messages failed to process then we want to update the job to only include those failed messages + guard !remainingMessagesToProcess.isEmpty else { return (job, lastError, []) } + + return ( + try job + .with(details: Details(messages: remainingMessagesToProcess)) + .defaulting(to: job) + .upserted(db), + lastError, + remainingMessagesToProcess + ) } - // If any messages failed to process then we want to update the job to only include - // those failed messages - guard !remainingMessagesToProcess.isEmpty else { return nil } - - updatedJob = try job - .with(details: Details(messages: remainingMessagesToProcess)) - .defaulting(to: job) - .upserted(db) - - return lastError - }, - completion: { result in - // Handle the result - switch result { - case .failure(let error): failure(updatedJob, error, false) - case .success(let lastError): - /// Report the result of the job - switch lastError { - case let error as MessageReceiverError where !error.isRetryable: - failure(updatedJob, error, true) - - case .some(let error): failure(updatedJob, error, false) - case .none: success(updatedJob, false) - } - - success(updatedJob, false) + return scheduler.schedule { + /// Report the result of the job + switch result.lastError { + case let error as MessageError: failure(result.updatedJob, error, true) + case .some(let error): failure(result.updatedJob, error, false) + case .none: success(result.updatedJob, false) + } + + success(result.updatedJob, false) } } - ) + catch { + return scheduler.schedule { + failure(job, error, false) + } + } + } } } @@ -147,41 +155,29 @@ extension MessageReceiveJob { case variant case threadVariant case serverExpirationTimestamp + @available(*, deprecated, message: "'serializedProtoData' has been removed, access `decodedMesage` instead") case serializedProtoData + case decodedMessage } public let message: Message public let variant: Message.Variant public let threadVariant: SessionThread.Variant public let serverExpirationTimestamp: TimeInterval? - public let serializedProtoData: Data + public let decodedMessage: DecodedMessage public init( message: Message, variant: Message.Variant, threadVariant: SessionThread.Variant, serverExpirationTimestamp: TimeInterval?, - proto: SNProtoContent - ) throws { - self.message = message - self.variant = variant - self.threadVariant = threadVariant - self.serverExpirationTimestamp = serverExpirationTimestamp - self.serializedProtoData = try proto.serializedData() - } - - private init( - message: Message, - variant: Message.Variant, - threadVariant: SessionThread.Variant, - serverExpirationTimestamp: TimeInterval?, - serializedProtoData: Data + decodedMessage: DecodedMessage ) { self.message = message self.variant = variant self.threadVariant = threadVariant self.serverExpirationTimestamp = serverExpirationTimestamp - self.serializedProtoData = serializedProtoData + self.decodedMessage = decodedMessage } // MARK: - Codable @@ -194,12 +190,31 @@ extension MessageReceiveJob { throw StorageError.decodingFailed } + let message: Message = try variant.decode(from: container, forKey: .message) + // FIXME: Remove this once pro has been out for long enough + let decodedMessage: DecodedMessage + if + let sender: SessionId = try? SessionId(from: message.sender), + let sentTimestampMs: UInt64 = message.sentTimestampMs, + let legacyProtoData: Data = try container.decodeIfPresent(Data.self, forKey: .serializedProtoData) + { + decodedMessage = DecodedMessage( + content: legacyProtoData, + sender: sender, + decodedEnvelope: nil, + sentTimestampMs: sentTimestampMs + ) + } + else { + decodedMessage = try container.decode(DecodedMessage.self, forKey: .decodedMessage) + } + self = MessageInfo( - message: try variant.decode(from: container, forKey: .message), + message: message, variant: variant, threadVariant: try container.decode(SessionThread.Variant.self, forKey: .threadVariant), serverExpirationTimestamp: try? container.decode(TimeInterval.self, forKey: .serverExpirationTimestamp), - serializedProtoData: try container.decode(Data.self, forKey: .serializedProtoData) + decodedMessage: decodedMessage ) } @@ -215,7 +230,7 @@ extension MessageReceiveJob { try container.encode(variant, forKey: .variant) try container.encode(threadVariant, forKey: .threadVariant) try container.encodeIfPresent(serverExpirationTimestamp, forKey: .serverExpirationTimestamp) - try container.encode(serializedProtoData, forKey: .serializedProtoData) + try container.encode(decodedMessage, forKey: .decodedMessage) } } @@ -224,8 +239,8 @@ extension MessageReceiveJob { public init(messages: [ProcessedMessage]) { self.messages = messages.compactMap { processedMessage in switch processedMessage { - case .config, .invalid: return nil - case .standard(_, _, _, let messageInfo, _): return messageInfo + case .config: return nil + case .standard(_, _, let messageInfo, _): return messageInfo } } } diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift index 5ec6e54d81..ebd29fa8a6 100644 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/MessageSendJob.swift @@ -92,12 +92,14 @@ public enum MessageSendJob: JobExecutor { using: dependencies ) } - .defaulting(to: AttachmentState(error: MessageSenderError.invalidMessage)) + .defaulting(to: AttachmentState(error: StorageError.invalidQueryResult)) /// If we got an error when trying to retrieve the attachment state then this job is actually invalid so it /// should permanently fail guard attachmentState.error == nil else { - switch (attachmentState.error ?? NetworkError.unknown) { + let finalError: Error = (attachmentState.error ?? NetworkError.unknown) + + switch finalError { case StorageError.objectNotFound: Log.warn(.cat, "Failing \(messageType) (\(job.id ?? -1)) due to missing interaction") @@ -108,7 +110,7 @@ public enum MessageSendJob: JobExecutor { Log.error(.cat, "Failed \(messageType) (\(job.id ?? -1)) due to invalid attachment state") } - return failure(job, (attachmentState.error ?? MessageSenderError.invalidMessage), true) + return failure(job, finalError, true) } /// If we have any pending (or failed) attachment uploads then we should create jobs for them and insert them into the @@ -171,9 +173,9 @@ public enum MessageSendJob: JobExecutor { var previousDeferralsMessage: String = "" switch details.destination { - case .closedGroup(let groupPublicKey) where groupPublicKey.starts(with: SessionId.Prefix.group.rawValue): + case .group(let publicKey) where publicKey.starts(with: SessionId.Prefix.group.rawValue): let deferalDuration: TimeInterval = 1 - let groupSessionId: SessionId = SessionId(.group, hex: groupPublicKey) + let groupSessionId: SessionId = SessionId(.group, hex: publicKey) let numGroupKeys: Int = (try? LibSession.numKeys(groupSessionId: groupSessionId, using: dependencies)) .defaulting(to: 0) let deferCount: Int = dependencies[singleton: .jobRunner].deferCount(for: job.id, of: job.variant) @@ -249,11 +251,8 @@ public enum MessageSendJob: JobExecutor { // Actual error handling switch (error, details.message) { - case (let senderError as MessageSenderError, _) where !senderError.isRetryable: - failure(job, error, true) - - case (SnodeAPIError.rateLimited, _): - failure(job, error, true) + case (is MessageError, _): failure(job, error, true) + case (SnodeAPIError.rateLimited, _): failure(job, error, true) case (SnodeAPIError.clockOutOfSync, _): Log.error(.cat, "\(originalSentTimestampMs != nil ? "Permanently Failing" : "Failing") to send \(messageType) (\(job.id ?? -1)) due to clock out of sync issue.") diff --git a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift index 0253242bc7..0527456be6 100644 --- a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift +++ b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift @@ -164,7 +164,7 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { ), using: dependencies ), - to: .closedGroup(groupPublicKey: groupSessionId.hexString), + to: .group(publicKey: groupSessionId.hexString), namespace: .groupMessages, interactionId: nil, attachments: nil, @@ -195,7 +195,7 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { response.allSatisfy({ subResponse in 200...299 ~= ((subResponse as? Network.BatchSubResponse)?.code ?? 400) }) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.invalidGroupUpdate("Failed to remove group member") } return () } @@ -235,6 +235,14 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { .filter(pendingRemovals.keys.contains(GroupMember.Columns.profileId)) .deleteAll(db) + pendingRemovals.keys.forEach { id in + db.addGroupMemberEvent( + profileId: id, + threadId: groupSessionId.hexString, + type: .deleted + ) + } + /// If we want to remove the messages sent by the removed members then do so and remove /// them from the swarm as well if !memberIdsToRemoveContent.isEmpty { diff --git a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift index e34c48694b..db62c6a2f8 100644 --- a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift @@ -40,143 +40,76 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { .isEmpty else { return deferred(job) } - // The Network.SOGS won't make any API calls if there is no entry for an OpenGroup - // in the database so we need to create a dummy one to retrieve the default room data - let defaultGroupId: String = OpenGroup.idFor(roomToken: "", server: Network.SOGS.defaultServer) - - dependencies[singleton: .storage].write { db in - guard try OpenGroup.exists(db, id: defaultGroupId) == false else { return } - - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "", - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: "", - userCount: 0, - infoUpdates: 0 - ) - .upserted(db) - } - - /// Try to retrieve the default rooms 8 times - dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> AuthenticationMethod in - try Authentication.with( - db, - server: Network.SOGS.defaultServer, - activeOnly: false, /// The record for the default rooms is inactive - using: dependencies - ) - } - .tryFlatMap { [dependencies] authMethod -> AnyPublisher<(ResponseInfoType, Network.SOGS.CapabilitiesAndRoomsResponse), Error> in - try Network.SOGS.preparedCapabilitiesAndRooms( - authMethod: authMethod, + Task { + do { + let request = try Network.SOGS.preparedCapabilitiesAndRooms( + authMethod: Network.SOGS.defaultAuthMethod, skipAuthentication: true, using: dependencies - ).send(using: dependencies) - } - .subscribe(on: scheduler, using: dependencies) - .receive(on: scheduler, using: dependencies) - .retry(8, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: - Log.info(.cat, "Successfully retrieved default Community rooms") - success(job, false) + ) + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SOGS.CapabilitiesAndRoomsResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + guard !Task.isCancelled else { return } + + /// Store the updated capabilities and schedule downloads for the room images (if they + /// are already downloaded then the job will just complete) + try await dependencies[singleton: .storage].writeAsync { db in + dependencies[singleton: .communityManager].handleCapabilities( + db, + capabilities: response.capabilities.data, + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey + ) + + response.rooms.data.forEach { info in + guard let imageId: String = info.imageId else { return } - case .failure(let error): - Log.error(.cat, "Failed to get default Community rooms due to error: \(error)") - failure(job, error, false) - } - }, - receiveValue: { info, response in - let defaultRooms: [OpenGroupManager.DefaultRoomInfo]? = dependencies[singleton: .storage].write { db -> [OpenGroupManager.DefaultRoomInfo] in - // Store the capabilities first - OpenGroupManager.handleCapabilities( + dependencies[singleton: .jobRunner].add( db, - capabilities: response.capabilities.data, - on: Network.SOGS.defaultServer + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .community( + imageId: imageId, + roomToken: info.token, + server: Network.SOGS.defaultServer, + skipAuthentication: true + ), + timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + ) + ), + canStartJob: true ) - - let existingImageIds: [String: String] = try OpenGroup - .filter(OpenGroup.Columns.server == Network.SOGS.defaultServer) - .filter(OpenGroup.Columns.imageId != nil) - .fetchAll(db) - .reduce(into: [:]) { result, next in result[next.id] = next.imageId } - let result: [OpenGroupManager.DefaultRoomInfo] = try response.rooms.data - .compactMap { room -> OpenGroupManager.DefaultRoomInfo? in - /// Try to insert an inactive version of the OpenGroup (use `insert` rather than - /// `save` as we want it to fail if the room already exists) - do { - return ( - room, - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: room.token, - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: room.name, - roomDescription: room.roomDescription, - imageId: room.imageId, - userCount: room.activeUsers, - infoUpdates: room.infoUpdates - ) - .inserted(db) - ) - } - catch { - return try OpenGroup - .fetchOne( - db, - id: OpenGroup.idFor( - roomToken: room.token, - server: Network.SOGS.defaultServer - ) - ) - .map { (room, $0) } - } - } - - /// Schedule the room image download (if it doesn't match out current one) - result.forEach { room, openGroup in - let openGroupId: String = OpenGroup.idFor(roomToken: room.token, server: Network.SOGS.defaultServer) - - guard - let imageId: String = room.imageId, - imageId != existingImageIds[openGroupId] || - openGroup.displayPictureOriginalUrl == nil - else { return } - - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .community( - imageId: imageId, - roomToken: room.token, - server: Network.SOGS.defaultServer, - skipAuthentication: true - ), - timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ) - ), - canStartJob: true - ) - } - - return result - } - - /// Update the `openGroupManager` cache to have the default rooms - dependencies.mutate(cache: .openGroupManager) { cache in - cache.setDefaultRoomInfo(defaultRooms ?? []) } } - ) + + /// Update the `CommunityManager` cache of room and capability data + await dependencies[singleton: .communityManager].updateRooms( + rooms: response.rooms.data, + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey, + areDefaultRooms: true + ) + Log.info(.cat, "Successfully retrieved default Community rooms") + + scheduler.schedule { + success(job, false) + } + } + catch { + /// We want to fail permanently here, otherwise we would just indefinitely retry (if the user opens the + /// "Join Community" screen that will kick off another job, otherwise this will automatically be rescheduled + /// on launch) + Log.error(.cat, "Failed to get default Community rooms due to error: \(error)") + scheduler.schedule { + failure(job, error, true) + } + } + } } public static func run(using dependencies: Dependencies) { diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index e7acb1067f..e2ad7f65e9 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -113,8 +113,7 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { displayPictureUpdate: .currentUserUpdateTo( url: displayPictureUrl.absoluteString, key: displayPictureEncryptionKey, - sessionProProof: dependencies.mutate(cache: .libSession) { $0.getCurrentUserProProof() }, - isReupload: true + type: .reupload ), using: dependencies ) @@ -173,8 +172,7 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { displayPictureUpdate: .currentUserUpdateTo( url: result.downloadUrl, key: result.encryptionKey, - sessionProProof: dependencies.mutate(cache: .libSession) { $0.getCurrentUserProProof() }, - isReupload: true + type: .reupload ), using: dependencies ) diff --git a/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift index 9196301bfc..5fcc7c708f 100644 --- a/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift @@ -33,10 +33,14 @@ public enum SendReadReceiptsJob: JobExecutor { return success(job, true) } - dependencies[singleton: .storage] - .readPublisher { db in try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) } - .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in - try MessageSender.preparedSend( + AnyPublisher + .lazy { () -> Network.PreparedRequest in + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: threadId, + using: dependencies + ) + + return try MessageSender.preparedSend( message: ReadReceipt( timestamps: details.timestampMsValues.map { UInt64($0) } ), @@ -47,8 +51,9 @@ public enum SendReadReceiptsJob: JobExecutor { authMethod: authMethod, onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - ).send(using: dependencies) + ) } + .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .sinkUntilComplete( diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 73f287b5a2..c46b00ded5 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -3,6 +3,7 @@ import Foundation import GRDB import SessionUtil +import SessionNetworkingKit import SessionUtilitiesKit // MARK: - Size Restrictions @@ -46,10 +47,8 @@ internal extension LibSessionCacheType { // The current users contact data is handled separately so exclude it if it's present (as that's // actually a bug) - let targetContactData: [String: ContactData] = try LibSession.extractContacts( - from: conf, - using: dependencies - ).filter { $0.key != userSessionId.hexString } + let targetContactData: [String: ContactData] = try extractContacts(from: conf) + .filter { $0.key != userSessionId.hexString } // Since we don't sync 100% of the data stored against the contact and profile objects we // need to only update the data we do have to ensure we don't overwrite anything that doesn't @@ -71,13 +70,24 @@ internal extension LibSessionCacheType { return .contactUpdateTo( url: displayPictureUrl, - key: displayPictureEncryptionKey, - contactProProof: getContanctProProof(for: sessionId) // TODO: double check if this is needed after Pro Proof is implemented + key: displayPictureEncryptionKey ) }(), nicknameUpdate: .set(to: data.profile.nickname), + proUpdate: { + guard let genIndexHashHex: String = profile.proGenIndexHashHex else { return .none } + + return .contactUpdate( + Profile.ProState( + profileFeatures: data.profile.proFeatures, + expiryUnixTimestampMs: data.profile.proExpiryUnixTimestampMs, + genIndexHashHex: genIndexHashHex + ) + ) + }(), profileUpdateTimestamp: data.profile.profileLastUpdated, cacheSource: .database, + currentUserSessionIds: [userSessionId.hexString], using: dependencies ) @@ -316,13 +326,16 @@ public extension LibSession { // want the extensions to trigger this as it can clog up their networking) if let updatedProfile: Profile = info.profile, + let newUrl: String = info.displayPictureUrl, + let newKey: Data = info.displayPictureEncryptionKey, dependencies[singleton: .appContext].isMainApp && ( - oldAvatarUrl != (info.displayPictureUrl ?? "") || - oldAvatarKey != (info.displayPictureEncryptionKey ?? Data()) + oldAvatarUrl != newUrl || + oldAvatarKey != newKey ) { dependencies[singleton: .displayPictureManager].scheduleDownload( - for: .user(updatedProfile) + for: .profile(id: updatedProfile.id, url: newUrl, encryptionKey: newKey), + timestamp: updatedProfile.profileLastUpdated ) } @@ -701,7 +714,7 @@ extension LibSession { fileprivate var profile: Profile? { guard let name: String = name else { return nil } - return Profile( + return Profile.with( id: id, name: name, nickname: nickname, @@ -804,11 +817,8 @@ internal struct ContactData { // MARK: - Convenience -internal extension LibSession { - static func extractContacts( - from conf: UnsafeMutablePointer?, - using dependencies: Dependencies - ) throws -> [String: ContactData] { +internal extension LibSessionCacheType { + func extractContacts(from conf: UnsafeMutablePointer?) throws -> [String: ContactData] { var infiniteLoopGuard: Int = 0 var result: [String: ContactData] = [:] var contact: contacts_contact = contacts_contact() @@ -827,13 +837,20 @@ internal extension LibSession { currentUserSessionId: userSessionId ) let displayPictureUrl: String? = contact.get(\.profile_pic.url, nullIfEmpty: true) + let proProofMetadata: LibSession.ProProofMetadata? = self.proProofMetadata( + threadId: contactId + ) let profileResult: Profile = Profile( id: contactId, name: contact.get(\.name), nickname: contact.get(\.nickname, nullIfEmpty: true), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), - profileLastUpdated: TimeInterval(contact.profile_updated) + profileLastUpdated: TimeInterval(contact.profile_updated), + blocksCommunityMessageRequests: nil, /// Not synced + proFeatures: SessionPro.ProfileFeatures(contact.profile_bitset), + proExpiryUnixTimestampMs: (proProofMetadata?.expiryUnixTimestampMs ?? 0), + proGenIndexHashHex: proProofMetadata?.genIndexHashHex ) let configResult: DisappearingMessagesConfiguration = DisappearingMessagesConfiguration( threadId: contactId, @@ -857,6 +874,19 @@ internal extension LibSession { } } +// MARK: - Convenience + +private extension Network.SessionPro.ProProof { + init?(profile: Profile) { + guard let genIndexHashHex: String = profile.proGenIndexHashHex else { return nil } + + self = Network.SessionPro.ProProof( + genIndexHash: Array(Data(hex: genIndexHashHex)), + expiryUnixTimestampMs: profile.proExpiryUnixTimestampMs + ) + } +} + // MARK: - C Conformance extension contacts_contact: CAccessible & CMutable {} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift index b45838b404..db4dc222f1 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift @@ -63,7 +63,11 @@ internal extension LibSessionCacheType { SessionThread.Columns.markedAsUnread.set(to: markedAsUnread), using: dependencies ) - db.addConversationEvent(id: threadId, type: .updated(.markedAsUnread(markedAsUnread))) + db.addConversationEvent( + id: threadId, + variant: threadInfo.variant, + type: .updated(.markedAsUnread(markedAsUnread)) + ) } // If the device has a more recent read interaction then return the info so we can @@ -171,6 +175,14 @@ internal extension LibSession { case .markedAsUnread(let unread): oneToOne.unread = unread + + case .proProofMetadata(let metadata): + oneToOne.has_pro_gen_index_hash = (metadata != nil) + + guard let metadata: ProProofMetadata = metadata else { return } + + oneToOne.set(\.pro_gen_index_hash, to: Data(hex: metadata.genIndexHashHex)) + oneToOne.pro_expiry_unix_ts_ms = metadata.expiryUnixTimestampMs } } convo_info_volatile_set_1to1(conf, &oneToOne) @@ -195,6 +207,8 @@ internal extension LibSession { case .markedAsUnread(let unread): legacyGroup.unread = unread + + case .proProofMetadata: break /// Unsupported } } convo_info_volatile_set_legacy_group(conf, &legacyGroup) @@ -228,6 +242,8 @@ internal extension LibSession { case .markedAsUnread(let unread): community.unread = unread + + case .proProofMetadata: break /// Unsupported } } convo_info_volatile_set_community(conf, &community) @@ -252,6 +268,8 @@ internal extension LibSession { case .markedAsUnread(let unread): group.unread = unread + + case .proProofMetadata: break /// Unsupported } } convo_info_volatile_set_group(conf, &group) @@ -482,6 +500,59 @@ public extension LibSession.Cache { return group.last_read } } + + func proProofMetadata(threadId: String) -> LibSession.ProProofMetadata? { + /// If it's the current user then source from the `proConfig` instead + guard threadId != userSessionId.hexString else { + return proConfig.map { proConfig in + return LibSession.ProProofMetadata( + genIndexHashHex: proConfig.proProof.genIndexHash.toHexString(), + expiryUnixTimestampMs: proConfig.proProof.expiryUnixTimestampMs + ) + } + } + + /// If we don't have a config then just assume the user is non-pro + guard case .convoInfoVolatile(let conf) = config(for: .convoInfoVolatile, sessionId: userSessionId) else { + return nil + } + + switch try? SessionId.Prefix(from: threadId) { + case .standard: + var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() + guard + var cThreadId: [CChar] = threadId.cString(using: .utf8), + convo_info_volatile_get_1to1(conf, &oneToOne, &cThreadId) + else { + LibSessionError.clear(conf) + return nil + } + guard oneToOne.has_pro_gen_index_hash else { return nil } + + return LibSession.ProProofMetadata( + genIndexHashHex: oneToOne.getHex(\.pro_gen_index_hash), + expiryUnixTimestampMs: oneToOne.pro_expiry_unix_ts_ms + ) + + case .blinded15, .blinded25: + var blinded: convo_info_volatile_blinded_1to1 = convo_info_volatile_blinded_1to1() + guard + var cThreadId: [CChar] = threadId.cString(using: .utf8), + convo_info_volatile_get_blinded_1to1(conf, &blinded, &cThreadId) + else { + LibSessionError.clear(conf) + return nil + } + guard blinded.has_pro_gen_index_hash else { return nil } + + return LibSession.ProProofMetadata( + genIndexHashHex: blinded.getHex(\.pro_gen_index_hash), + expiryUnixTimestampMs: blinded.pro_expiry_unix_ts_ms + ) + + default: return nil /// Other conversation types don't have `ProProofMetadata` + } + } } // MARK: State Access @@ -490,26 +561,32 @@ public extension LibSessionCacheType { func timestampAlreadyRead( threadId: String, threadVariant: SessionThread.Variant, - timestampMs: Int64, + timestampMs: UInt64, openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Bool { - let lastReadTimestampMs = conversationLastRead( + let lastReadTimestampMs: Int64? = conversationLastRead( threadId: threadId, threadVariant: threadVariant, openGroupUrlInfo: openGroupUrlInfo ) - return ((lastReadTimestampMs ?? 0) >= timestampMs) + return ((lastReadTimestampMs ?? 0) >= Int64(timestampMs)) } } // MARK: - VolatileThreadInfo public extension LibSession { + struct ProProofMetadata { + let genIndexHashHex: String + let expiryUnixTimestampMs: UInt64 + } + struct VolatileThreadInfo { enum Change { case markedAsUnread(Bool) case lastReadTimestampMs(Int64) + case proProofMetadata(ProProofMetadata?) } let threadId: String @@ -626,6 +703,7 @@ public extension LibSession { var community: convo_info_volatile_community = convo_info_volatile_community() var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() var group: convo_info_volatile_group = convo_info_volatile_group() + var blinded: convo_info_volatile_blinded_1to1 = convo_info_volatile_blinded_1to1() let convoIterator: OpaquePointer = convo_info_volatile_iterator_new(conf) while !convo_info_volatile_iterator_done(convoIterator) { @@ -638,7 +716,15 @@ public extension LibSession { variant: .contact, changes: [ .markedAsUnread(oneToOne.unread), - .lastReadTimestampMs(oneToOne.last_read) + .lastReadTimestampMs(oneToOne.last_read), + .proProofMetadata({ + guard oneToOne.has_pro_gen_index_hash else { return nil } + + return ProProofMetadata( + genIndexHashHex: oneToOne.getHex(\.pro_gen_index_hash), + expiryUnixTimestampMs: oneToOne.pro_expiry_unix_ts_ms + ) + }()) ] ) ) @@ -689,6 +775,26 @@ public extension LibSession { ) ) } + else if convo_info_volatile_it_is_blinded_1to1(convoIterator, &blinded) { + result.append( + VolatileThreadInfo( + threadId: blinded.get(\.blinded_session_id), + variant: .contact, + changes: [ + .markedAsUnread(blinded.unread), + .lastReadTimestampMs(blinded.last_read), + .proProofMetadata({ + guard blinded.has_pro_gen_index_hash else { return nil } + + return ProProofMetadata( + genIndexHashHex: blinded.getHex(\.pro_gen_index_hash), + expiryUnixTimestampMs: blinded.pro_expiry_unix_ts_ms + ) + }()) + ] + ) + ) + } else { Log.error(.libSession, "Ignoring unknown conversation type when iterating through volatile conversation info update") } @@ -731,3 +837,4 @@ extension convo_info_volatile_1to1: CAccessible & CMutable {} extension convo_info_volatile_community: CAccessible & CMutable {} extension convo_info_volatile_legacy_group: CAccessible & CMutable {} extension convo_info_volatile_group: CAccessible & CMutable {} +extension convo_info_volatile_blinded_1to1: CAccessible & CMutable {} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift index 82cdc5060c..49a33278aa 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift @@ -51,15 +51,14 @@ internal extension LibSessionCacheType { func handleGroupInfoUpdate( _ db: ObservingDatabase, in config: LibSession.Config?, - groupSessionId: SessionId, - serverTimestampMs: Int64 + groupSessionId: SessionId ) throws { guard configNeedsDump(config) else { return } guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject(wanted: .groupInfo, got: config) } - // If the group is destroyed then mark the group as kicked in the USER_GROUPS config and remove + // If the group is destroyed then mark the group as destroyed in the USER_GROUPS config and remove // the group data (want to keep the group itself around because the UX of conversations randomly // disappearing isn't great) - no other changes matter and this can't be reversed guard !groups_info_is_destroyed(conf) else { @@ -74,6 +73,13 @@ internal extension LibSessionCacheType { ], using: dependencies ) + + /// Notify of being marked as destroyed + db.addConversationEvent( + id: groupSessionId.hexString, + variant: .group, + type: .updated(.markedAsDestroyed) + ) return } @@ -133,12 +139,17 @@ internal extension LibSessionCacheType { // Emit events if existingGroup?.name != groupName { - db.addConversationEvent(id: groupSessionId.hexString, type: .updated(.displayName(groupName))) + db.addConversationEvent( + id: groupSessionId.hexString, + variant: .group, + type: .updated(.displayName(groupName)) + ) } - if existingGroup?.groupDescription == groupDesc { + if existingGroup?.groupDescription != groupDesc { db.addConversationEvent( id: groupSessionId.hexString, + variant: .group, type: .updated(.description(groupDesc)) ) } @@ -152,7 +163,7 @@ internal extension LibSessionCacheType { shouldBeUnique: true, details: DisplayPictureDownloadJob.Details( target: .group(id: groupSessionId.hexString, url: url, encryptionKey: key), - timestamp: TimeInterval(Double(serverTimestampMs) / 1000) + timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) ) ), canStartJob: true @@ -184,11 +195,7 @@ internal extension LibSessionCacheType { // Check if the user is an admin in the group var messageHashesToDelete: Set = [] - let isAdmin: Bool = ((try? ClosedGroup - .filter(id: groupSessionId.hexString) - .select(.groupIdentityPrivateKey) - .asRequest(of: Data.self) - .fetchOne(db)) != nil) + let isAdmin: Bool = isAdmin(groupSessionId: groupSessionId) // If there is a `delete_before` setting then delete all messages before the provided timestamp let deleteBeforeTimestamp: Int64 = groups_info_get_delete_before(conf) @@ -233,17 +240,12 @@ internal extension LibSessionCacheType { let attachDeleteBeforeTimestamp: Int64 = groups_info_get_attach_delete_before(conf) if attachDeleteBeforeTimestamp > 0 { - let interactionInfo: [InteractionInfo] = (try? Interaction - .filter(Interaction.Columns.threadId == groupSessionId.hexString) - .filter(Interaction.Columns.timestampMs < (TimeInterval(attachDeleteBeforeTimestamp) * 1000)) - .joining( - required: Interaction.interactionAttachments.joining( - required: InteractionAttachment.attachment - .filter(Attachment.Columns.variant != Attachment.Variant.voiceMessage) - ) + let interactionInfo: [Interaction.VariantInfo] = (try? SessionThread + .interactionInfoWithAttachments( + threadId: groupSessionId.hexString, + beforeTimestampMs: Int64(floor(TimeInterval(attachDeleteBeforeTimestamp) * 1000)), + attachmentVariants: [.standard] ) - .select(.id, .serverHash) - .asRequest(of: InteractionInfo.self) .fetchAll(db)) .defaulting(to: []) let interactionIdsToRemove: Set = Set(interactionInfo.map { $0.id }) @@ -289,7 +291,6 @@ internal extension LibSessionCacheType { // send a fire-and-forget API call to delete the messages from the swarm if isAdmin && !messageHashesToDelete.isEmpty { (try? Authentication.with( - db, swarmPublicKey: groupSessionId.hexString, using: dependencies )).map { authMethod in @@ -361,12 +362,17 @@ internal extension LibSession { groups_info_set_description(conf, &cGroupDesc) if currentGroupName != group.name { - db.addConversationEvent(id: group.threadId, type: .updated(.displayName(group.name))) + db.addConversationEvent( + id: group.threadId, + variant: .group, + type: .updated(.displayName(group.name)) + ) } if currentGroupDesc != group.groupDescription { db.addConversationEvent( id: group.threadId, + variant: .group, type: .updated(.description(group.groupDescription)) ) } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift index dfd4c4c62e..ec7e6a9bd6 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift @@ -204,6 +204,55 @@ internal extension LibSession { // MARK: - State Accses public extension LibSession.Cache { + func latestGroupKey(groupSessionId: SessionId) throws -> [UInt8] { + guard let config: LibSession.Config = config(for: .groupKeys, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) + } + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) + } + + let result: span_u8 = groups_keys_group_enc_key(conf); + + guard result.size > 0 else { throw CryptoError.invalidKey } + + return Array(UnsafeBufferPointer(start: result.data, count: result.size)) + } + + func allActiveGroupKeys(groupSessionId: SessionId) throws -> [[UInt8]] { + guard let config: LibSession.Config = config(for: .groupKeys, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) + } + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) + } + + /// Get the number of active keys first, if there aren't any then no need to allocate anything + let activeKeys: Int = groups_keys_size(conf) + + guard activeKeys > 0 else { return [] } + + let destBuffer = UnsafeMutableBufferPointer.allocate(capacity: activeKeys) + defer { destBuffer.deallocate() } + + destBuffer.initialize(repeating: span_u8()) + + let numKeys: Int = groups_keys_get_keys(conf, 0, destBuffer.baseAddress, activeKeys) + var keys: [[UInt8]] = [] + keys.reserveCapacity(numKeys) + + for i in 0.. Bool { guard case .groupKeys(let conf, _, _) = config(for: .groupKeys, sessionId: groupSessionId) else { return false diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift index f8459b524a..74bc5f673c 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift @@ -51,9 +51,9 @@ internal extension LibSessionCacheType { .asSet() // Add in any new members and remove any removed members - try updatedMembers - .subtracting(existingMembers) - .forEach { try $0.upsert(db) } + let newMembers: Set = updatedMembers.subtracting(existingMembers) + let removedMembers: Set = existingMembers.subtracting(updatedMembers) + try newMembers.forEach { try $0.upsert(db) } try GroupMember .filter(GroupMember.Columns.groupId == groupSessionId.hexString) @@ -68,6 +68,38 @@ internal extension LibSessionCacheType { ) .deleteAll(db) + // Notify of any member/role changes + newMembers.forEach { member in + db.addGroupMemberEvent( + profileId: member.profileId, + threadId: groupSessionId.hexString, + type: .created + ) + } + + removedMembers.forEach { member in + db.addGroupMemberEvent( + profileId: member.profileId, + threadId: groupSessionId.hexString, + type: .deleted + ) + } + + updatedMembers.forEach { member in + guard + let existingMember: GroupMember = existingMembers.first(where: { $0.profileId == member.profileId }), ( + existingMember.role != member.role || + existingMember.roleStatus != member.roleStatus + ) + else { return } + + db.addGroupMemberEvent( + profileId: member.profileId, + threadId: groupSessionId.hexString, + type: .updated(.role(role: member.role, status: member.roleStatus)) + ) + } + // Schedule a job to process the removals if (try? LibSession.extractPendingRemovals(from: conf, groupSessionId: groupSessionId))?.isEmpty == false { dependencies[singleton: .jobRunner].add( @@ -116,6 +148,11 @@ internal extension LibSessionCacheType { status: .accepted, in: config ) + db.addGroupMemberEvent( + profileId: userSessionId.hexString, + threadId: groupSessionId.hexString, + type: .updated(.role(role: .admin, status: .accepted)) + ) } // If there were members then also extract and update the profile information for the members @@ -132,8 +169,10 @@ internal extension LibSessionCacheType { db, publicKey: profile.id, displayNameUpdate: .contactUpdate(profile.name), - displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), + displayPictureUpdate: .contactUpdateTo(profile, fallback: .none), + proUpdate: .none, /// Syncing group member pro state is not supported (changes come from contacts or messages) profileUpdateTimestamp: profile.profileLastUpdated, + currentUserSessionIds: [userSessionId.hexString], using: dependencies ) } @@ -517,7 +556,7 @@ internal extension LibSession { } result.append( - Profile( + Profile.with( id: member.get(\.session_id), name: member.get(\.name), nickname: nil, diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift deleted file mode 100644 index c79d45b035..0000000000 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtil -import SessionUtilitiesKit - -// MARK: - Character Limits - -public extension LibSession { - static var CharacterLimit: Int { 2000 } - static var ProCharacterLimit: Int { 10000 } - static var PinnedConversationLimit: Int { 5 } - - static func numberOfCharactersLeft(for content: String, isSessionPro: Bool) -> Int { - return ((isSessionPro ? ProCharacterLimit : CharacterLimit) - content.utf16.count) - } -} - -// MARK: - Session Pro -// TODO: Implementation - -public extension LibSessionCacheType { - var isSessionPro: Bool { - guard dependencies[feature: .sessionProEnabled] else { return false } - return [ .active, .refunding ].contains(dependencies[feature: .mockCurrentUserSessionProState]) - } - - func validateProProof(for message: Message?) -> Bool { - guard let message = message, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .allUsersSessionPro] - } - - func validateProProof(for profile: Profile?) -> Bool { - guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .allUsersSessionPro] - } - - func validateSessionProState(for threadId: String?) -> Bool { - guard let threadId = threadId, dependencies[feature: .sessionProEnabled] else { return false } - let threadVariant = dependencies[singleton: .storage].read { db in - try SessionThread - .select(SessionThread.Columns.variant) - .filter(id: threadId) - .asRequest(of: SessionThread.Variant.self) - .fetchOne(db) - } - guard threadVariant != .community else { return false } - if threadId == dependencies[cache: .general].sessionId.hexString { - return [ .active, .refunding ].contains(dependencies[feature: .mockCurrentUserSessionProState]) - } else { - return dependencies[feature: .allUsersSessionPro] - } - } - - func shouldShowProBadge(for profile: Profile?) -> Bool { - guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } - return ( - dependencies[feature: .allUsersSessionPro] && - dependencies[feature: .messageFeatureProBadge] || - (profile.showProBadge == true) - ) - } - - func getCurrentUserProProof() -> String? { - guard isSessionPro else { - return nil - } - return "" - } - - func getContanctProProof(for sessionId: String) -> String? { - guard dependencies[feature: .allUsersSessionPro] else { - return nil - } - return "" - } -} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index b34b915250..ca80a098b2 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -139,6 +139,7 @@ internal extension LibSession { db.addEvent( ConversationEvent( id: thread.id, + variant: thread.variant, change: .pinnedPriority( thread.pinnedPriority .map { Int32($0 == 0 ? LibSession.visiblePriority : max($0, 1)) } @@ -509,10 +510,10 @@ public extension LibSession.Cache { return SessionThread.displayName( threadId: threadId, variant: threadVariant, - closedGroupName: finalClosedGroupName, - openGroupName: finalOpenGroupName, + groupName: finalClosedGroupName, + communityName: finalOpenGroupName, isNoteToSelf: (threadId == userSessionId.hexString), - ignoringNickname: false, + ignoreNickname: false, profile: finalProfile ) } @@ -757,12 +758,12 @@ public extension LibSession.Cache { visibleMessage: VisibleMessage? ) -> Profile? { // FIXME: Once `libSession` manages unsynced "Profile" data we should source this from there - /// Extract the `displayName` directly from the `VisibleMessage` if available and it was sent by the desired contact + /// Extract the `displayName` directly from the `VisibleMessage` if available as it was sent by the desired contact let displayNameInMessage: String? = (visibleMessage?.sender != contactId ? nil : visibleMessage?.profile?.displayName?.nullIfEmpty ) let profileLastUpdatedInMessage: TimeInterval? = visibleMessage?.profile?.updateTimestampSeconds - let fallbackProfile: Profile? = displayNameInMessage.map { Profile(id: contactId, name: $0) } + let fallbackProfile: Profile? = displayNameInMessage.map { Profile.with(id: contactId, name: $0) } guard var cContactId: [CChar] = contactId.cString(using: .utf8) else { return fallbackProfile @@ -780,6 +781,8 @@ public extension LibSession.Cache { let displayPic: user_profile_pic = user_profile_get_pic(conf) let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true) let lastUpdated: TimeInterval = max((profileLastUpdatedInMessage ?? 0), TimeInterval(user_profile_get_profile_updated(conf))) + let proConfig: SessionPro.ProConfig? = self.proConfig + let proProfileFeatures: SessionPro.ProfileFeatures = SessionPro.ProfileFeatures(user_profile_get_pro_features(conf)) return Profile( id: contactId, @@ -787,7 +790,11 @@ public extension LibSession.Cache { nickname: nil, displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : displayPic.get(\.key)), - profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil) + profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil), + blocksCommunityMessageRequests: !self.get(.checkForCommunityMessageRequests), + proFeatures: proProfileFeatures, + proExpiryUnixTimestampMs: (proConfig?.proProof.expiryUnixTimestampMs ?? 0), + proGenIndexHashHex: proConfig.map { $0.proProof.genIndexHash.toHexString() } ) } @@ -817,7 +824,12 @@ public extension LibSession.Cache { nickname: nil, displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : member.get(\.profile_pic.key)), - profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil) + profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil), + blocksCommunityMessageRequests: visibleMessage?.profile?.blocksCommunityMessageRequests, + /// Group members don't sync pro status so try to extract from the provided message + proFeatures: (visibleMessage?.proProfileFeatures ?? .none), + proExpiryUnixTimestampMs: (visibleMessage?.proProof?.expiryUnixTimestampMs ?? 0), + proGenIndexHashHex: visibleMessage?.proProof.map { $0.genIndexHash.toHexString() } ) } @@ -835,15 +847,29 @@ public extension LibSession.Cache { let displayPictureUrl: String? = contact.get(\.profile_pic.url, nullIfEmpty: true) let lastUpdated: TimeInterval = max((profileLastUpdatedInMessage ?? 0), TimeInterval(contact.get( \.profile_updated))) + let proProfileFeatures: SessionPro.ProfileFeatures = SessionPro.ProfileFeatures(contact.profile_bitset) + let proProofMetadata: LibSession.ProProofMetadata? = proProofMetadata(threadId: contactId) - /// The `displayNameInMessage` value is likely newer than the `name` value in the config so use that if available + /// The `displayNameInMessage` and other values contained within the message are likely newer than the values stored + /// in the config so use those if available return Profile( id: contactId, name: (displayNameInMessage ?? contact.get(\.name)), nickname: contact.get(\.nickname, nullIfEmpty: true), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), - profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil) + profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil), + blocksCommunityMessageRequests: visibleMessage?.profile?.blocksCommunityMessageRequests, + proFeatures: (visibleMessage?.proProfileFeatures ?? proProfileFeatures), + proExpiryUnixTimestampMs: ( + visibleMessage?.proProof?.expiryUnixTimestampMs ?? + proProofMetadata?.expiryUnixTimestampMs ?? + 0 + ), + proGenIndexHashHex: ( + (visibleMessage?.proProof?.genIndexHash).map { $0.toHexString() } ?? + proProofMetadata?.genIndexHashHex + ) ) } @@ -989,12 +1015,12 @@ public protocol LibSessionRespondingViewController { var isConversationList: Bool { get } func isConversation(in threadIds: [String]) -> Bool - func forceRefreshIfNeeded() + @MainActor func forceRefreshIfNeeded() } public extension LibSessionRespondingViewController { var isConversationList: Bool { false } func isConversation(in threadIds: [String]) -> Bool { return false } - func forceRefreshIfNeeded() {} + @MainActor func forceRefreshIfNeeded() {} } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift index ac14439634..b1d7ef957c 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift @@ -61,7 +61,7 @@ internal extension LibSession { guard let groupIdentityKeyPair: KeyPair = dependencies[singleton: .crypto].generate(.ed25519KeyPair()), !dependencies[cache: .general].ed25519SecretKey.isEmpty - else { throw MessageSenderError.noKeyPair } + else { throw CryptoError.missingUserSecretKey } // Prep the relevant details (reduce the members to ensure we don't accidentally insert duplicates) let groupSessionId: SessionId = SessionId(.group, publicKey: groupIdentityKeyPair.publicKey) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift index 81bd31ff30..d2514f0458 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift @@ -39,7 +39,8 @@ internal extension LibSession { internal extension LibSessionCacheType { func handleUserGroupsUpdate( _ db: ObservingDatabase, - in config: LibSession.Config? + in config: LibSession.Config?, + oldState: [ObservableKey: Any] ) throws { guard configNeedsDump(config) else { return } guard case .userGroups(let conf) = config else { @@ -71,7 +72,7 @@ internal extension LibSessionCacheType { // Add any new communities (via the OpenGroupManager) extractedUserGroups.communities.forEach { community in - let successfullyAddedGroup: Bool = dependencies[singleton: .openGroupManager].add( + let successfullyAddedGroup: Bool = dependencies[singleton: .communityManager].add( db, roomToken: community.roomToken, server: community.server, @@ -82,7 +83,7 @@ internal extension LibSessionCacheType { if successfullyAddedGroup { db.afterCommit { [dependencies] in - dependencies[singleton: .openGroupManager].performInitialRequestsAfterAdd( + dependencies[singleton: .communityManager].performInitialRequestsAfterAdd( queue: DispatchQueue.global(qos: .userInitiated), successfullyAddedGroup: successfullyAddedGroup, roomToken: community.roomToken, @@ -112,6 +113,7 @@ internal extension LibSessionCacheType { if existingInfo.pinnedPriority != community.priority { db.addConversationEvent( id: community.threadId, + variant: .community, type: .updated(.pinnedPriority(community.priority)) ) } @@ -212,7 +214,11 @@ internal extension LibSessionCacheType { } if existingLegacyGroups[group.id]?.name != name { - db.addConversationEvent(id: group.id, type: .updated(.displayName(name))) + db.addConversationEvent( + id: group.id, + variant: .legacyGroup, + type: .updated(.displayName(name)) + ) } // Update the members @@ -307,6 +313,7 @@ internal extension LibSessionCacheType { db.addConversationEvent( id: group.id, + variant: .legacyGroup, type: .updated(.pinnedPriority(group.priority ?? LibSession.hiddenPriority)) ) } @@ -417,10 +424,15 @@ internal extension LibSessionCacheType { if existingInfo.pinnedPriority != group.priority { db.addConversationEvent( id: group.groupSessionId, + variant: .group, type: .updated(.pinnedPriority(group.priority)) ) } } + + if oldState[.groupInfo(groupId: group.groupSessionId)] as? LibSession.GroupInfo != group { + db.addEvent(group, forKey: .groupInfo(groupId: group.groupSessionId)) + } } // Remove any groups which are no longer in the config @@ -1043,6 +1055,47 @@ public extension LibSession.Cache { return ugroups_group_is_destroyed(&userGroup) } + + func authData(groupSessionId: SessionId) -> GroupAuthData { + var group: ugroups_group_info = ugroups_group_info() + + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8), + user_groups_get_group(conf, &group, &cGroupId) + else { return GroupAuthData(groupIdentityPrivateKey: nil, authData: nil) } + + return GroupAuthData( + groupIdentityPrivateKey: (!group.have_secretkey ? nil : group.get(\.secretkey, nullIfEmpty: true)), + authData: (!group.have_auth_data ? nil : group.get(\.auth_data, nullIfEmpty: true)) + ) + } + + func groupInfo(for groupIds: Set) -> [LibSession.GroupInfo?] { + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { return [] } + + + return groupIds.map { groupId -> LibSession.GroupInfo? in + var group: ugroups_group_info = ugroups_group_info() + + guard + var cGroupId: [CChar] = groupId.cString(using: .utf8), + user_groups_get_group(conf, &group, &cGroupId) + else { return nil } + + return LibSession.GroupInfo( + groupSessionId: group.get(\.id), + groupIdentityPrivateKey: (!group.have_secretkey ? nil : group.get(\.secretkey, nullIfEmpty: true)), + name: group.get(\.name), + authData: (!group.have_auth_data ? nil : group.get(\.auth_data, nullIfEmpty: true)), + priority: group.priority, + joinedAt: TimeInterval(group.joined_at), + invited: group.invited, + wasKickedFromGroup: ugroups_group_is_kicked(&group), + wasGroupDestroyed: ugroups_group_is_destroyed(&group) + ) + } + } } // MARK: - Convenience @@ -1191,7 +1244,7 @@ public extension LibSession { // MARK: - GroupInfo public extension LibSession { - struct GroupInfo { + struct GroupInfo: Sendable, Equatable, Hashable { let groupSessionId: String let groupIdentityPrivateKey: Data? let name: String @@ -1257,6 +1310,6 @@ public extension LibSession { // MARK: - C Conformance -extension ugroups_community_info: CAccessible & CMutable {} -extension ugroups_legacy_group_info: CAccessible & CMutable {} -extension ugroups_group_info: CAccessible & CMutable {} +extension ugroups_community_info: @retroactive CAccessible & CMutable {} +extension ugroups_legacy_group_info: @retroactive CAccessible & CMutable {} +extension ugroups_group_info: @retroactive CAccessible & CMutable {} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index c8d324f46d..9e5d109d7d 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -3,6 +3,7 @@ import Foundation import GRDB import SessionUtil +import SessionNetworkingKit import SessionUtilitiesKit // MARK: - LibSession @@ -56,13 +57,26 @@ internal extension LibSessionCacheType { return .currentUserUpdateTo( url: displayPictureUrl, key: displayPictureEncryptionKey, - sessionProProof: getCurrentUserProProof(), // TODO: double check if this is needed after Pro Proof is implemented - isReupload: false + type: .config + ) + }(), + proUpdate: { + guard let proConfig: SessionPro.ProConfig = self.proConfig else { return .none } + + let profileFeatures: SessionPro.ProfileFeatures = SessionPro.ProfileFeatures(user_profile_get_pro_features(conf)) + + return .currentUserUpdate( + Profile.ProState( + profileFeatures: profileFeatures, + expiryUnixTimestampMs: proConfig.proProof.expiryUnixTimestampMs, + genIndexHashHex: proConfig.proProof.genIndexHash.toHexString() + ) ) }(), profileUpdateTimestamp: profileLastUpdateTimestamp, cacheSource: .value((oldState[.profile(userSessionId.hexString)] as? Profile), fallback: .database), suppressUserProfileConfigUpdate: true, + currentUserSessionIds: [userSessionId.hexString], using: dependencies ) @@ -142,6 +156,19 @@ internal extension LibSessionCacheType { db.addContactEvent(id: userSessionId.hexString, change: .isApproved(true)) db.addContactEvent(id: userSessionId.hexString, change: .didApproveMe(true)) } + + /// If the `proAccessExpiryTimestampMs` value was updated then we need to take the larger of the two + let oldProAccessExpiryTimestampMs: UInt64 = (oldState[.proAccessExpiryUpdated] as? UInt64 ?? 0) + let proAccessExpiryTimestampMs: UInt64 = user_profile_get_pro_access_expiry_ms(conf) + + if oldProAccessExpiryTimestampMs > proAccessExpiryTimestampMs { + updateProAccessExpiryTimestampMs(oldProAccessExpiryTimestampMs) + } + + // Update the SessionProManager with these changes + db.afterCommit { [sessionProManager = dependencies[singleton: .sessionProManager]] in + Task { await sessionProManager.updateWithLatestFromUserConfig() } + } } } @@ -213,10 +240,30 @@ public extension LibSession.Cache { return String(cString: profileNamePtr) } + var proConfig: SessionPro.ProConfig? { + var cProConfig: pro_pro_config = pro_pro_config() + + guard + case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId), + user_profile_get_pro_config(conf, &cProConfig) + else { return nil } + + return SessionPro.ProConfig(cProConfig) + } + + var proAccessExpiryTimestampMs: UInt64 { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return 0 } + + return user_profile_get_pro_access_expiry_ms(conf) + } + + /// This function should not be called outside of the `Profile.updateIfNeeded` function to avoid duplicating changes and events, + /// as a result this function doesn't emit profile change events itself (use `Profile.updateLocal` instead) func updateProfile( displayName: Update, displayPictureUrl: Update, displayPictureEncryptionKey: Update, + proProfileFeatures: Update, isReuploadProfilePicture: Bool ) throws { guard let config: LibSession.Config = config(for: .userProfile, sessionId: userSessionId) else { @@ -232,6 +279,7 @@ public extension LibSession.Cache { let oldDisplayPic: user_profile_pic = user_profile_get_pic(conf) let oldDisplayPictureUrl: String? = oldDisplayPic.get(\.url, nullIfEmpty: true) let oldDisplayPictureKey: Data? = oldDisplayPic.get(\.key, nullIfEmpty: true) + let oldProProfileFeatures: SessionPro.ProfileFeatures = SessionPro.ProfileFeatures(user_profile_get_pro_features(conf)) /// Either assign the updated profile pic, or sent a blank profile pic (to remove the current one) /// @@ -250,18 +298,9 @@ public extension LibSession.Cache { } try LibSessionError.throwIfNeeded(conf) - - /// Add a pending observation to notify any observers of the change once it's committed - addEvent( - key: .profile(userSessionId.hexString), - value: ProfileEvent( - id: userSessionId.hexString, - change: .displayPictureUrl(displayPictureUrl.or(oldDisplayPictureUrl)) - ) - ) } - /// Update the nam + /// Update the name /// /// **Note:** Setting the name (even if it hasn't changed) currently results in a timestamp change so only do this if it was /// changed (this will be fixed in `libSession v1.5.8`) @@ -271,13 +310,35 @@ public extension LibSession.Cache { }() user_profile_set_name(conf, &cUpdatedName) try LibSessionError.throwIfNeeded(conf) - - /// Add a pending observation to notify any observers of the change once it's committed - addEvent( - key: .profile(userSessionId.hexString), - value: ProfileEvent(id: userSessionId.hexString, change: .name(displayName.or(oldNameFallback))) - ) } + + /// Update the pro features + /// + /// **Note:** Setting the name (even if it hasn't changed) currently results in a timestamp change so only do this if it was + /// changed (this will be fixed in `libSession v1.5.8`) + if proProfileFeatures.or(.none) != oldProProfileFeatures { + user_profile_set_pro_badge(conf, proProfileFeatures.or(.none).contains(.proBadge)) + user_profile_set_animated_avatar(conf, proProfileFeatures.or(.none).contains(.animatedAvatar)) + } + } + + func updateProConfig(proConfig: SessionPro.ProConfig) { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return } + + var cProConfig: pro_pro_config = proConfig.libSessionValue + user_profile_set_pro_config(conf, &cProConfig) + } + + func removeProConfig() { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return } + + user_profile_remove_pro_config(conf) + } + + func updateProAccessExpiryTimestampMs(_ proAccessExpiryTimestampMs: UInt64) { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return } + + user_profile_set_pro_access_expiry_ms(conf, proAccessExpiryTimestampMs) } } @@ -293,4 +354,4 @@ public extension LibSession { // MARK: - C Conformance -extension user_profile_pic: CAccessible & CMutable {} +extension user_profile_pic: @retroactive CAccessible & CMutable {} diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index fe06365dc8..73fa08dba9 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -543,7 +543,7 @@ public extension LibSession { /// Count the OneToOne conversations (visible contacts) if case .contacts(let conf) = configStore[userSessionId, .contacts], - let contactData: [String: ContactData] = try? LibSession.extractContacts(from: conf, using: dependencies) + let contactData: [String: ContactData] = try? extractContacts(from: conf) { let visibleContacts: [ContactData] = contactData.values .filter { $0.priority >= LibSession.visiblePriority } @@ -815,61 +815,69 @@ public extension LibSession { .reduce([], +) } + public func currentConfigState( + swarmPublicKey: String, + variants: Set + ) throws -> [ConfigDump.Variant: [ObservableKey: Any]] { + guard !variants.isEmpty else { return [:] } + guard !swarmPublicKey.isEmpty else { throw MessageError.invalidConfigMessageHandling } + + return try variants.reduce(into: [:]) { result, variant in + let sessionId: SessionId = SessionId(hex: swarmPublicKey, dumpVariant: variant) + + switch configStore[sessionId, variant] { + case .userProfile: + result[variant] = [ + .profile(userSessionId.hexString): profile, + .setting(.checkForCommunityMessageRequests): get(.checkForCommunityMessageRequests), + .proAccessExpiryUpdated: proAccessExpiryTimestampMs + ] + + case .contacts(let conf): + result[variant] = try extractContacts(from: conf).reduce(into: [:]) { result, next in + result[.contact(next.key)] = next.value.contact + result[.profile(next.key)] = next.value.profile + } + + case .userGroups(let conf): + let extractedUserGroups: ExtractedUserGroups = try extractUserGroups(from: conf, using: dependencies) + var userGroupEvents: [ObservableKey: Any] = [:] + + extractedUserGroups.groups.forEach { info in + userGroupEvents[.groupInfo(groupId: info.groupSessionId)] = info + } + + result[variant] = userGroupEvents + + default: break + } + } + } + public func mergeConfigMessages( swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo], - afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? - ) throws -> [MergeResult] { - guard !messages.isEmpty else { return [] } - guard !swarmPublicKey.isEmpty else { throw MessageReceiverError.noThread } + messages: [ConfigMessageReceiveJob.Details.MessageInfo] + ) throws -> [ConfigDump.Variant: Int64] { + guard !messages.isEmpty else { return [:] } + guard !swarmPublicKey.isEmpty else { throw MessageError.invalidConfigMessageHandling } - let groupedMessages: [ConfigDump.Variant: [ConfigMessageReceiveJob.Details.MessageInfo]] = messages + return try messages .grouped(by: { ConfigDump.Variant(namespace: $0.namespace) }) - - return try groupedMessages .sorted { lhs, rhs in lhs.key.namespace.processingOrder < rhs.key.namespace.processingOrder } - .compactMap { variant, messages -> MergeResult? in + .reduce(into: [:]) { result, next in + let (variant, messages): (ConfigDump.Variant, [ConfigMessageReceiveJob.Details.MessageInfo]) = next let sessionId: SessionId = SessionId(hex: swarmPublicKey, dumpVariant: variant) let config: Config? = configStore[sessionId, variant] do { - let oldState: [ObservableKey: Any] = try { - switch config { - case .userProfile: - return [ - .profile(userSessionId.hexString): profile, - .setting(.checkForCommunityMessageRequests): get(.checkForCommunityMessageRequests) - ] - - case .contacts(let conf): - return try LibSession - .extractContacts(from: conf, using: dependencies) - .reduce(into: [:]) { result, next in - result[.contact(next.key)] = next.value.contact - result[.profile(next.key)] = next.value.profile - } - - default: return [:] - } - }() - // Merge the messages (if it doesn't merge anything then don't bother trying // to handle the result) Log.info(.libSession, "Attempting to merge \(variant) config messages") guard let latestServerTimestampMs: Int64 = try config?.merge(messages) else { - return nil + return } - // Now that the config message has been merged, run any after-merge logic - let dump: ConfigDump? = try afterMerge( - sessionId, - variant, - config, - latestServerTimestampMs, - oldState - ) - - return (sessionId, variant, dump) + result[variant] = latestServerTimestampMs } catch { Log.error(.libSession, "Failed to process merge of \(variant) config data") @@ -883,90 +891,101 @@ public extension LibSession { swarmPublicKey: String, messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws { - let results: [MergeResult] = try mergeConfigMessages( + let oldStateMap: [ConfigDump.Variant: [ObservableKey: Any]] = try currentConfigState( + swarmPublicKey: swarmPublicKey, + variants: Set(messages.map { ConfigDump.Variant(namespace: $0.namespace) }) + ) + let latestServerTimestampsMs: [ConfigDump.Variant: Int64] = try mergeConfigMessages( swarmPublicKey: swarmPublicKey, messages: messages - ) { sessionId, variant, config, latestServerTimestampMs, oldState in - // Apply the updated states to the database - switch variant { - case .userProfile: - try handleUserProfileUpdate( - db, - in: config, - oldState: oldState - ) - - case .contacts: - try handleContactsUpdate( - db, - in: config, - oldState: oldState - ) - - case .convoInfoVolatile: - try handleConvoInfoVolatileUpdate( - db, - in: config - ) - - case .userGroups: - try handleUserGroupsUpdate( - db, - in: config - ) - - case .groupInfo: - try handleGroupInfoUpdate( - db, - in: config, - groupSessionId: sessionId, - serverTimestampMs: latestServerTimestampMs - ) - - case .groupMembers: - try handleGroupMembersUpdate( - db, - in: config, - groupSessionId: sessionId, - serverTimestampMs: latestServerTimestampMs - ) - - case .groupKeys: - try handleGroupKeysUpdate( - db, - in: config, - groupSessionId: sessionId - ) - - case .local: Log.error(.libSession, "Tried to process merge of local config") - case .invalid: Log.error(.libSession, "Failed to process merge of invalid config namespace") - } - - // Need to check if the config needs to be dumped (this might have changed - // after handling the merge changes) - guard configNeedsDump(config) else { - try ConfigDump - .filter( - ConfigDump.Columns.variant == variant && - ConfigDump.Columns.publicKey == sessionId.hexString - ) - .updateAll( - db, - ConfigDump.Columns.timestampMs.set(to: latestServerTimestampMs) - ) - return nil + ) + let results: [MergeResult] = try latestServerTimestampsMs + .sorted { lhs, rhs in lhs.key.namespace.processingOrder < rhs.key.namespace.processingOrder } + .compactMap { variant, latestServerTimestampMs in + let sessionId: SessionId = SessionId(hex: swarmPublicKey, dumpVariant: variant) + let config: Config? = configStore[sessionId, variant] + let oldState: [ObservableKey: Any] = (oldStateMap[variant] ?? [:]) + + // Apply the updated states to the database + switch variant { + case .userProfile: + try handleUserProfileUpdate( + db, + in: config, + oldState: oldState + ) + + case .contacts: + try handleContactsUpdate( + db, + in: config, + oldState: oldState + ) + + case .convoInfoVolatile: + try handleConvoInfoVolatileUpdate( + db, + in: config + ) + + case .userGroups: + try handleUserGroupsUpdate( + db, + in: config, + oldState: oldState + ) + + case .groupInfo: + try handleGroupInfoUpdate( + db, + in: config, + groupSessionId: sessionId + ) + + case .groupMembers: + try handleGroupMembersUpdate( + db, + in: config, + groupSessionId: sessionId, + serverTimestampMs: latestServerTimestampMs + ) + + case .groupKeys: + try handleGroupKeysUpdate( + db, + in: config, + groupSessionId: sessionId + ) + + case .local: Log.error(.libSession, "Tried to process merge of local config") + case .invalid: Log.error(.libSession, "Failed to process merge of invalid config namespace") + } + + // Need to check if the config needs to be dumped (this might have changed + // after handling the merge changes) + guard configNeedsDump(config) else { + try ConfigDump + .filter( + ConfigDump.Columns.variant == variant && + ConfigDump.Columns.publicKey == sessionId.hexString + ) + .updateAll( + db, + ConfigDump.Columns.timestampMs.set(to: latestServerTimestampMs) + ) + return nil + } + + let dump: ConfigDump? = try createDump( + config: config, + for: variant, + sessionId: sessionId, + timestampMs: latestServerTimestampMs + ) + try dump?.upsert(db) + + return (sessionId, variant, dump) } - - let dump: ConfigDump? = try createDump( - config: config, - for: variant, - sessionId: sessionId, - timestampMs: latestServerTimestampMs - ) - try dump?.upsert(db) - - return dump - } let needsPush: Bool = (try? SessionId(from: swarmPublicKey)).map { configStore[$0].contains(where: { $0.needsPush }) && @@ -1003,23 +1022,6 @@ public extension LibSession { } } } - - public func unsafeDirectMergeConfigMessage( - swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo] - ) throws { - guard !messages.isEmpty else { return } - - let groupedMessages: [ConfigDump.Variant: [ConfigMessageReceiveJob.Details.MessageInfo]] = messages - .grouped(by: { ConfigDump.Variant(namespace: $0.namespace) }) - - try groupedMessages - .sorted { lhs, rhs in lhs.key.namespace.processingOrder < rhs.key.namespace.processingOrder } - .forEach { [configStore] variant, message in - let sessionId: SessionId = SessionId(hex: swarmPublicKey, dumpVariant: variant) - _ = try configStore[sessionId, variant]?.merge(message) - } - } } } @@ -1029,7 +1031,6 @@ public extension LibSession { public protocol LibSessionImmutableCacheType: ImmutableCacheType { var userSessionId: SessionId { get } var isEmpty: Bool { get } - var isSessionPro: Bool { get } var allDumpSessionIds: Set { get } func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool @@ -1041,7 +1042,6 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT var dependencies: Dependencies { get } var userSessionId: SessionId { get } var isEmpty: Bool { get } - var isSessionPro: Bool { get } var allDumpSessionIds: Set { get } // MARK: - State Management @@ -1108,24 +1108,14 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func mergeConfigMessages( swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo], - afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? - ) throws -> [LibSession.MergeResult] + messages: [ConfigMessageReceiveJob.Details.MessageInfo] + ) throws -> [ConfigDump.Variant: Int64] func handleConfigMessages( _ db: ObservingDatabase, swarmPublicKey: String, messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws - /// This function takes config messages and just triggers the merge into `libSession` - /// - /// **Note:** This function should only be used in a situation where we want to retrieve the data from a config message as using it - /// elsewhere will result in the database getting out of sync with the config state - func unsafeDirectMergeConfigMessage( - swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo] - ) throws - // MARK: - SettingFetcher func has(_ key: Setting.EnumKey) -> Bool @@ -1138,13 +1128,21 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func set(_ key: Setting.EnumKey, _ value: T?) var displayName: String? { get } + var proConfig: SessionPro.ProConfig? { get } + var proAccessExpiryTimestampMs: UInt64 { get } + /// This function should not be called outside of the `Profile.updateIfNeeded` function to avoid duplicating changes and events, + /// as a result this function doesn't emit profile change events itself (use `Profile.updateLocal` instead) func updateProfile( displayName: Update, displayPictureUrl: Update, displayPictureEncryptionKey: Update, + proProfileFeatures: Update, isReuploadProfilePicture: Bool ) throws + func updateProConfig(proConfig: SessionPro.ProConfig) + func removeProConfig() + func updateProAccessExpiryTimestampMs(_ proAccessExpiryTimestampMs: UInt64) func canPerformChange( threadId: String, @@ -1170,6 +1168,7 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT threadVariant: SessionThread.Variant, openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Int64? + func proProofMetadata(threadId: String) -> LibSession.ProProofMetadata? /// Returns whether the specified conversation is a message request /// @@ -1200,6 +1199,8 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func hasCredentials(groupSessionId: SessionId) -> Bool func secretKey(groupSessionId: SessionId) -> [UInt8]? + func latestGroupKey(groupSessionId: SessionId) throws -> [UInt8] + func allActiveGroupKeys(groupSessionId: SessionId) throws -> [[UInt8]] func isAdmin(groupSessionId: SessionId) -> Bool func loadAdminKey( groupIdentitySeed: Data, @@ -1210,8 +1211,11 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func wasKickedFromGroup(groupSessionId: SessionId) -> Bool func groupName(groupSessionId: SessionId) -> String? func groupIsDestroyed(groupSessionId: SessionId) -> Bool + func groupInfo(for groupIds: Set) -> [LibSession.GroupInfo?] func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? + + func authData(groupSessionId: SessionId) -> GroupAuthData } public extension LibSessionCacheType { @@ -1287,6 +1291,7 @@ public extension LibSessionCacheType { displayName: .set(to: displayName), displayPictureUrl: .useExisting, displayPictureEncryptionKey: .useExisting, + proProfileFeatures: .useExisting, isReuploadProfilePicture: false ) } @@ -1305,7 +1310,6 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { let dependencies: Dependencies let userSessionId: SessionId = .invalid let isEmpty: Bool = true - var isSessionPro: Bool = false let allDumpSessionIds: Set = [] init(using dependencies: Dependencies) { @@ -1395,18 +1399,13 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { func activeHashes(for swarmPublicKey: String) -> [String] { return [] } func mergeConfigMessages( swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo], - afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? - ) throws -> [LibSession.MergeResult] { return [] } + messages: [ConfigMessageReceiveJob.Details.MessageInfo] + ) throws -> [ConfigDump.Variant: Int64] { return [:] } func handleConfigMessages( _ db: ObservingDatabase, swarmPublicKey: String, messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws {} - func unsafeDirectMergeConfigMessage( - swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo] - ) throws {} // MARK: - SettingFetcher @@ -1417,6 +1416,8 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { // MARK: - State Access var displayName: String? { return nil } + var proConfig: SessionPro.ProConfig? { return nil } + var proAccessExpiryTimestampMs: UInt64 { return 0 } func set(_ key: Setting.BoolKey, _ value: Bool?) {} func set(_ key: Setting.EnumKey, _ value: T?) {} @@ -1424,8 +1425,12 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { displayName: Update, displayPictureUrl: Update, displayPictureEncryptionKey: Update, + proProfileFeatures: Update, isReuploadProfilePicture: Bool ) throws {} + func updateProConfig(proConfig: SessionPro.ProConfig) {} + func removeProConfig() {} + func updateProAccessExpiryTimestampMs(_ proAccessExpiryTimestampMs: UInt64) {} func canPerformChange( threadId: String, @@ -1451,6 +1456,7 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { threadVariant: SessionThread.Variant, openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Int64? { return nil } + func proProofMetadata(threadId: String) -> LibSession.ProProofMetadata? { return nil } func isMessageRequest( threadId: String, @@ -1480,14 +1486,21 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { func hasCredentials(groupSessionId: SessionId) -> Bool { return false } func secretKey(groupSessionId: SessionId) -> [UInt8]? { return nil } + func latestGroupKey(groupSessionId: SessionId) throws -> [UInt8] { throw CryptoError.invalidKey } + func allActiveGroupKeys(groupSessionId: SessionId) throws -> [[UInt8]] { throw CryptoError.invalidKey } func isAdmin(groupSessionId: SessionId) -> Bool { return false } func markAsInvited(groupSessionIds: [String]) throws {} func markAsKicked(groupSessionIds: [String]) throws {} func wasKickedFromGroup(groupSessionId: SessionId) -> Bool { return false } func groupName(groupSessionId: SessionId) -> String? { return nil } func groupIsDestroyed(groupSessionId: SessionId) -> Bool { return false } + func groupInfo(for groupIds: Set) -> [LibSession.GroupInfo?] { return [] } func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? { return nil } func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? { return nil } + + func authData(groupSessionId: SessionId) -> GroupAuthData { + return GroupAuthData(groupIdentityPrivateKey: nil, authData: nil) + } } // MARK: - Convenience @@ -1539,7 +1552,7 @@ private extension Int32 { } } -private extension SessionId { +public extension SessionId { init(hex: String, dumpVariant: ConfigDump.Variant) { switch (try? SessionId(from: hex), dumpVariant) { case (.some(let sessionId), _): self = sessionId diff --git a/SessionMessagingKit/LibSession/Types/GroupAuthData.swift b/SessionMessagingKit/LibSession/Types/GroupAuthData.swift new file mode 100644 index 0000000000..cb0dd8bf24 --- /dev/null +++ b/SessionMessagingKit/LibSession/Types/GroupAuthData.swift @@ -0,0 +1,8 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct GroupAuthData: Codable { + let groupIdentityPrivateKey: Data? + let authData: Data? +} diff --git a/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift b/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift index f9181fd85f..bdb7a90cb9 100644 --- a/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift +++ b/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift @@ -70,17 +70,15 @@ public extension LibSession { // MARK: - Queries - public static func fetchOne(_ db: ObservingDatabase, server: String, activeOnly: Bool = true) throws -> OpenGroupCapabilityInfo? { + public static func fetchOne(_ db: ObservingDatabase, server: String, activelyPollingOnly: Bool = true) throws -> OpenGroupCapabilityInfo? { var query: QueryInterfaceRequest = OpenGroup .select(.threadId, .server, .roomToken, .publicKey) .filter(OpenGroup.Columns.server == server.lowercased()) .asRequest(of: OpenGroupUrlInfo.self) - /// If we only want to retrieve data for active OpenGroups then add additional filters - if activeOnly { - query = query - .filter(OpenGroup.Columns.isActive == true) - .filter(OpenGroup.Columns.roomToken != "") + /// If we only want to retrieve data for OpenGroups we are actively polling then add additional filters + if activelyPollingOnly { + query = query.filter(OpenGroup.Columns.shouldPoll == true) } guard let urlInfo: OpenGroupUrlInfo = try query.fetchOne(db) else { return nil } diff --git a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift index a1521a47b1..5eae7459ec 100644 --- a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift +++ b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift @@ -43,12 +43,17 @@ public final class DataExtractionNotification: ControlMessage { // MARK: - Validation - public override func isValid(isSending: Bool) -> Bool { - guard super.isValid(isSending: isSending), let kind = kind else { return false } + public override func validateMessage(isSending: Bool) throws { + try super.validateMessage(isSending: isSending) + + guard let kind = kind else { throw MessageError.missingRequiredField("kind") } switch kind { - case .screenshot: return true - case .mediaSaved(let timestamp): return timestamp > 0 + case .screenshot: break + case .mediaSaved(let timestamp): + if timestamp == 0 { + throw MessageError.invalidMessage("Timestamp is invalid") + } } } diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift index 12c08361d6..f61e82faa1 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift @@ -99,7 +99,7 @@ public final class GroupUpdateDeleteMemberContentMessage: ControlMessage { switch adminSignature { case .some(.standard(let signature)): try container.encode(signature, forKey: .adminSignature) - case .some(.subaccount): throw MessageSenderError.signingFailed + case .some(.subaccount): throw MessageError.requiredSignatureMissing case .none: break // Valid case (member deleting their own sent messages) } } @@ -126,7 +126,7 @@ public final class GroupUpdateDeleteMemberContentMessage: ControlMessage { switch adminSignature { case .some(.standard(let signature)): deleteMemberContentMessageBuilder.setAdminSignature(Data(signature)) - case .some(.subaccount): throw MessageSenderError.signingFailed + case .some(.subaccount): throw MessageError.requiredSignatureMissing case .none: break // Valid case (member deleting their own sent messages) } diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift index 7dbd388901..5385a41afc 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift @@ -107,7 +107,7 @@ public final class GroupUpdateInfoChangeMessage: ControlMessage { switch adminSignature { case .standard(let signature): try container.encode(signature, forKey: .adminSignature) - case .subaccount: throw MessageSenderError.signingFailed + case .subaccount: throw MessageError.requiredSignatureMissing } } @@ -143,7 +143,7 @@ public final class GroupUpdateInfoChangeMessage: ControlMessage { adminSignature: try { switch adminSignature { case .standard(let signature): return Data(signature) - case .subaccount: throw MessageSenderError.signingFailed + case .subaccount: throw MessageError.requiredSignatureMissing } }() ) diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift index e614131dc8..057d9980da 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift @@ -114,7 +114,7 @@ public final class GroupUpdateInviteMessage: ControlMessage { switch adminSignature { case .standard(let signature): try container.encode(signature, forKey: .adminSignature) - case .subaccount: throw MessageSenderError.signingFailed + case .subaccount: throw MessageError.requiredSignatureMissing } } @@ -149,7 +149,7 @@ public final class GroupUpdateInviteMessage: ControlMessage { adminSignature: try { switch adminSignature { case .standard(let signature): return Data(signature) - case .subaccount: throw MessageSenderError.signingFailed + case .subaccount: throw MessageError.requiredSignatureMissing } }() ) diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift index 32689fb1b5..5ec3abcd76 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift @@ -107,7 +107,7 @@ public final class GroupUpdateMemberChangeMessage: ControlMessage { switch adminSignature { case .standard(let signature): try container.encode(signature, forKey: .adminSignature) - case .subaccount: throw MessageSenderError.signingFailed + case .subaccount: throw MessageError.requiredSignatureMissing } } @@ -143,7 +143,7 @@ public final class GroupUpdateMemberChangeMessage: ControlMessage { adminSignature: try { switch adminSignature { case .standard(let signature): return Data(signature) - case .subaccount: throw MessageSenderError.signingFailed + case .subaccount: throw MessageError.requiredSignatureMissing } }() ) diff --git a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift index dc272f3701..c8ba91a041 100644 --- a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift +++ b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift @@ -20,10 +20,10 @@ public final class ReadReceipt: ControlMessage { // MARK: - Validation - public override func isValid(isSending: Bool) -> Bool { - guard super.isValid(isSending: isSending) else { return false } - if let timestamps = timestamps, !timestamps.isEmpty { return true } - return false + public override func validateMessage(isSending: Bool) throws { + try super.validateMessage(isSending: isSending) + + if timestamps?.isEmpty != false { throw MessageError.missingRequiredField("timestamps") } } // MARK: - Codable diff --git a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift index 7bd5844223..2087f9a319 100644 --- a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift +++ b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift @@ -42,9 +42,10 @@ public final class TypingIndicator: ControlMessage { // MARK: - Validation - public override func isValid(isSending: Bool) -> Bool { - guard super.isValid(isSending: isSending) else { return false } - return kind != nil + public override func validateMessage(isSending: Bool) throws { + try super.validateMessage(isSending: isSending) + + if kind == nil { throw MessageError.missingRequiredField("kind") } } // MARK: - Initialization diff --git a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift index 88b84364f5..a0c433d647 100644 --- a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift +++ b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift @@ -16,10 +16,11 @@ public final class UnsendRequest: ControlMessage { // MARK: - Validation - public override func isValid(isSending: Bool) -> Bool { - guard super.isValid(isSending: isSending) else { return false } + public override func validateMessage(isSending: Bool) throws { + try super.validateMessage(isSending: isSending) - return timestamp != nil && author != nil + if (timestamp ?? 0) == 0 { throw MessageError.missingRequiredField("timestamp") } + if author?.isEmpty != false { throw MessageError.missingRequiredField("author") } } // MARK: - Initialization diff --git a/SessionMessagingKit/Messages/Decoding/DecodedEnvelope.swift b/SessionMessagingKit/Messages/Decoding/DecodedEnvelope.swift new file mode 100644 index 0000000000..b86aac51fb --- /dev/null +++ b/SessionMessagingKit/Messages/Decoding/DecodedEnvelope.swift @@ -0,0 +1,60 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public struct DecodedEnvelope: Sendable, Codable, Equatable { + let success: Bool + let envelope: Envelope + let content: Data + + /// The `ed25519` public key of the sender + /// + /// **Note:** Messages sent to a SOGS are not encrypted so this value will be `null` + let senderEd25519Pubkey: [UInt8]? + let senderX25519Pubkey: [UInt8] + let decodedPro: SessionPro.DecodedProForMessage + let errorLenInclNullTerminator: Int + + /// The timestamp that the message was sent from the senders device + /// + /// **Note:** For a message from SOGS this value is the timestamp the message was received by the server instead of the value + /// contained within the `Envelope` + let sentTimestampMs: UInt64 + + // MARK: - Initialization + + init( + success: Bool, + envelope: Envelope, + content: Data, + senderEd25519Pubkey: [UInt8]?, + senderX25519Pubkey: [UInt8], + decodedPro: SessionPro.DecodedProForMessage, + errorLenInclNullTerminator: Int, + sentTimestampMs: UInt64 + ) { + self.success = success + self.envelope = envelope + self.content = content + self.senderEd25519Pubkey = senderEd25519Pubkey + self.senderX25519Pubkey = senderX25519Pubkey + self.decodedPro = decodedPro + self.errorLenInclNullTerminator = errorLenInclNullTerminator + self.sentTimestampMs = sentTimestampMs + } + + init(_ libSessionValue: session_protocol_decoded_envelope) { + success = libSessionValue.success + envelope = Envelope(libSessionValue.envelope) + content = libSessionValue.get(\.content_plaintext) + senderEd25519Pubkey = libSessionValue.get(\.sender_ed25519_pubkey) + senderX25519Pubkey = libSessionValue.get(\.sender_x25519_pubkey) + decodedPro = SessionPro.DecodedProForMessage(libSessionValue.pro) + errorLenInclNullTerminator = libSessionValue.error_len_incl_null_terminator + sentTimestampMs = envelope.timestampMs + } +} + +extension session_protocol_decoded_envelope: @retroactive CAccessible & CMutable {} diff --git a/SessionMessagingKit/Messages/Decoding/DecodedMessage.swift b/SessionMessagingKit/Messages/Decoding/DecodedMessage.swift new file mode 100644 index 0000000000..203c950e4c --- /dev/null +++ b/SessionMessagingKit/Messages/Decoding/DecodedMessage.swift @@ -0,0 +1,98 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public struct DecodedMessage: Codable, Equatable { + static let empty: DecodedMessage = DecodedMessage( + content: Data(), + sender: .invalid, + decodedEnvelope: nil, + sentTimestampMs: 0 + ) + + public let content: Data + public let sender: SessionId + + /// The decoded envelope data + /// + /// **Note:** For legacy SOGS messages this value will be `null` + public let decodedEnvelope: DecodedEnvelope? + + /// The timestamp that the message was sent from the senders device + /// + /// **Note:** For a message from SOGS this value is the timestamp the message was received by the server instead of the value + /// contained within the `Envelope` + public let sentTimestampMs: UInt64 + + // MARK: - Convenience forwarded access + + var senderEd25519Pubkey: [UInt8]? { decodedEnvelope?.senderEd25519Pubkey } + var senderX25519Pubkey: [UInt8]? { decodedEnvelope?.senderX25519Pubkey } + var decodedPro: SessionPro.DecodedProForMessage? { decodedEnvelope?.decodedPro } + + // MARK: - Initialization + + init( + content: Data, + sender: SessionId, + decodedEnvelope: DecodedEnvelope?, + sentTimestampMs: UInt64 + ) { + self.content = content + self.sender = sender + self.decodedEnvelope = decodedEnvelope + self.sentTimestampMs = sentTimestampMs + } + + init(decodedValue: session_protocol_decoded_envelope) { + let decodedEnvelope: DecodedEnvelope = DecodedEnvelope(decodedValue) + + self = DecodedMessage( + content: decodedEnvelope.content, + sender: SessionId(.standard, publicKey: decodedEnvelope.senderX25519Pubkey), + decodedEnvelope: decodedEnvelope, + sentTimestampMs: decodedEnvelope.envelope.timestampMs + ) + } + + init( + decodedValue: session_protocol_decoded_community_message, + sender: String, + posted: TimeInterval + ) throws { + let content: Data = decodedValue.get(\.content_plaintext) + let senderSessionId: SessionId = try SessionId(from: sender) + + self = DecodedMessage( + content: content, + sender: senderSessionId, + decodedEnvelope: { + guard decodedValue.has_envelope else { return nil } + + return DecodedEnvelope( + success: decodedValue.success, + envelope: Envelope(decodedValue.envelope), + content: content, + senderEd25519Pubkey: nil, /// SOGS doesn't include the senders `ed25519` key + senderX25519Pubkey: senderSessionId.publicKey, + decodedPro: SessionPro.DecodedProForMessage(decodedValue.pro), + errorLenInclNullTerminator: decodedValue.error_len_incl_null_terminator, + sentTimestampMs: UInt64(floor(posted * 1000)) + ) + }(), + sentTimestampMs: UInt64(floor(posted * 1000)) + ) + } + + // MARK: - Functions + + public func decodeProtoContent() throws -> SNProtoContent { + return try Result(catching: { try SNProtoContent.parseData(content) }) + .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } + .get() + } +} + +extension session_protocol_decoded_community_message: @retroactive CAccessible {} diff --git a/SessionMessagingKit/Messages/Decoding/Envelope.swift b/SessionMessagingKit/Messages/Decoding/Envelope.swift new file mode 100644 index 0000000000..305717c2b5 --- /dev/null +++ b/SessionMessagingKit/Messages/Decoding/Envelope.swift @@ -0,0 +1,27 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +struct Envelope: Sendable, Codable, Equatable { + let flags: EnvelopeFlags + let timestampMs: UInt64 + let source: [UInt8] + let sourceDevice: UInt32 + let serverTimestamp: UInt64 + let proSignature: [UInt8] + + // MARK: - Initialization + + init(_ libSessionValue: session_protocol_envelope) { + flags = EnvelopeFlags(libSessionValue.flags) + timestampMs = libSessionValue.timestamp_ms + source = libSessionValue.get(\.source) + sourceDevice = libSessionValue.source_device + serverTimestamp = libSessionValue.server_timestamp + proSignature = libSessionValue.get(\.pro_sig) + } +} + +extension session_protocol_envelope: @retroactive CAccessible {} diff --git a/SessionMessagingKit/Messages/Decoding/EnvelopeFlags.swift b/SessionMessagingKit/Messages/Decoding/EnvelopeFlags.swift new file mode 100644 index 0000000000..93fef2a20e --- /dev/null +++ b/SessionMessagingKit/Messages/Decoding/EnvelopeFlags.swift @@ -0,0 +1,28 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +struct EnvelopeFlags: OptionSet, Sendable, Codable, Equatable, Hashable { + public let rawValue: UInt32 + + public static let source: EnvelopeFlags = EnvelopeFlags(rawValue: 1 << 0) + public static let sourceDevice: EnvelopeFlags = EnvelopeFlags(rawValue: 1 << 1) + public static let serverTimestamp: EnvelopeFlags = EnvelopeFlags(rawValue: 1 << 2) + public static let proSignature: EnvelopeFlags = EnvelopeFlags(rawValue: 1 << 3) + public static let timestamp: EnvelopeFlags = EnvelopeFlags(rawValue: 1 << 4) + + var libSessionValue: SESSION_PROTOCOL_ENVELOPE_FLAGS { + SESSION_PROTOCOL_ENVELOPE_FLAGS(rawValue) + } + + // MARK: - Initialization + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + init(_ libSessionValue: SESSION_PROTOCOL_ENVELOPE_FLAGS) { + self = EnvelopeFlags(rawValue: libSessionValue) + } +} diff --git a/SessionMessagingKit/Messages/LibSessionMessage.swift b/SessionMessagingKit/Messages/LibSessionMessage.swift index a5f552161b..dff9b9cfb5 100644 --- a/SessionMessagingKit/Messages/LibSessionMessage.swift +++ b/SessionMessagingKit/Messages/LibSessionMessage.swift @@ -12,8 +12,10 @@ public final class LibSessionMessage: Message, NotProtoConvertible { // MARK: - Validation - public override func isValid(isSending: Bool) -> Bool { - return !ciphertext.isEmpty + public override func validateMessage(isSending: Bool) throws { + try super.validateMessage(isSending: isSending) + + if ciphertext.isEmpty { throw MessageError.missingRequiredField("ciphertext") } } // MARK: - Initialization @@ -52,7 +54,7 @@ public extension LibSessionMessage { guard let sessionId: SessionId = try? SessionId(from: memberId), let groupKeysGenData: Data = "\(groupKeysGen)".data(using: .ascii) - else { throw MessageSenderError.encryptionFailed } + else { throw MessageError.invalidMessage("Unable to generate group kicked message") } return (sessionId, Data(sessionId.publicKey.appending(contentsOf: Array(groupKeysGenData)))) } @@ -68,7 +70,7 @@ public extension LibSessionMessage { encoding: .ascii ), let currentGen: Int = Int(currentGenString, radix: 10) - else { throw MessageReceiverError.decryptionFailed } + else { throw MessageError.decodingFailed } return (SessionId(.standard, publicKey: Array(plaintext[0..= currentKeysGen - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.ignorableMessage } } } diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index a61a43ec3d..dcaa9936aa 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -15,10 +15,10 @@ public extension Message { /// A one-to-one destination where `groupPublicKey` is a `standard` `SessionId` for legacy groups /// and a `group` `SessionId` for updated groups - case closedGroup(groupPublicKey: String) + case group(publicKey: String) /// A message directed to an open group - case openGroup( + case community( roomToken: String, server: String, whisperTo: String? = nil, @@ -26,27 +26,27 @@ public extension Message { ) /// A message directed to an open group inbox - case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String) + case communityInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String) public var threadVariant: SessionThread.Variant { switch self { - case .contact, .syncMessage, .openGroupInbox: return .contact - case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: + case .contact, .syncMessage, .communityInbox: return .contact + case .group(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: return .group - case .closedGroup: return .legacyGroup - case .openGroup: return .community + case .group: return .legacyGroup + case .community: return .community } } public var defaultNamespace: Network.SnodeAPI.Namespace? { switch self { case .contact, .syncMessage: return .`default` - case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: + case .group(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: return .groupMessages - case .closedGroup: return .legacyClosedGroup - case .openGroup, .openGroupInbox: return nil + case .group: return .legacyClosedGroup + case .community, .communityInbox: return nil } } @@ -64,7 +64,7 @@ public extension Message { throw SOGSError.blindedLookupMissingCommunityInfo } - return .openGroupInbox( + return .communityInbox( server: lookup.openGroupServer, openGroupPublicKey: lookup.openGroupPublicKey, blindedPublicKey: threadId @@ -73,7 +73,7 @@ public extension Message { return .contact(publicKey: threadId) - case .legacyGroup, .group: return .closedGroup(groupPublicKey: threadId) + case .legacyGroup, .group: return .group(publicKey: threadId) case .community: guard @@ -81,7 +81,7 @@ public extension Message { .fetchOne(db, id: threadId) else { throw StorageError.objectNotFound } - return .openGroup(roomToken: info.roomToken, server: info.server) + return .community(roomToken: info.roomToken, server: info.server) } } } diff --git a/SessionMessagingKit/Messages/Message+Origin.swift b/SessionMessagingKit/Messages/Message+Origin.swift index 1c0d5f330a..5223db6e37 100644 --- a/SessionMessagingKit/Messages/Message+Origin.swift +++ b/SessionMessagingKit/Messages/Message+Origin.swift @@ -16,14 +16,14 @@ public extension Message { case community( openGroupId: String, sender: String, - timestamp: TimeInterval?, + posted: TimeInterval, messageServerId: Int64, whisper: Bool, whisperMods: Bool, whisperTo: String? ) - case openGroupInbox( - timestamp: TimeInterval, + case communityInbox( + posted: TimeInterval, messageServerId: Int64, serverPublicKey: String, senderId: String, @@ -37,25 +37,11 @@ public extension Message { } } - public var isCommunity: Bool { + public var isRevokedRetrievableNamespace: Bool { switch self { - case .community: return true + case .swarm(_, let namespace, _, _, _): return (namespace == .revokedRetrievableGroupMessages) default: return false } } - - public var serverHash: String? { - switch self { - case .swarm(_, _, let serverHash, _, _): return serverHash - default: return nil - } - } - - public var serverExpirationTimestamp: TimeInterval? { - switch self { - case .swarm(_, _, _, _, let expirationTimestamp): return expirationTimestamp - default: return nil - } - } } } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 2583d84d7c..e18234cfd2 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -21,6 +21,8 @@ public class Message: Codable { case expiresInSeconds case expiresStartedAtMs + case proMessageFeatures + case proProfileFeatures case proProof } @@ -45,23 +47,24 @@ public class Message: Codable { public var expiresInSeconds: TimeInterval? public var expiresStartedAtMs: Double? - public var proProof: String? + public var proProof: Network.SessionPro.ProProof? + public var proMessageFeatures: SessionPro.MessageFeatures? + public var proProfileFeatures: SessionPro.ProfileFeatures? // MARK: - Validation - public func isValid(isSending: Bool) -> Bool { + public func validateMessage(isSending: Bool) throws { guard let sentTimestampMs: UInt64 = sentTimestampMs, - sentTimestampMs > 0, - sender != nil - else { return false } + sentTimestampMs > 0 + else { throw MessageError.missingRequiredField("sentTimestampMs") } + if sender?.isEmpty != false { throw MessageError.missingRequiredField("sender") } /// If this is an incoming message then ensure we also have a received timestamp if !isSending { - guard - let receivedTimestampMs: UInt64 = receivedTimestampMs, - receivedTimestampMs > 0 - else { return false } + if (receivedTimestampMs ?? 0) == 0 { + throw MessageError.missingRequiredField("receivedTimestampMs") + } } /// We added a new `sigTimestampMs` which is included in the message data so can be verified as part of the signature @@ -75,7 +78,9 @@ public class Message: Codable { /// at, due to this we need to allow for some variation between the values switch (isSending, sigTimestampMs, openGroupServerMessageId) { case (_, .some(let sigTimestampMs), .none), (true, .some(let sigTimestampMs), .some): - return (sigTimestampMs == sentTimestampMs) + if sigTimestampMs != sentTimestampMs { + throw MessageError.invalidMessage("Signature timestamp doesn't match sent timestamp") + } /// Outgoing messages to a community should have matching `sigTimestampMs` and `sentTimestampMs` /// values as they are set locally, when we get a response from the community we update the `sentTimestampMs` to @@ -83,10 +88,12 @@ public class Message: Codable { case (false, .some(let sigTimestampMs), .some): let delta: TimeInterval = (TimeInterval(max(sigTimestampMs, sentTimestampMs) - min(sigTimestampMs, sentTimestampMs)) / 1000) - return delta < Network.SOGS.validTimestampVarianceThreshold + if delta > Network.SOGS.validTimestampVarianceThreshold { + throw MessageError.invalidMessage("Difference between signature timestamp and sent timestamp is too large") + } // FIXME: We want to remove support for this case in a future release - case (_, .none, _): return true + case (_, .none, _): break } } @@ -104,7 +111,9 @@ public class Message: Codable { serverHash: String? = nil, expiresInSeconds: TimeInterval? = nil, expiresStartedAtMs: Double? = nil, - proProof: String? = nil + proProof: Network.SessionPro.ProProof? = nil, + proMessageFeatures: SessionPro.MessageFeatures? = nil, + proProfileFeatures: SessionPro.ProfileFeatures? = nil ) { self.id = id self.sentTimestampMs = sentTimestampMs @@ -118,6 +127,8 @@ public class Message: Codable { self.expiresInSeconds = expiresInSeconds self.expiresStartedAtMs = expiresStartedAtMs self.proProof = proProof + self.proMessageFeatures = proMessageFeatures + self.proProfileFeatures = proProfileFeatures } // MARK: - Proto Conversion @@ -168,7 +179,6 @@ public enum ProcessedMessage { case standard( threadId: String, threadVariant: SessionThread.Variant, - proto: SNProtoContent, messageInfo: MessageReceiveJob.Details.MessageInfo, uniqueIdentifier: String ) @@ -180,19 +190,17 @@ public enum ProcessedMessage { data: Data, uniqueIdentifier: String ) - case invalid public var threadId: String { switch self { - case .standard(let threadId, _, _, _, _): return threadId + case .standard(let threadId, _, _, _): return threadId case .config(let publicKey, _, _, _, _, _): return publicKey - case .invalid: return "" } } var namespace: Network.SnodeAPI.Namespace { switch self { - case .standard(_, let threadVariant, _, _, _): + case .standard(_, let threadVariant, _, _): switch threadVariant { case .group: return .groupMessages case .legacyGroup: return .legacyClosedGroup @@ -200,15 +208,13 @@ public enum ProcessedMessage { } case .config(_, let namespace, _, _, _, _): return namespace - case .invalid: return .default } } var uniqueIdentifier: String { switch self { - case .standard(_, _, _, _, let uniqueIdentifier): return uniqueIdentifier + case .standard(_, _, _, let uniqueIdentifier): return uniqueIdentifier case .config(_, _, _, _, _, let uniqueIdentifier): return uniqueIdentifier - case .invalid: return "" } } @@ -216,7 +222,6 @@ public enum ProcessedMessage { switch self { case .standard: return false case .config: return true - case .invalid: return false } } } @@ -360,18 +365,18 @@ public extension Message { } public extension Message { - static func createMessageFrom(_ proto: SNProtoContent, sender: String, using dependencies: Dependencies) throws -> Message { - let decodedMessage: Message? = Variant + static func createMessageFrom(_ proto: SNProtoContent, decodedMessage: DecodedMessage, using dependencies: Dependencies) throws -> Message { + let result: Message? = Variant .allCases .sorted { lhs, rhs -> Bool in lhs.protoPriority < rhs.protoPriority } .filter { variant -> Bool in variant.isProtoConvetible } .reduce(nil) { prev, variant in guard prev == nil else { return prev } - return variant.messageType.fromProto(proto, sender: sender, using: dependencies) + return variant.messageType.fromProto(proto, sender: decodedMessage.sender.hexString, using: dependencies) } - return try decodedMessage ?? { throw MessageReceiverError.unknownMessage(proto) }() + return try result ?? { throw MessageError.unknownMessage(decodedMessage) }() } static func shouldSync(message: Message) -> Bool { @@ -421,11 +426,11 @@ public extension Message { return (maybeSyncTarget ?? publicKey) - case .closedGroup(let groupPublicKey): return groupPublicKey - case .openGroup(let roomToken, let server, _, _): + case .group(let publicKey): return publicKey + case .community(let roomToken, let server, _, _): return OpenGroup.idFor(roomToken: roomToken, server: server) - case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey + case .communityInbox(_, _, let blindedPublicKey): return blindedPublicKey } } @@ -433,7 +438,7 @@ public extension Message { _ db: ObservingDatabase, openGroupId: String, message: Network.SOGS.Message, - associatedPendingChanges: [OpenGroupManager.PendingChange], + associatedPendingChanges: [CommunityManager.PendingChange], using dependencies: Dependencies ) -> [Reaction] { guard @@ -483,7 +488,7 @@ public extension Message { let pendingChangeSelfReaction: Bool? = { // Find the newest 'PendingChange' entry with a matching emoji, if one exists, and // set the "self reaction" value based on it's action - let maybePendingChange: OpenGroupManager.PendingChange? = associatedPendingChanges + let maybePendingChange: CommunityManager.PendingChange? = associatedPendingChanges .sorted(by: { lhs, rhs -> Bool in (lhs.seqNo ?? Int64.max) >= (rhs.seqNo ?? Int64.max) }) .first { pendingChange in if case .reaction(_, let emoji, _) = pendingChange.metadata { @@ -495,7 +500,7 @@ public extension Message { // If there is no pending change for this reaction then return nil guard - let pendingChange: OpenGroupManager.PendingChange = maybePendingChange, + let pendingChange: CommunityManager.PendingChange = maybePendingChange, case .reaction(_, _, let action) = pendingChange.metadata else { return nil } @@ -582,9 +587,9 @@ public extension Message { // Disappear after sent messages with exceptions case (_, is UnsendRequest): return message.ttl - case (.closedGroup, is GroupUpdateInviteMessage), (.closedGroup, is GroupUpdateInviteResponseMessage), - (.closedGroup, is GroupUpdatePromoteMessage), (.closedGroup, is GroupUpdateMemberLeftMessage), - (.closedGroup, is GroupUpdateDeleteMemberContentMessage): + case (.group, is GroupUpdateInviteMessage), (.group, is GroupUpdateInviteResponseMessage), + (.group, is GroupUpdatePromoteMessage), (.group, is GroupUpdateMemberLeftMessage), + (.group, is GroupUpdateDeleteMemberContentMessage): return message.ttl default: @@ -661,9 +666,4 @@ public extension Message { self.expiresStartedAtMs = expiresStartedAtMs return self } - - func with(proProof: String?) -> Self { - self.proProof = proProof - return self - } } diff --git a/SessionMessagingKit/Messages/MessageError.swift b/SessionMessagingKit/Messages/MessageError.swift new file mode 100644 index 0000000000..583a6506a6 --- /dev/null +++ b/SessionMessagingKit/Messages/MessageError.swift @@ -0,0 +1,129 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUIKit +import SessionUtilitiesKit + +public enum MessageError: Error, CustomStringConvertible { + case encodingFailed + case decodingFailed + case invalidMessage(String) + case missingRequiredField(String?) + + case duplicateMessage + case duplicatedCall + case outdatedMessage + case ignorableMessage + case ignorableMessageRequestMessage + case deprecatedMessage + case protoConversionFailed + case unknownMessage(DecodedMessage) + + case requiredSignatureMissing + case invalidConfigMessageHandling + case invalidRevokedRetrievalMessageHandling + case invalidGroupUpdate(String) + case communitiesDoNotSupportControlMessages + case requiresGroupId(String) + case requiresGroupIdentityPrivateKey + case messageTooLarge + + case selfSend + case invalidSender + case senderBlocked + case messageRequiresThreadToExistButThreadDoesNotExist + case sendFailure(Log.Category?, String, Error) + + public static let missingRequiredField: MessageError = .missingRequiredField(nil) + + public var shouldUpdateLastHash: Bool { + switch self { + /// If we get one of these errors then we still want to update the last hash to prevent retrieving and attempting to process + /// the same messages again (as well as ensure the next poll doesn't retrieve the same message - these errors are essentially + /// considered "already successfully processed") + case .duplicateMessage, .duplicatedCall, .outdatedMessage, .selfSend: + return true + + default: return false + } + } + + public var description: String { + switch self { + case .encodingFailed: return "Failed to encode message." + case .decodingFailed: return "Failed to decode message." + case .invalidMessage(let reason): return "Invalid message (\(reason))." + case .missingRequiredField(let field): + return "Message missing required field\(field.map { ": \($0)" } ?? "")." + + case .duplicateMessage: return "Duplicate message." + case .duplicatedCall: return "Duplicate call." + case .outdatedMessage: return "Message was sent before a config change which would have removed the message." + case .ignorableMessage: return "Message should be ignored." + case .ignorableMessageRequestMessage: return "Message request message should be ignored." + case .deprecatedMessage: return "This message type has been deprecated." + case .protoConversionFailed: return "Failed to convert to protobuf message." + case .unknownMessage(let decodedMessage): + var messageInfo: [String] = [ + "size: \(Format.fileSize(UInt(decodedMessage.content.count)))" + ] + + if decodedMessage.decodedEnvelope != nil { + messageInfo.append("hasDecodedEnvelope") + } + + if let proto: SNProtoContent = try? decodedMessage.decodeProtoContent() { + let protoInfo: [(String, Bool)] = [ + ("hasDataMessage", (proto.dataMessage != nil)), + ("hasProfile", (proto.dataMessage?.profile != nil)), + ("hasBody", (proto.dataMessage?.hasBody == true)), + ("hasAttachments", (proto.dataMessage?.attachments.isEmpty == false)), + ("hasReaction", (proto.dataMessage?.reaction != nil)), + ("hasQuote", (proto.dataMessage?.quote != nil)), + ("hasLinkPreview", (proto.dataMessage?.preview != nil)), + ("hasOpenGroupInvitation", (proto.dataMessage?.openGroupInvitation != nil)), + ("hasGroupV2ControlMessage", (proto.dataMessage?.groupUpdateMessage != nil)), + ("hasTimestamp", (proto.dataMessage?.hasTimestamp == true)), + ("hasSyncTarget", (proto.dataMessage?.hasSyncTarget == true)), + ("hasBlocksCommunityMessageRequests", (proto.dataMessage?.hasBlocksCommunityMessageRequests == true)), + ("hasCallMessage", (proto.callMessage != nil)), + ("hasReceiptMessage", (proto.receiptMessage != nil)), + ("hasTypingMessage", (proto.typingMessage != nil)), + ("hasDataExtractionMessage", (proto.dataExtractionNotification != nil)), + ("hasUnsendRequest", (proto.unsendRequest != nil)), + ("hasMessageRequestResponse", (proto.messageRequestResponse != nil)), + ("hasExpirationTimer", (proto.hasExpirationTimer == true)), + ("hasExpirationType", (proto.hasExpirationType == true)), + ("hasSigTimestamp", (proto.hasSigTimestamp == true)) + ] + + messageInfo.append( + contentsOf: protoInfo + .filter { _, val in val } + .map { name, _ in "proto.\(name)" } + ) + } + + let infoString: String = messageInfo.joined(separator: ", ") + return "Unknown message type (\(infoString))." + + case .requiredSignatureMissing: return "Required signature missing." + case .invalidConfigMessageHandling: return "Invalid handling of a config message." + case .invalidRevokedRetrievalMessageHandling: return "Invalid handling of a revoked retrieval message." + case .invalidGroupUpdate(let reason): return "Invalid group update (\(reason))." + case .communitiesDoNotSupportControlMessages: return "Communities do not support control messages." + case .requiresGroupId(let id): return "Required group id but was given: \(id)." + case .requiresGroupIdentityPrivateKey: return "Requires group identity private key." + case .messageTooLarge: return "Message is too large." + + case .selfSend: return "Message addressed at self." + case .invalidSender: return "Invalid sender." + case .senderBlocked: return "Received a message from a blocked user." + + case .messageRequiresThreadToExistButThreadDoesNotExist: return "Message requires a thread to exist before processing the message but the thread does not exist." + case .sendFailure(_, _, let error): return "\(error)" + } + } +} diff --git a/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift b/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift index e4fc9e9510..24d954445a 100644 --- a/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift +++ b/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift @@ -30,12 +30,12 @@ public extension SNProtoContent { /// We need to ensure we don't send a message which should have uploaded files but hasn't, we do this by comparing the /// `attachmentIds` on the `VisibleMessage` to the `attachments` value guard expectedAttachmentUploadCount == (attachments?.count ?? 0) else { - throw MessageSenderError.attachmentsNotUploaded + throw MessageError.invalidMessage("Attachments not uploaded") } /// Ensure we haven't incorrectly included the `linkPreview` or `quote` attachments in the main `attachmentIds` guard uniqueAttachmentIds.count == expectedAttachmentUploadCount else { - throw MessageSenderError.attachmentsInvalid + throw MessageError.invalidMessage("Incorrect attachment count") } do { diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift index 00e8327e19..04346dbff9 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift @@ -21,9 +21,13 @@ public extension VisibleMessage { public var sizeInBytes: UInt? public var url: String? - public func isValid(isSending: Bool) -> Bool { + public func validateMessage(isSending: Bool) throws { // key and digest can be nil for open group attachments - contentType != nil && kind != nil && size != nil && sizeInBytes != nil && url != nil + if contentType?.isEmpty != false { throw MessageError.invalidMessage("contentType") } + if kind == nil { throw MessageError.invalidMessage("kind") } + if (size ?? .zero) == .zero { throw MessageError.invalidMessage("size") } + if (sizeInBytes ?? 0) == 0 { throw MessageError.invalidMessage("sizeInBytes") } + if url?.isEmpty != false { throw MessageError.invalidMessage("url") } } // MARK: - Initialization diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift index 3d64e8e482..c74499f43c 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift @@ -5,37 +5,48 @@ import SessionUtilitiesKit public extension VisibleMessage { struct VMLinkPreview: Codable { + public let url: String public let title: String? - public let url: String? public let attachmentId: String? + public let nonInsertedAttachment: Attachment? - public func isValid(isSending: Bool) -> Bool { title != nil && url != nil && attachmentId != nil } + public func validateMessage(isSending: Bool) throws { + if !url.isEmpty { throw MessageError.invalidMessage("url") } + } // MARK: - Initialization - internal init(title: String?, url: String, attachmentId: String?) { - self.title = title + internal init( + url: String, + title: String?, + attachmentId: String?, + nonInsertedAttachment: Attachment? + ) { self.url = url + self.title = title self.attachmentId = attachmentId + self.nonInsertedAttachment = nonInsertedAttachment } // MARK: - Proto Conversion public static func fromProto(_ proto: SNProtoDataMessagePreview) -> VMLinkPreview? { + guard + !proto.url.isEmpty, + LinkPreviewManager.isValidLinkUrl(proto.url) + else { return nil } + return VMLinkPreview( - title: proto.title, url: proto.url, - attachmentId: nil + title: proto.title, + attachmentId: nil, + nonInsertedAttachment: proto.image.map { Attachment(proto: $0) } ) } public func toProto() -> SNProtoDataMessagePreview? { - guard let url = url else { - Log.warn(.messageSender, "Couldn't construct link preview proto from: \(self).") - return nil - } let linkPreviewProto = SNProtoDataMessagePreview.builder(url: url) - if let title = title { linkPreviewProto.setTitle(title) } + if let title: String = title, !title.isEmpty { linkPreviewProto.setTitle(title) } do { return try linkPreviewProto.build() @@ -50,9 +61,10 @@ public extension VisibleMessage { public var description: String { """ LinkPreview( + url: \(url), title: \(title ?? "null"), - url: \(url ?? "null"), - attachmentId: \(attachmentId ?? "null") + attachmentId: \(attachmentId ?? "null"), + nonInsertedAttachment: \(nonInsertedAttachment.map { "\($0)" } ?? "null") ) """ } @@ -64,9 +76,10 @@ public extension VisibleMessage { public extension VisibleMessage.VMLinkPreview { static func from(linkPreview: LinkPreview) -> VisibleMessage.VMLinkPreview { return VisibleMessage.VMLinkPreview( - title: linkPreview.title, url: linkPreview.url, - attachmentId: linkPreview.attachmentId + title: linkPreview.title, + attachmentId: linkPreview.attachmentId, + nonInsertedAttachment: nil ) } } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index 3896985b74..174f5212b5 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -12,7 +12,6 @@ public extension VisibleMessage { public let profilePictureUrl: String? public let updateTimestampSeconds: TimeInterval? public let blocksCommunityMessageRequests: Bool? - public let sessionProProof: String? // MARK: - Initialization @@ -21,8 +20,7 @@ public extension VisibleMessage { profileKey: Data? = nil, profilePictureUrl: String? = nil, updateTimestampSeconds: TimeInterval? = nil, - blocksCommunityMessageRequests: Bool? = nil, - sessionProProof: String? = nil + blocksCommunityMessageRequests: Bool? = nil ) { let hasUrlAndKey: Bool = (profileKey != nil && profilePictureUrl != nil) @@ -31,12 +29,21 @@ public extension VisibleMessage { self.profilePictureUrl = (hasUrlAndKey ? profilePictureUrl : nil) self.updateTimestampSeconds = updateTimestampSeconds self.blocksCommunityMessageRequests = blocksCommunityMessageRequests - self.sessionProProof = sessionProProof + } + + internal init(profile: Profile, blocksCommunityMessageRequests: Bool? = nil) { + self.init( + displayName: profile.name, + profileKey: profile.displayPictureEncryptionKey, + profilePictureUrl: profile.displayPictureUrl, + updateTimestampSeconds: profile.profileLastUpdated, + blocksCommunityMessageRequests: blocksCommunityMessageRequests + ) } // MARK: - Proto Conversion - public static func fromProto(_ proto: SNProtoDataMessage) -> VMProfile? { + public static func fromProto(_ proto: ProtoWithProfile) -> VMProfile? { guard let profileProto = proto.profile, let displayName = profileProto.displayName @@ -47,13 +54,15 @@ public extension VisibleMessage { profileKey: proto.profileKey, profilePictureUrl: profileProto.profilePicture, updateTimestampSeconds: TimeInterval(profileProto.lastUpdateSeconds), - blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? proto.blocksCommunityMessageRequests : nil), - sessionProProof: nil // TODO: Add Session Pro Proof to profile proto + blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? + proto.blocksCommunityMessageRequests : + nil + ) ) } public func toProtoBuilder() throws -> SNProtoDataMessage.SNProtoDataMessageBuilder { - guard let displayName = displayName else { throw MessageSenderError.protoConversionFailed } + guard let displayName = displayName else { throw MessageError.protoConversionFailed } let dataMessageProto = SNProtoDataMessage.builder() let profileProto = SNProtoLokiProfile.builder() @@ -73,6 +82,7 @@ public extension VisibleMessage { } dataMessageProto.setProfile(try profileProto.build()) + return dataMessageProto } @@ -90,21 +100,6 @@ public extension VisibleMessage { // MARK: - MessageRequestResponse - public static func fromProto(_ proto: SNProtoMessageRequestResponse) -> VMProfile? { - guard - let profileProto = proto.profile, - let displayName = profileProto.displayName - else { return nil } - - return VMProfile( - displayName: displayName, - profileKey: proto.profileKey, - profilePictureUrl: profileProto.profilePicture, - updateTimestampSeconds: TimeInterval(profileProto.lastUpdateSeconds), - sessionProProof: nil // TODO: Add Session Pro Proof to profile proto - ) - } - public func toProto(isApproved: Bool) -> SNProtoMessageRequestResponse? { guard let displayName = displayName else { Log.warn(.messageSender, "Couldn't construct profile proto from: \(self).") @@ -158,3 +153,19 @@ extension MessageRequestResponse: MessageWithProfile {} extension GroupUpdateInviteMessage: MessageWithProfile {} extension GroupUpdatePromoteMessage: MessageWithProfile {} extension GroupUpdateInviteResponseMessage: MessageWithProfile {} + +// MARK: - ProtoWithProfile + +public protocol ProtoWithProfile { + var profileKey: Data? { get } + var profile: SNProtoLokiProfile? { get } + + var hasBlocksCommunityMessageRequests: Bool { get } + var blocksCommunityMessageRequests: Bool { get } +} + +extension SNProtoDataMessage: ProtoWithProfile {} +extension SNProtoMessageRequestResponse: ProtoWithProfile { + public var hasBlocksCommunityMessageRequests: Bool { return false } + public var blocksCommunityMessageRequests: Bool { return false } +} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift index d51cad72ee..4eff1d33d2 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -6,10 +6,13 @@ import SessionUtilitiesKit public extension VisibleMessage { struct VMQuote: Codable { - public let timestamp: UInt64? - public let authorId: String? + public let timestamp: UInt64 + public let authorId: String - public func isValid(isSending: Bool) -> Bool { timestamp != nil && authorId != nil } + public func validateMessage(isSending: Bool) throws { + if timestamp == 0 { throw MessageError.invalidMessage("timestamp") } + if !authorId.isEmpty { throw MessageError.invalidMessage("authorId") } + } // MARK: - Initialization @@ -21,6 +24,8 @@ public extension VisibleMessage { // MARK: - Proto Conversion public static func fromProto(_ proto: SNProtoDataMessageQuote) -> VMQuote? { + guard proto.id != 0 && !proto.author.isEmpty else { return nil } + return VMQuote( timestamp: proto.id, authorId: proto.author @@ -28,10 +33,6 @@ public extension VisibleMessage { } public func toProto() -> SNProtoDataMessageQuote? { - guard let timestamp = timestamp, let authorId = authorId else { - Log.warn(.messageSender, "Couldn't construct quote proto from: \(self).") - return nil - } let quoteProto = SNProtoDataMessageQuote.builder(id: timestamp, author: authorId) do { return try quoteProto.build() @@ -46,8 +47,8 @@ public extension VisibleMessage { public var description: String { """ Quote( - timestamp: \(timestamp?.description ?? "null"), - authorId: \(authorId ?? "null") + timestamp: \(timestamp), + authorId: \(authorId) ) """ } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift index 7f7b3e63b1..7b6fdd8f66 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift @@ -17,7 +17,7 @@ public extension VisibleMessage { /// This is the behaviour for the reaction public var kind: Kind - public func isValid(isSending: Bool) -> Bool { true } + public func validateMessage(isSending: Bool) throws {} // MARK: - Kind diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 6fe8111fdc..3a439535e8 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionNetworkingKit import SessionUtilitiesKit public final class VisibleMessage: Message { @@ -33,13 +34,17 @@ public final class VisibleMessage: Message { // MARK: - Validation - public override func isValid(isSending: Bool) -> Bool { - guard super.isValid(isSending: isSending) else { return false } - if !attachmentIds.isEmpty || dataMessageHasAttachments == true { return true } - if openGroupInvitation != nil { return true } - if reaction != nil { return true } - if let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return true } - return false + public override func validateMessage(isSending: Bool) throws { + try super.validateMessage(isSending: isSending) + + let hasAttachments: Bool = (!attachmentIds.isEmpty || dataMessageHasAttachments == true) + let hasOpenGroupInvitation: Bool = (openGroupInvitation != nil) + let hasReaction: Bool = (reaction != nil) + let hasText: Bool = (text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + + if !hasAttachments && !hasOpenGroupInvitation && !hasReaction && !hasText { + throw MessageError.invalidMessage("Has no content") + } } // MARK: - Initialization @@ -55,7 +60,10 @@ public final class VisibleMessage: Message { linkPreview: VMLinkPreview? = nil, profile: VMProfile? = nil, // Added when sending via the `MessageWithProfile` protocol openGroupInvitation: VMOpenGroupInvitation? = nil, - reaction: VMReaction? = nil + reaction: VMReaction? = nil, + proProof: Network.SessionPro.ProProof? = nil, + proMessageFeatures: SessionPro.MessageFeatures? = nil, + proProfileFeatures: SessionPro.ProfileFeatures? = nil ) { self.syncTarget = syncTarget self.text = text @@ -69,7 +77,10 @@ public final class VisibleMessage: Message { super.init( sentTimestampMs: sentTimestampMs, - sender: sender + sender: sender, + proProof: proProof, + proMessageFeatures: proMessageFeatures, + proProfileFeatures: proProfileFeatures ) } @@ -112,6 +123,36 @@ public final class VisibleMessage: Message { public override class func fromProto(_ proto: SNProtoContent, sender: String, using dependencies: Dependencies) -> VisibleMessage? { guard let dataMessage = proto.dataMessage else { return nil } + typealias ProInfo = ( + proof: Network.SessionPro.ProProof, + messageFeatures: SessionPro.MessageFeatures, + profileFeatures: SessionPro.ProfileFeatures + ) + let proInfo: ProInfo? = proto.proMessage + .map { proMessage -> ProInfo? in + guard + let vmProof: SNProtoProProof = proMessage.proof, + vmProof.hasVersion, + vmProof.version <= UInt8.max, /// Sanity check - Protobuf only supports `UInt32`/`UInt64` + vmProof.hasExpiryUnixTs, + let vmGenIndexHash: Data = vmProof.genIndexHash, + let vmRotatingPublicKey: Data = vmProof.rotatingPublicKey, + let vmSig: Data = vmProof.sig + else { return nil } + + return ( + Network.SessionPro.ProProof( + version: UInt8(vmProof.version), + genIndexHash: Array(vmGenIndexHash), + rotatingPubkey: Array(vmRotatingPublicKey), + expiryUnixTimestampMs: vmProof.expiryUnixTs, + signature: Array(vmSig) + ), + SessionPro.MessageFeatures(rawValue: proMessage.msgBitset), + SessionPro.ProfileFeatures(rawValue: proMessage.profileBitset) + ) + } + return VisibleMessage( syncTarget: dataMessage.syncTarget, text: dataMessage.body, @@ -121,7 +162,10 @@ public final class VisibleMessage: Message { linkPreview: dataMessage.preview.first.map { VMLinkPreview.fromProto($0) }, profile: VMProfile.fromProto(dataMessage), openGroupInvitation: dataMessage.openGroupInvitation.map { VMOpenGroupInvitation.fromProto($0) }, - reaction: dataMessage.reaction.map { VMReaction.fromProto($0) } + reaction: dataMessage.reaction.map { VMReaction.fromProto($0) }, + proProof: proInfo?.proof, + proMessageFeatures: proInfo?.messageFeatures, + proProfileFeatures: proInfo?.profileFeatures ) } @@ -178,6 +222,41 @@ public final class VisibleMessage: Message { dataMessage.setSyncTarget(syncTarget) } + // Pro content + let proMessageFeatures: SessionPro.MessageFeatures = (self.proMessageFeatures ?? .none) + let proProfileFeatures: SessionPro.ProfileFeatures = (self.proProfileFeatures ?? .none) + + if + let proProof: Network.SessionPro.ProProof = proProof, ( + proMessageFeatures != .none || + proProfileFeatures != .none + ) + { + let proMessageBuilder: SNProtoProMessage.SNProtoProMessageBuilder = SNProtoProMessage.builder() + let proofBuilder: SNProtoProProof.SNProtoProProofBuilder = SNProtoProProof.builder() + proofBuilder.setVersion(UInt32(proProof.version)) + proofBuilder.setGenIndexHash(Data(proProof.genIndexHash)) + proofBuilder.setRotatingPublicKey(Data(proProof.rotatingPubkey)) + proofBuilder.setExpiryUnixTs(proProof.expiryUnixTimestampMs) + proofBuilder.setSig(Data(proProof.signature)) + + do { + proMessageBuilder.setProof(try proofBuilder.build()) + + if proMessageFeatures != .none { + proMessageBuilder.setMsgBitset(proMessageFeatures.rawValue) + } + + if proProfileFeatures != .none { + proMessageBuilder.setProfileBitset(proProfileFeatures.rawValue) + } + + proto.setProMessage(try proMessageBuilder.build()) + } catch { + Log.warn(.messageSender, "Couldn't attach pro proof to message due to error: \(error).") + } + } + // Build do { proto.setDataMessage(try dataMessage.build()) diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index 9cbe349c2a..846e40d193 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -3,5 +3,4 @@ FOUNDATION_EXPORT double SessionMessagingKitVersionNumber; FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; -#import #import diff --git a/SessionMessagingKit/Open Groups/CommunityManager.swift b/SessionMessagingKit/Open Groups/CommunityManager.swift new file mode 100644 index 0000000000..7c8943ce9b --- /dev/null +++ b/SessionMessagingKit/Open Groups/CommunityManager.swift @@ -0,0 +1,1483 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import SessionUtilitiesKit +import SessionNetworkingKit + +// MARK: - Singleton + +public extension Singleton { + static let communityManager: SingletonConfig = Dependencies.create( + identifier: "communityManager", + createInstance: { dependencies in CommunityManager(using: dependencies) } + ) +} + +// MARK: - Log.Category + +public extension Log.Category { + static let communityManager: Log.Category = .create("communityManager", defaultLevel: .info) +} + +// MARK: - CommunityManager + +public actor CommunityManager: CommunityManagerType { + private let dependencies: Dependencies + nonisolated private let syncState: CommunityManagerSyncState + + nonisolated private let _defaultRooms: CurrentValueAsyncStream<(rooms: [Network.SOGS.Room], lastError: Error?)> = CurrentValueAsyncStream(([], nil)) + private var _lastSuccessfulCommunityPollTimestamp: TimeInterval? + private var _hasFetchedDefaultRooms: Bool = false + private var _hasLoadedCache: Bool = false + private var _servers: [String: Server] = [:] + + nonisolated public var defaultRooms: AsyncStream<(rooms: [Network.SOGS.Room], lastError: Error?)> { + _defaultRooms.stream + } + public var pendingChanges: [PendingChange] = [] + nonisolated public var syncPendingChanges: [CommunityManager.PendingChange] { syncState.pendingChanges } + + // MARK: - Initialization + + init(using dependencies: Dependencies) { + self.dependencies = dependencies + self.syncState = CommunityManagerSyncState(using: dependencies) + } + + // MARK: - Cache + + @available(*, deprecated, message: "Use `getLastSuccessfulCommunityPollTimestamp` instead") + nonisolated public func getLastSuccessfulCommunityPollTimestampSync() -> TimeInterval { + if let storedTime: TimeInterval = syncState.lastSuccessfulCommunityPollTimestamp { + return storedTime + } + + guard let lastPoll: Date = syncState.dependencies[defaults: .standard, key: .lastOpen] else { + return 0 + } + + syncState.update(lastSuccessfulCommunityPollTimestamp: .set(to: lastPoll.timeIntervalSince1970)) + return lastPoll.timeIntervalSince1970 + } + + public func getLastSuccessfulCommunityPollTimestamp() async -> TimeInterval { + if let storedTime: TimeInterval = _lastSuccessfulCommunityPollTimestamp { + return storedTime + } + + guard let lastPoll: Date = dependencies[defaults: .standard, key: .lastOpen] else { + return 0 + } + + _lastSuccessfulCommunityPollTimestamp = lastPoll.timeIntervalSince1970 + return lastPoll.timeIntervalSince1970 + } + + public func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) async { + dependencies[defaults: .standard, key: .lastOpen] = Date(timeIntervalSince1970: timestamp) + _lastSuccessfulCommunityPollTimestamp = timestamp + } + + nonisolated public func currentUserSessionIdsSync(_ server: String) -> Set { + return ( + syncState.servers[server.lowercased()]?.currentUserSessionIds ?? + [syncState.dependencies[cache: .general].sessionId.hexString] + ) + } + + public func fetchDefaultRoomsIfNeeded() async { + /// If we don't have any default rooms in memory then we haven't fetched this launch so schedule + /// the `RetrieveDefaultOpenGroupRoomsJob` if one isn't already running + guard await _defaultRooms.getCurrent().rooms.isEmpty else { return } + + RetrieveDefaultOpenGroupRoomsJob.run(using: dependencies) + } + + public func loadCacheIfNeeded() async { + guard !_hasLoadedCache else { return } + + let data: (info: [OpenGroup], capabilities: [Capability], members: [GroupMember]) = (try? await dependencies[singleton: .storage] + .readAsync { db in + let openGroups: [OpenGroup] = try OpenGroup.fetchAll(db) + let ids: [String] = openGroups.map { $0.id } + + return ( + openGroups, + try Capability.fetchAll(db), + try GroupMember + .filter(ids.contains(GroupMember.Columns.groupId)) + .fetchAll(db) + ) + }) + .defaulting(to: ([], [], [])) + let rooms: [String: [OpenGroup]] = data.info.grouped(by: \.server) + let capabilities: [String: [Capability.Variant]] = data.capabilities.reduce(into: [:]) { result, next in + result.append(next.variant, toArrayOn: next.openGroupServer.lowercased()) + } + let members: [String: [GroupMember]] = data.members.grouped(by: \.groupId) + + _servers = rooms.reduce(into: [:]) { result, next in + guard let publicKey: String = next.value.first?.publicKey else { return } + + let server: String = next.key.lowercased() + result[server] = CommunityManager.Server( + server: server, + publicKey: publicKey, + openGroups: next.value, + capabilities: capabilities[server].map { Set($0) }, + roomMembers: next.value.reduce(into: [:]) { result, next in + result[next.roomToken] = members[next.threadId] + }, + using: dependencies + ) + } + _hasLoadedCache = true + } + + public func server(_ server: String) async -> Server? { + return _servers[server.lowercased()] + } + + public func server(threadId: String) async -> Server? { + return _servers.values.first { server in + return server.rooms.values.contains { + OpenGroup.idFor(roomToken: $0.token, server: server.server) == threadId + } + } + } + + public func serversByThreadId() async -> [String: CommunityManager.Server] { + return _servers.values.reduce(into: [:]) { result, server in + server.rooms.forEach { roomToken, _ in + result[OpenGroup.idFor(roomToken: roomToken, server: server.server)] = server + } + } + } + + public func updateServer(server: Server) async { + _servers[server.server.lowercased()] = server + syncState.update(servers: .set(to: _servers)) + } + + public func updateCapabilities( + capabilities: Set, + server: String, + publicKey: String + ) async { + switch _servers[server.lowercased()] { + case .none: + _servers[server.lowercased()] = CommunityManager.Server( + server: server.lowercased(), + publicKey: publicKey, + openGroups: [], + capabilities: capabilities, + roomMembers: nil, + using: dependencies + ) + + case .some(let existingServer): + _servers[server.lowercased()] = existingServer.with( + capabilities: .set(to: capabilities), + using: dependencies + ) + } + + syncState.update(servers: .set(to: _servers)) + } + + public func updateRooms( + rooms: [Network.SOGS.Room], + server: String, + publicKey: String, + areDefaultRooms: Bool + ) async { + /// For default rooms we don't want to replicate or store them alongside other room data, so just emit that we have received + /// them and stop (since we don't want to poll or interact with these outside of the default rooms UI we want to avoid keeping + /// them alongside other room data) + guard !areDefaultRooms else { + await _defaultRooms.send((rooms, nil)) + return + } + + let targetServer: Server = ( + _servers[server.lowercased()] ?? + CommunityManager.Server( + server: server.lowercased(), + publicKey: publicKey, + openGroups: [], + capabilities: nil, + roomMembers: nil, + using: dependencies + ) + ) + _servers[server.lowercased()] = targetServer.with( + rooms: .set(to: rooms), + using: dependencies + ) + syncState.update(servers: .set(to: _servers)) + } + + public func removeRoom(server: String, roomToken: String) async { + let serverString: String = server.lowercased() + + guard let server: Server = _servers[serverString] else { return } + + _servers[serverString] = server.with( + rooms: .set(to: Array(server.rooms.removingValue(forKey: roomToken).values)), + using: dependencies + ) + syncState.update(servers: .set(to: _servers)) + } + + // MARK: - Adding & Removing + + // stringlint:ignore_contents + private static func port(for server: String, serverUrl: URL) -> String { + if let port: Int = serverUrl.port { + return ":\(port)" + } + + let components: [String] = server.components(separatedBy: ":") + + guard + let port: String = components.last, + ( + port != components.first && + !port.starts(with: "//") + ) + else { return "" } + + return ":\(port)" + } + + public static func isSessionRunCommunity(server: String) -> Bool { + guard let serverUrl: URL = (URL(string: server.lowercased()) ?? URL(string: "http://\(server.lowercased())")) else { + return false + } + + let serverPort: String = CommunityManager.port(for: server, serverUrl: serverUrl) + let serverHost: String = serverUrl.host + .defaulting( + to: server + .lowercased() + .replacingOccurrences(of: serverPort, with: "") + ) + let options: Set = Set([ + Network.SOGS.legacyDefaultServerIP, + Network.SOGS.defaultServer + .replacingOccurrences(of: "http://", with: "") + .replacingOccurrences(of: "https://", with: "") + ]) + + return options.contains(serverHost) + } + + nonisolated public func hasExistingCommunity( + roomToken: String, + server: String, + publicKey: String + ) -> Bool { + guard let serverUrl: URL = URL(string: server.lowercased()) else { return false } + + let serverPort: String = CommunityManager.port(for: server, serverUrl: serverUrl) + let serverHost: String = serverUrl.host + .defaulting( + to: server + .lowercased() + .replacingOccurrences(of: serverPort, with: "") + ) + let defaultServerHost: String = Network.SOGS.defaultServer + .replacingOccurrences(of: "http://", with: "") + .replacingOccurrences(of: "https://", with: "") + var serverOptions: Set = Set([ + server.lowercased(), + "\(serverHost)\(serverPort)", + "http://\(serverHost)\(serverPort)", + "https://\(serverHost)\(serverPort)" + ]) + + /// If the server is run by Session then include all configurations in case one of the alternate configurations was used + if CommunityManager.isSessionRunCommunity(server: server) { + serverOptions.insert(defaultServerHost) + serverOptions.insert("http://\(defaultServerHost)") + serverOptions.insert("https://\(defaultServerHost)") + serverOptions.insert(Network.SOGS.legacyDefaultServerIP) + serverOptions.insert("http://\(Network.SOGS.legacyDefaultServerIP)") + serverOptions.insert("https://\(Network.SOGS.legacyDefaultServerIP)") + } + + /// Check if the result matches an entry in the cache + let cachedServers: [String: Server] = syncState.servers + + return serverOptions.contains { serverName in + cachedServers[serverName.lowercased()]?.rooms[roomToken] != nil + } + } + + nonisolated public func add( + _ db: ObservingDatabase, + roomToken: String, + server: String, + publicKey: String, + joinedAt: TimeInterval, + forceVisible: Bool + ) -> Bool { + /// No need to do anything if the community is already in the cache + if hasExistingCommunity(roomToken: roomToken, server: server, publicKey: publicKey) { + Log.info(.communityManager, "Ignoring join open group attempt (already joined)") + return false + } + + /// Normalize the server + let targetServer: String = { + guard CommunityManager.isSessionRunCommunity(server: server) else { + return server.lowercased() + } + + return Network.SOGS.defaultServer + }() + let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: targetServer) + + /// Optionally try to insert a new version of the `OpenGroup` (it will fail if there is already an inactive one but that won't matter + /// as we then activate it) + _ = try? SessionThread.upsert( + db, + id: threadId, + variant: .community, + values: SessionThread.TargetValues( + creationDateTimestamp: .useExistingOrSetTo(joinedAt), + /// When adding an open group via config handling then we want to force it to be visible (if it did come via config + /// handling then we want to wait until it actually has messages before making it visible) + shouldBeVisible: (forceVisible ? .setTo(true) : .useExisting) + ), + using: syncState.dependencies + ) + + /// Update the state to allow polling and reset the `sequenceNumber` + let openGroup: OpenGroup = OpenGroup + .fetchOrCreate(db, server: targetServer, roomToken: roomToken, publicKey: publicKey) + .with(shouldPoll: .set(to: true), sequenceNumber: .set(to: 0)) + try? openGroup.upsert(db) + + /// Update the cache to have a record of the new room + db.afterCommit { [weak self] in + Task.detached(priority: .userInitiated) { + let targetRooms: [Network.SOGS.Room] + + switch await self?._servers[server.lowercased()] { + case .none: + targetRooms = [Network.SOGS.Room(openGroup: openGroup)] + + case .some(let existingServer): + targetRooms = ( + Array(existingServer.rooms.values) + [Network.SOGS.Room(openGroup: openGroup)] + ) + } + + await self?.updateRooms( + rooms: targetRooms, + server: openGroup.server, + publicKey: openGroup.publicKey, + areDefaultRooms: false + ) + } + } + + return true + } + + nonisolated public func performInitialRequestsAfterAdd( + queue: DispatchQueue, + successfullyAddedGroup: Bool, + roomToken: String, + server: String, + publicKey: String + ) -> AnyPublisher { + // Only bother performing the initial request if the network isn't suspended + guard + successfullyAddedGroup, + !syncState.dependencies[singleton: .storage].isSuspended, + !syncState.dependencies[cache: .libSessionNetwork].isSuspended + else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + // Store the open group information + let targetServer: String = { + guard CommunityManager.isSessionRunCommunity(server: server) else { + return server.lowercased() + } + + return Network.SOGS.defaultServer + }() + + return Result { + try Network.SOGS + .preparedCapabilitiesAndRoom( + roomToken: roomToken, + authMethod: Authentication.Community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: roomToken, + server: server, + publicKey: publicKey, + capabilities: [] /// We won't have `capabilities` before the first request so just hard code + ) + ), + using: syncState.dependencies + ) + } + .publisher + .flatMap { [dependencies = syncState.dependencies] in $0.send(using: dependencies) } + .flatMapStorageWritePublisher(using: syncState.dependencies) { [weak self, dependencies = syncState.dependencies] (db: ObservingDatabase, response: (info: ResponseInfoType, value: Network.SOGS.CapabilitiesAndRoomResponse)) -> Void in + guard let self = self else { throw StorageError.objectNotSaved } + + // Add the new open group to libSession + try LibSession.add( + db, + server: server, + rootToken: roomToken, + publicKey: publicKey, + using: dependencies + ) + + // Store the capabilities first + handleCapabilities( + db, + capabilities: response.value.capabilities.data, + server: targetServer, + publicKey: publicKey + ) + + // Then the room + try handlePollInfo( + db, + pollInfo: Network.SOGS.RoomPollInfo(room: response.value.room.data), + server: targetServer, + roomToken: roomToken, + publicKey: publicKey + ) + } + .handleEvents( + receiveCompletion: { [dependencies = syncState.dependencies] result in + switch result { + case .finished: + // (Re)start the poller if needed (want to force it to poll immediately in the next + // run loop to avoid a big delay before the next poll) + dependencies.mutate(cache: .communityPollers) { cache in + let poller: CommunityPollerType = cache.getOrCreatePoller(for: server.lowercased()) + poller.stop() + poller.startIfNeeded() + } + + case .failure(let error): Log.error(.communityManager, "Failed to join open group with error: \(error).") + } + } + ) + .eraseToAnyPublisher() + } + + nonisolated public func delete( + _ db: ObservingDatabase, + openGroupId: String, + skipLibSessionUpdate: Bool + ) throws { + let server: String? = try? OpenGroup + .select(.server) + .filter(id: openGroupId) + .asRequest(of: String.self) + .fetchOne(db) + let roomToken: String? = try? OpenGroup + .select(.roomToken) + .filter(id: openGroupId) + .asRequest(of: String.self) + .fetchOne(db) + + // Stop the poller if needed + // + // Note: The default room promise creates an OpenGroup with an empty `roomToken` value, + // we don't want to start a poller for this as the user hasn't actually joined a room + let numActiveRooms: Int = (try? OpenGroup + .filter(OpenGroup.Columns.server == server?.lowercased()) + .filter(OpenGroup.Columns.shouldPoll == true) + .fetchCount(db)) + .defaulting(to: 1) + + if numActiveRooms == 1, let server: String = server?.lowercased() { + db.afterCommit { [weak self] in + self?.syncState.dependencies.mutate(cache: .communityPollers) { + $0.stopAndRemovePoller(for: server) + } + } + } + + // Remove all the data (everything should cascade delete) + _ = try? Interaction.deleteWhere(db, .filter(Interaction.Columns.threadId == openGroupId)) + _ = try? SessionThread + .filter(id: openGroupId) + .deleteAll(db) + + db.addConversationEvent( + id: openGroupId, + variant: .community, + type: .deleted + ) + + // Remove any dedupe records (we will want to reprocess all OpenGroup messages if they get re-added) + try MessageDeduplication.deleteIfNeeded(db, threadIds: [openGroupId], using: syncState.dependencies) + + // Remove the open group (no foreign key to the thread so it won't auto-delete) + _ = try? OpenGroup + .filter(id: openGroupId) + .deleteAll(db) + + // Delete any capabilities associated with the room (no foreign key so it won't auto-delete) + if numActiveRooms == 1, let server: String = server { + _ = try? Capability + .filter(Capability.Columns.openGroupServer == server.lowercased()) + .deleteAll(db) + } + + if let server: String = server, let roomToken: String = roomToken { + if !skipLibSessionUpdate { + try LibSession.remove(db, server: server, roomToken: roomToken, using: syncState.dependencies) + } + + db.afterCommit { [weak self] in + Task.detached(priority: .userInitiated) { + await self?.removeRoom(server: server, roomToken: roomToken) + } + } + } + } + + // MARK: - Response Processing + + nonisolated public func handleCapabilities( + _ db: ObservingDatabase, + capabilities: Network.SOGS.CapabilitiesResponse, + server: String, + publicKey: String + ) { + // Remove old capabilities first + _ = try? Capability + .filter(Capability.Columns.openGroupServer == server.lowercased()) + .deleteAll(db) + + // Then insert the new capabilities (both present and missing) + let newCapabilities: Set = Set(capabilities.capabilities + .map { Capability.Variant(from: $0) }) + newCapabilities.forEach { variant in + try? Capability( + openGroupServer: server.lowercased(), + variant: variant, + isMissing: false + ) + .upsert(db) + } + capabilities.missing?.forEach { capability in + try? Capability( + openGroupServer: server.lowercased(), + variant: Capability.Variant(from: capability), + isMissing: true + ) + .upsert(db) + } + + /// Update the `CommunityManager` cache + db.afterCommit { [weak self] in + Task.detached(priority: .userInitiated) { + await self?.updateCapabilities( + capabilities: newCapabilities, + server: server, + publicKey: publicKey + ) + } + } + } + + nonisolated public func handlePollInfo( + _ db: ObservingDatabase, + pollInfo: Network.SOGS.RoomPollInfo, + server: String, + roomToken: String, + publicKey: String + ) throws { + // Create the open group model and get or create the thread + let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) + + guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return } + + // Only update the database columns which have changed (this is to prevent the UI from triggering + // updates due to changing database columns to the existing value) + let hasDetails: Bool = (pollInfo.details != nil) + let permissions: OpenGroup.Permissions = OpenGroup.Permissions(roomInfo: pollInfo) + let changes: [ConfigColumnAssignment] = [] + .appending(openGroup.publicKey == publicKey ? nil : + OpenGroup.Columns.publicKey.set(to: publicKey) + ) + .appending(openGroup.userCount == pollInfo.activeUsers ? nil : + OpenGroup.Columns.userCount.set(to: pollInfo.activeUsers) + ) + .appending(openGroup.permissions == permissions ? nil : + OpenGroup.Columns.permissions.set(to: permissions) + ) + .appending(!hasDetails || openGroup.name == pollInfo.details?.name ? nil : + OpenGroup.Columns.name.set(to: pollInfo.details?.name) + ) + .appending(!hasDetails || openGroup.roomDescription == pollInfo.details?.roomDescription ? nil : + OpenGroup.Columns.roomDescription.set(to: pollInfo.details?.roomDescription) + ) + .appending(!hasDetails || openGroup.imageId == pollInfo.details?.imageId ? nil : + OpenGroup.Columns.imageId.set(to: pollInfo.details?.imageId) + ) + .appending(!hasDetails || openGroup.infoUpdates == pollInfo.details?.infoUpdates ? nil : + OpenGroup.Columns.infoUpdates.set(to: pollInfo.details?.infoUpdates) + ) + + try OpenGroup + .filter(id: openGroup.id) + .updateAllAndConfig(db, changes, using: syncState.dependencies) + + // Update the admin/moderator group members + if let roomDetails: Network.SOGS.Room = pollInfo.details { + let oldMembers: [GroupMember]? = try? GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .fetchAll(db) + _ = try? GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .deleteAll(db) + + try roomDetails.admins.forEach { adminId in + try GroupMember( + groupId: threadId, + profileId: adminId, + role: .admin, + roleStatus: .accepted, // Community members don't have role statuses + isHidden: false + ).upsert(db) + } + + try roomDetails.hiddenAdmins + .defaulting(to: []) + .forEach { adminId in + try GroupMember( + groupId: threadId, + profileId: adminId, + role: .admin, + roleStatus: .accepted, // Community members don't have role statuses + isHidden: true + ).upsert(db) + } + + try roomDetails.moderators.forEach { moderatorId in + try GroupMember( + groupId: threadId, + profileId: moderatorId, + role: .moderator, + roleStatus: .accepted, // Community members don't have role statuses + isHidden: false + ).upsert(db) + } + + try roomDetails.hiddenModerators + .defaulting(to: []) + .forEach { moderatorId in + try GroupMember( + groupId: threadId, + profileId: moderatorId, + role: .moderator, + roleStatus: .accepted, // Community members don't have role statuses + isHidden: true + ).upsert(db) + } + + /// Schedule an event to be sent + let oldAdmins: Set = Set((oldMembers? + .filter { $0.role == .admin && !$0.isHidden } + .map { $0.profileId }) ?? []) + let oldHiddenAdmins: Set = Set((oldMembers? + .filter { $0.role == .admin && $0.isHidden } + .map { $0.profileId }) ?? []) + let oldMods: Set = Set((oldMembers? + .filter { $0.role == .moderator && !$0.isHidden } + .map { $0.profileId }) ?? []) + let oldHiddenMods: Set = Set((oldMembers? + .filter { $0.role == .moderator && !$0.isHidden } + .map { $0.profileId }) ?? []) + let newAdmins: Set = Set(roomDetails.admins) + let newHiddenAdmins: Set = Set(roomDetails.hiddenAdmins ?? []) + let newMods: Set = Set(roomDetails.moderators) + let newHiddenMods: Set = Set(roomDetails.hiddenModerators ?? []) + + if + oldAdmins != newAdmins || + oldHiddenAdmins != newHiddenAdmins || + oldMods != newMods || + oldHiddenMods != newHiddenMods + { + db.addCommunityEvent( + id: threadId, + change: .moderatorsAndAdmins( + admins: Array(newAdmins), + hiddenAdmins: Array(newHiddenAdmins), + moderators: Array(newMods), + hiddenModerators: Array(newHiddenMods) + ) + ) + } + + /// Update the `CommunityManager` cache + db.afterCommit { [weak self] in + Task.detached(priority: .userInitiated) { + let targetRooms: [Network.SOGS.Room] + + switch await self?._servers[server.lowercased()] { + case .none: + targetRooms = [roomDetails] + + case .some(let existingServer): + targetRooms = (Array(existingServer.rooms.values) + [roomDetails]) + } + + await self?.updateRooms( + rooms: targetRooms, + server: openGroup.server, + publicKey: openGroup.publicKey, + areDefaultRooms: false + ) + } + } + } + + /// Schedule the room image download (if we don't have one or it's been updated) + if + let imageId: String = (pollInfo.details?.imageId ?? openGroup.imageId), + ( + openGroup.displayPictureOriginalUrl == nil || + openGroup.imageId != imageId + ) + { + syncState.dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .community( + imageId: imageId, + roomToken: openGroup.roomToken, + server: openGroup.server + ), + timestamp: (syncState.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + ) + ), + canStartJob: true + ) + } + + /// Emit events + if hasDetails { + if openGroup.name != pollInfo.details?.name { + db.addConversationEvent( + id: openGroup.id, + variant: .community, + type: .updated(.displayName(pollInfo.details?.name ?? openGroup.name)) + ) + } + + if openGroup.roomDescription == pollInfo.details?.roomDescription { + db.addConversationEvent( + id: openGroup.id, + variant: .community, + type: .updated(.description(pollInfo.details?.roomDescription)) + ) + } + + if pollInfo.details?.imageId == nil { + db.addConversationEvent( + id: openGroup.id, + variant: .community, + type: .updated(.displayPictureUrl(nil)) + ) + } + } + } + + nonisolated public func handleMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.Message], + server: String, + roomToken: String, + currentUserSessionIds: Set + ) -> [MessageReceiver.InsertedInteractionInfo?] { + guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) else { + Log.error(.communityManager, "Couldn't handle open group messages due to missing group.") + return [] + } + + /// Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't + /// find those older messages + let previousMessageCount: Int = ((try? Interaction + .filter(Interaction.Columns.id == openGroup.id) + .fetchCount(db)) ?? 0) + let sortedMessages: [Network.SOGS.Message] = messages + .filter { $0.deleted != true } + .sorted { lhs, rhs in lhs.id < rhs.id } + var messageServerInfoToRemove: [(id: Int64, seqNo: Int64)] = messages + .filter { $0.deleted == true } + .map { ($0.id, $0.seqNo) } + var largestValidSeqNo: Int64 = openGroup.sequenceNumber + var insertedInteractionInfo: [MessageReceiver.InsertedInteractionInfo?] = [] + + // Process the messages + sortedMessages.forEach { message in + if message.base64EncodedData == nil && message.reactions == nil { + messageServerInfoToRemove.append((message.id, message.seqNo)) + return + } + + // Handle messages + if + let base64EncodedString: String = message.base64EncodedData, + let data = Data(base64Encoded: base64EncodedString), + let sender: String = message.sender, + let posted: TimeInterval = message.posted + { + do { + let processedMessage: ProcessedMessage = try MessageReceiver.parse( + data: data, + origin: .community( + openGroupId: openGroup.id, + sender: sender, + posted: posted, + messageServerId: message.id, + whisper: message.whisper, + whisperMods: message.whisperMods, + whisperTo: message.whisperTo + ), + using: syncState.dependencies + ) + try MessageDeduplication.insert( + db, + processedMessage: processedMessage, + ignoreDedupeFiles: false, + using: syncState.dependencies + ) + + switch processedMessage { + case .config: break + case .standard(_, _, let messageInfo, _): + insertedInteractionInfo.append( + try MessageReceiver.handle( + db, + threadId: openGroup.id, + threadVariant: .community, + message: messageInfo.message, + decodedMessage: messageInfo.decodedMessage, + serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, + suppressNotifications: false, + currentUserSessionIds: currentUserSessionIds, + using: syncState.dependencies + ) + ) + largestValidSeqNo = max(largestValidSeqNo, message.seqNo) + } + } + catch { + switch error { + // Ignore duplicate & selfSend message errors (and don't bother logging + // them as there will be a lot since we each service node duplicates messages) + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, + DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE + MessageError.duplicateMessage, + MessageError.selfSend: + break + + default: + Log.error(.communityManager, "Couldn't receive open group message due to error: \(error).") + } + } + } + + // Handle reactions + if message.reactions != nil { + do { + let reactions: [Reaction] = Message.processRawReceivedReactions( + db, + openGroupId: openGroup.id, + message: message, + associatedPendingChanges: syncPendingChanges.filter { + guard $0.server == server && $0.room == roomToken && $0.changeType == .reaction else { + return false + } + + if case .reaction(let messageId, _, _) = $0.metadata { + return messageId == message.id + } + return false + }, + using: syncState.dependencies + ) + + try MessageReceiver.handleOpenGroupReactions( + db, + threadId: openGroup.threadId, + openGroupMessageServerId: message.id, + openGroupReactions: reactions + ) + largestValidSeqNo = max(largestValidSeqNo, message.seqNo) + } + catch { + Log.error(.communityManager, "Couldn't handle open group reactions due to error: \(error).") + } + } + } + + // Handle any deletions that are needed + if !messageServerInfoToRemove.isEmpty { + let messageServerIdsToRemove: [Int64] = messageServerInfoToRemove.map { $0.id } + _ = try? Interaction.deleteWhere( + db, + .filter(Interaction.Columns.threadId == openGroup.threadId), + .filter(messageServerIdsToRemove.contains(Interaction.Columns.openGroupServerMessageId)) + ) + + // Update the seqNo for deletions + largestValidSeqNo = max(largestValidSeqNo, (messageServerInfoToRemove.map({ $0.seqNo }).max() ?? 0)) + } + + // If we didn't previously have any messages for this community then we should notify that the + // initial fetch has now been completed + if previousMessageCount == 0 { + db.addCommunityEvent(id: openGroup.id, change: .receivedInitialMessages(sortedMessages)) + } + + // Now that we've finished processing all valid message changes we can update the `sequenceNumber` to + // the `largestValidSeqNo` value + _ = try? OpenGroup + .filter(id: openGroup.id) + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: largestValidSeqNo)) + + // Update pendingChange cache based on the `largestValidSeqNo` value + db.afterCommit { [weak self] in + Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + + await setPendingChanges( + pendingChanges.filter { + $0.seqNo == nil || ($0.seqNo ?? 0) > largestValidSeqNo + } + ) + + let targetRooms: [Network.SOGS.Room] + + switch await self.server(server) { + case .none: + targetRooms = [Network.SOGS.Room(openGroup: openGroup)] + + case .some(let existingServer): + targetRooms = ( + Array(existingServer.rooms.values) + [Network.SOGS.Room(openGroup: openGroup)] + ) + } + + await updateRooms( + rooms: targetRooms, + server: openGroup.server, + publicKey: openGroup.publicKey, + areDefaultRooms: false + ) + } + } + + return insertedInteractionInfo + } + + nonisolated public func handleDirectMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.DirectMessage], + fromOutbox: Bool, + server: String, + currentUserSessionIds: Set + ) -> [MessageReceiver.InsertedInteractionInfo?] { + // Don't need to do anything if we have no messages (it's a valid case) + guard !messages.isEmpty else { return [] } + guard let openGroup: OpenGroup = try? OpenGroup.filter(OpenGroup.Columns.server == server.lowercased()).fetchOne(db) else { + Log.error(.communityManager, "Couldn't receive inbox message due to missing group.") + return [] + } + + // Sorting the messages by server ID before importing them fixes an issue where messages + // that quote older messages can't find those older messages + let sortedMessages: [Network.SOGS.DirectMessage] = messages + .sorted { lhs, rhs in lhs.id < rhs.id } + let latestMessageId: Int64 = sortedMessages[sortedMessages.count - 1].id + var lookupCache: [String: BlindedIdLookup] = [:] // Only want this cache to exist for the current loop + var insertedInteractionInfo: [MessageReceiver.InsertedInteractionInfo?] = [] + + // Update the 'latestMessageId' value + if fromOutbox { + _ = try? OpenGroup + .filter(OpenGroup.Columns.server == server.lowercased()) + .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: latestMessageId)) + } + else { + _ = try? OpenGroup + .filter(OpenGroup.Columns.server == server.lowercased()) + .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: latestMessageId)) + } + + db.afterCommit { [weak self] in + Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + + if let server: Server = await self.server(server) { + if fromOutbox { + await updateServer( + server: server.with( + outboxLatestMessageId: .set(to: latestMessageId), + using: dependencies + ) + ) + } + else { + await updateServer( + server: server.with( + inboxLatestMessageId: .set(to: latestMessageId), + using: dependencies + ) + ) + } + } + } + } + + // Process the messages + sortedMessages.forEach { message in + guard let messageData = Data(base64Encoded: message.base64EncodedMessage) else { + Log.error(.communityManager, "Couldn't receive inbox message.") + return + } + + do { + let processedMessage: ProcessedMessage = try MessageReceiver.parse( + data: messageData, + origin: .communityInbox( + posted: message.posted, + messageServerId: message.id, + serverPublicKey: openGroup.publicKey, + senderId: message.sender, + recipientId: message.recipient + ), + using: syncState.dependencies + ) + try MessageDeduplication.insert( + db, + processedMessage: processedMessage, + ignoreDedupeFiles: false, + using: syncState.dependencies + ) + + switch processedMessage { + case .config: break + case .standard(let threadId, _, let messageInfo, _): + /// We want to update the BlindedIdLookup cache with the message info so we can avoid using the + /// "expensive" lookup when possible + let lookup: BlindedIdLookup = try { + /// Minor optimisation to avoid processing the same sender multiple times in the same + /// 'handleMessages' call (since the 'mapping' call is done within a transaction we + /// will never have a mapping come through part-way through processing these messages) + if let result: BlindedIdLookup = lookupCache[message.recipient] { + return result + } + + return try BlindedIdLookup.fetchOrCreate( + db, + blindedId: (fromOutbox ? + message.recipient : + message.sender + ), + sessionId: (fromOutbox ? + nil : + threadId + ), + openGroupServer: server.lowercased(), + openGroupPublicKey: openGroup.publicKey, + isCheckingForOutbox: fromOutbox, + using: syncState.dependencies + ) + }() + lookupCache[message.recipient] = lookup + + // We also need to set the 'syncTarget' for outgoing messages so the behaviour + // to determine the threadId is consistent with standard messages + if fromOutbox { + let syncTarget: String = (lookup.sessionId ?? message.recipient) + + switch messageInfo.variant { + case .visibleMessage: + (messageInfo.message as? VisibleMessage)?.syncTarget = syncTarget + + case .expirationTimerUpdate: + (messageInfo.message as? ExpirationTimerUpdate)?.syncTarget = syncTarget + + default: break + } + } + + insertedInteractionInfo.append( + try MessageReceiver.handle( + db, + threadId: (lookup.sessionId ?? lookup.blindedId), + threadVariant: .contact, // Technically not open group messages + message: messageInfo.message, + decodedMessage: messageInfo.decodedMessage, + serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, + suppressNotifications: false, + currentUserSessionIds: currentUserSessionIds, + using: syncState.dependencies + ) + ) + } + } + catch { + switch error { + // Ignore duplicate and self-send errors (we will always receive a duplicate message back + // whenever we send a message so this ends up being spam otherwise) + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, + DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE + MessageError.duplicateMessage, + MessageError.selfSend: + break + + default: + Log.error(.communityManager, "Couldn't receive inbox message due to error: \(error).") + } + } + } + + return insertedInteractionInfo + } + + // MARK: - Convenience + + public func addPendingReaction( + emoji: String, + id: Int64, + in roomToken: String, + on server: String, + type: PendingChange.ReactAction + ) async -> PendingChange { + let pendingChange: PendingChange = PendingChange( + server: server, + room: roomToken, + changeType: .reaction, + metadata: .reaction( + messageId: id, + emoji: emoji, + action: type + ) + ) + pendingChanges.append(pendingChange) + syncState.update(pendingChanges: .set(to: pendingChanges)) + + return pendingChange + } + + public func setPendingChanges(_ pendingChanges: [CommunityManager.PendingChange]) async { + self.pendingChanges = pendingChanges + syncState.update(pendingChanges: .set(to: self.pendingChanges)) + } + + public func updatePendingChange(_ pendingChange: PendingChange, seqNo: Int64?) async { + if let index = pendingChanges.firstIndex(of: pendingChange) { + pendingChanges[index].seqNo = seqNo + syncState.update(pendingChanges: .set(to: pendingChanges)) + } + } + + public func removePendingChange(_ pendingChange: PendingChange) async { + if let index = pendingChanges.firstIndex(of: pendingChange) { + pendingChanges.remove(at: index) + syncState.update(pendingChanges: .set(to: pendingChanges)) + } + } + + /// This method specifies if the given capability is supported on a specified Open Group + public func doesOpenGroupSupport( + capability: Capability.Variant, + on maybeServer: String? + ) async -> Bool { + guard + let serverString: String = maybeServer, + let cachedServer: Server = await server(serverString) + else { return false } + + return cachedServer.capabilities.contains(capability) + } + + public func allModeratorsAndAdmins( + server maybeServer: String?, + roomToken: String?, + includingHidden: Bool + ) async -> Set { + guard + let roomToken: String = roomToken, + let serverString: String = maybeServer, + let cachedServer: Server = await server(serverString), + let room: Network.SOGS.Room = cachedServer.rooms[roomToken] + else { return [] } + + return CommunityManager.allModeratorsAndAdmins(room: room, includingHidden: includingHidden) + } + + /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group + public func isUserModeratorOrAdmin( + targetUserPublicKey: String, + server maybeServer: String?, + roomToken: String?, + includingHidden: Bool + ) async -> Bool { + guard + let roomToken: String = roomToken, + let serverString: String = maybeServer, + let cachedServer: Server = await server(serverString), + let room: Network.SOGS.Room = cachedServer.rooms[roomToken] + else { return false } + + /// If the `publicKey` belongs to the current user then we should check against any of their pubkey possibilities + let possibleKeys: Set = (cachedServer.currentUserSessionIds.contains(targetUserPublicKey) ? + cachedServer.currentUserSessionIds : + [targetUserPublicKey] + ) + + /// Check if the `publicKey` matches a visible admin or moderator + let isVisibleModOrAdmin: Bool = ( + !possibleKeys.isDisjoint(with: Set(room.admins)) || + !possibleKeys.isDisjoint(with: Set(room.moderators)) + ) + + /// If they are a visible admin/mod, or we don't want to consider hidden admins/mods, then no need to continue + if isVisibleModOrAdmin || !includingHidden { + return isVisibleModOrAdmin + } + + /// Check if the `publicKey` is a hidden admin/mod + return ( + !possibleKeys.isDisjoint(with: Set(room.hiddenAdmins ?? [])) || + !possibleKeys.isDisjoint(with: Set(room.hiddenModerators ?? [])) + ) + } +} + +public extension CommunityManagerType { + static func allModeratorsAndAdmins( + room: Network.SOGS.Room, + includingHidden: Bool + ) -> Set { + var result: Set = Set(room.admins + room.moderators) + + if includingHidden { + result.insert(contentsOf: Set(room.hiddenAdmins ?? [])) + result.insert(contentsOf: Set(room.hiddenModerators ?? [])) + } + + return result + } +} + +// MARK: - SyncState + +private final class CommunityManagerSyncState { + private let lock: NSLock = NSLock() + private let _dependencies: Dependencies + private var _servers: [String: CommunityManager.Server] = [:] + private var _pendingChanges: [CommunityManager.PendingChange] = [] + + @available(*, deprecated, message: "Remove this alongside 'getLastSuccessfulCommunityPollTimestampSync'") + private var _lastSuccessfulCommunityPollTimestamp: TimeInterval? = nil + + fileprivate var dependencies: Dependencies { lock.withLock { _dependencies } } + fileprivate var servers: [String: CommunityManager.Server] { lock.withLock { _servers } } + fileprivate var pendingChanges: [CommunityManager.PendingChange] { lock.withLock { _pendingChanges } } + fileprivate var lastSuccessfulCommunityPollTimestamp: TimeInterval? { + lock.withLock { _lastSuccessfulCommunityPollTimestamp } + } + + fileprivate init(using dependencies: Dependencies) { + self._dependencies = dependencies + } + + fileprivate func update( + servers: Update<[String: CommunityManager.Server]> = .useExisting, + pendingChanges: Update<[CommunityManager.PendingChange]> = .useExisting, + lastSuccessfulCommunityPollTimestamp: Update = .useExisting + ) { + lock.withLock { + self._servers = servers.or(self._servers) + self._pendingChanges = pendingChanges.or(self._pendingChanges) + self._lastSuccessfulCommunityPollTimestamp = lastSuccessfulCommunityPollTimestamp + .or(self._lastSuccessfulCommunityPollTimestamp) + } + } +} + +// MARK: - CommunityManagerType + +public protocol CommunityManagerType { + nonisolated var defaultRooms: AsyncStream<(rooms: [Network.SOGS.Room], lastError: Error?)> { get } + var pendingChanges: [CommunityManager.PendingChange] { get async } + nonisolated var syncPendingChanges: [CommunityManager.PendingChange] { get } + + // MARK: - Cache + + nonisolated func getLastSuccessfulCommunityPollTimestampSync() -> TimeInterval + func getLastSuccessfulCommunityPollTimestamp() async -> TimeInterval + func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) async + + @available(*, deprecated, message: "use `server(_:)?.currentUserSessionIds` instead") + nonisolated func currentUserSessionIdsSync(_ server: String) -> Set + + func fetchDefaultRoomsIfNeeded() async + func loadCacheIfNeeded() async + + func server(_ server: String) async -> CommunityManager.Server? + func server(threadId: String) async -> CommunityManager.Server? + func serversByThreadId() async -> [String: CommunityManager.Server] + func updateServer(server: CommunityManager.Server) async + func updateCapabilities( + capabilities: Set, + server: String, + publicKey: String + ) async + func updateRooms( + rooms: [Network.SOGS.Room], + server: String, + publicKey: String, + areDefaultRooms: Bool + ) async + + // MARK: - Adding & Removing + + func hasExistingCommunity(roomToken: String, server: String, publicKey: String) async -> Bool + + nonisolated func add( + _ db: ObservingDatabase, + roomToken: String, + server: String, + publicKey: String, + joinedAt: TimeInterval, + forceVisible: Bool + ) -> Bool + nonisolated func performInitialRequestsAfterAdd( + queue: DispatchQueue, + successfullyAddedGroup: Bool, + roomToken: String, + server: String, + publicKey: String + ) -> AnyPublisher + nonisolated func delete( + _ db: ObservingDatabase, + openGroupId: String, + skipLibSessionUpdate: Bool + ) throws + + // MARK: - Response Processing + + nonisolated func handleCapabilities( + _ db: ObservingDatabase, + capabilities: Network.SOGS.CapabilitiesResponse, + server: String, + publicKey: String + ) + nonisolated func handlePollInfo( + _ db: ObservingDatabase, + pollInfo: Network.SOGS.RoomPollInfo, + server: String, + roomToken: String, + publicKey: String + ) throws + nonisolated func handleMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.Message], + server: String, + roomToken: String, + currentUserSessionIds: Set + ) -> [MessageReceiver.InsertedInteractionInfo?] + nonisolated func handleDirectMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.DirectMessage], + fromOutbox: Bool, + server: String, + currentUserSessionIds: Set + ) -> [MessageReceiver.InsertedInteractionInfo?] + + // MARK: - Convenience + + func addPendingReaction( + emoji: String, + id: Int64, + in roomToken: String, + on server: String, + type: CommunityManager.PendingChange.ReactAction + ) async -> CommunityManager.PendingChange + func setPendingChanges(_ pendingChanges: [CommunityManager.PendingChange]) async + func updatePendingChange(_ pendingChange: CommunityManager.PendingChange, seqNo: Int64?) async + func removePendingChange(_ pendingChange: CommunityManager.PendingChange) async + + func doesOpenGroupSupport( + capability: Capability.Variant, + on maybeServer: String? + ) async -> Bool + func allModeratorsAndAdmins( + server maybeServer: String?, + roomToken: String?, + includingHidden: Bool + ) async -> Set + func isUserModeratorOrAdmin( + targetUserPublicKey: String, + server maybeServer: String?, + roomToken: String?, + includingHidden: Bool + ) async -> Bool +} + +// MARK: - Observations + +// stringlint:ignore_contents +public extension ObservableKey { + static func communityUpdated(_ id: String) -> ObservableKey { + ObservableKey("communityUpdated-\(id)", .communityUpdated) + } +} + +// stringlint:ignore_contents +public extension GenericObservableKey { + static let communityUpdated: GenericObservableKey = "communityUpdated" +} + +// MARK: - Event Payloads - Conversations + +public struct CommunityEvent: Hashable { + public let id: String + public let change: Change + + public enum Change: Hashable { + case receivedInitialMessages([Network.SOGS.Message]) + case capabilities([Capability.Variant]) + case permissions(read: Bool, write: Bool, upload: Bool) + case role(moderator: Bool, admin: Bool, hiddenModerator: Bool, hiddenAdmin: Bool) + case moderatorsAndAdmins(admins: [String], hiddenAdmins: [String], moderators: [String], hiddenModerators: [String]) + } +} + +public extension ObservingDatabase { + func addCommunityEvent(id: String, change: CommunityEvent.Change) { + let event: CommunityEvent = CommunityEvent(id: id, change: change) + addEvent(ObservedEvent(key: .communityUpdated(id), value: event)) + } +} diff --git a/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroup.swift b/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroup.swift index 2ff8f76fc6..c447dadc73 100644 --- a/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroup.swift +++ b/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroup.swift @@ -9,47 +9,8 @@ import SessionUtilitiesKit // MARK: - Messages public extension Crypto.Generator { - static func ciphertextWithSessionBlindingProtocol( - plaintext: Data, - recipientBlindedId: String, - serverPublicKey: String - ) -> Crypto.Generator { - return Crypto.Generator( - id: "ciphertextWithSessionBlindingProtocol", - args: [plaintext, serverPublicKey] - ) { dependencies in - var cPlaintext: [UInt8] = Array(plaintext) - var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey - var cRecipientBlindedId: [UInt8] = Array(Data(hex: recipientBlindedId)) - var cServerPublicKey: [UInt8] = Array(Data(hex: serverPublicKey)) - var maybeCiphertext: UnsafeMutablePointer? = nil - var ciphertextLen: Int = 0 - - guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } - guard - cEd25519SecretKey.count == 64, - cServerPublicKey.count == 32, - session_encrypt_for_blinded_recipient( - &cPlaintext, - cPlaintext.count, - &cEd25519SecretKey, - &cServerPublicKey, - &cRecipientBlindedId, - &maybeCiphertext, - &ciphertextLen - ), - ciphertextLen > 0, - let ciphertext: Data = maybeCiphertext.map({ Data(bytes: $0, count: ciphertextLen) }) - else { throw MessageSenderError.encryptionFailed } - - free(UnsafeMutableRawPointer(mutating: maybeCiphertext)) - - return ciphertext - } - } - - static func plaintextWithSessionBlindingProtocol( - ciphertext: Data, + static func plaintextWithSessionBlindingProtocol( + ciphertext: I, senderId: String, recipientId: String, serverPublicKey: String @@ -67,7 +28,7 @@ public extension Crypto.Generator { var maybePlaintext: UnsafeMutablePointer? = nil var plaintextLen: Int = 0 - guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } + guard !cEd25519SecretKey.isEmpty else { throw CryptoError.missingUserSecretKey } guard cEd25519SecretKey.count == 64, cServerPublicKey.count == 32, @@ -84,7 +45,7 @@ public extension Crypto.Generator { ), plaintextLen > 0, let plaintext: Data = maybePlaintext.map({ Data(bytes: $0, count: plaintextLen) }) - else { throw MessageReceiverError.decryptionFailed } + else { throw MessageError.decodingFailed } free(UnsafeMutableRawPointer(mutating: maybePlaintext)) diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift deleted file mode 100644 index 156436ba0d..0000000000 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ /dev/null @@ -1,1108 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import GRDB -import SessionUtilitiesKit -import SessionNetworkingKit - -// MARK: - Singleton - -public extension Singleton { - static let openGroupManager: SingletonConfig = Dependencies.create( - identifier: "openGroupManager", - createInstance: { dependencies in OpenGroupManager(using: dependencies) } - ) -} - -// MARK: - Cache - -public extension Cache { - static let openGroupManager: CacheConfig = Dependencies.create( - identifier: "openGroupManager", - createInstance: { dependencies in OpenGroupManager.Cache(using: dependencies) }, - mutableInstance: { $0 }, - immutableInstance: { $0 } - ) -} - -// MARK: - Log.Category - -public extension Log.Category { - static let openGroup: Log.Category = .create("OpenGroup", defaultLevel: .info) -} - -// MARK: - OpenGroupManager - -public final class OpenGroupManager { - public typealias DefaultRoomInfo = (room: Network.SOGS.Room, openGroup: OpenGroup) - - private let dependencies: Dependencies - - // MARK: - Initialization - - init(using dependencies: Dependencies) { - self.dependencies = dependencies - } - - // MARK: - Adding & Removing - - // stringlint:ignore_contents - private static func port(for server: String, serverUrl: URL) -> String { - if let port: Int = serverUrl.port { - return ":\(port)" - } - - let components: [String] = server.components(separatedBy: ":") - - guard - let port: String = components.last, - ( - port != components.first && - !port.starts(with: "//") - ) - else { return "" } - - return ":\(port)" - } - - public static func isSessionRunOpenGroup(server: String) -> Bool { - guard let serverUrl: URL = (URL(string: server.lowercased()) ?? URL(string: "http://\(server.lowercased())")) else { - return false - } - - let serverPort: String = OpenGroupManager.port(for: server, serverUrl: serverUrl) - let serverHost: String = serverUrl.host - .defaulting( - to: server - .lowercased() - .replacingOccurrences(of: serverPort, with: "") - ) - let options: Set = Set([ - Network.SOGS.legacyDefaultServerIP, - Network.SOGS.defaultServer - .replacingOccurrences(of: "http://", with: "") - .replacingOccurrences(of: "https://", with: "") - ]) - - return options.contains(serverHost) - } - - public func hasExistingOpenGroup( - _ db: ObservingDatabase, - roomToken: String, - server: String, - publicKey: String - ) -> Bool { - guard let serverUrl: URL = URL(string: server.lowercased()) else { return false } - - let serverPort: String = OpenGroupManager.port(for: server, serverUrl: serverUrl) - let serverHost: String = serverUrl.host - .defaulting( - to: server - .lowercased() - .replacingOccurrences(of: serverPort, with: "") - ) - let defaultServerHost: String = Network.SOGS.defaultServer - .replacingOccurrences(of: "http://", with: "") - .replacingOccurrences(of: "https://", with: "") - var serverOptions: Set = Set([ - server.lowercased(), - "\(serverHost)\(serverPort)", - "http://\(serverHost)\(serverPort)", - "https://\(serverHost)\(serverPort)" - ]) - - // If the server is run by Session then include all configurations in case one of the alternate configurations - // was used - if OpenGroupManager.isSessionRunOpenGroup(server: server) { - serverOptions.insert(defaultServerHost) - serverOptions.insert("http://\(defaultServerHost)") - serverOptions.insert("https://\(defaultServerHost)") - serverOptions.insert(Network.SOGS.legacyDefaultServerIP) - serverOptions.insert("http://\(Network.SOGS.legacyDefaultServerIP)") - serverOptions.insert("https://\(Network.SOGS.legacyDefaultServerIP)") - } - - // First check if there is no poller for the specified server - if Set(dependencies[cache: .communityPollers].serversBeingPolled).intersection(serverOptions).isEmpty { - return false - } - - // Then check if there is an existing open group thread - let hasExistingThread: Bool = serverOptions.contains(where: { serverName in - (try? SessionThread - .exists( - db, - id: OpenGroup.idFor(roomToken: roomToken, server: serverName) - )) - .defaulting(to: false) - }) - - return hasExistingThread - } - - public func add( - _ db: ObservingDatabase, - roomToken: String, - server: String, - publicKey: String, - joinedAt: TimeInterval, - forceVisible: Bool - ) -> Bool { - // If we are currently polling for this server and already have a TSGroupThread for this room the do nothing - if hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey) { - Log.info(.openGroup, "Ignoring join open group attempt (already joined)") - return false - } - - // Store the open group information - let targetServer: String = { - guard OpenGroupManager.isSessionRunOpenGroup(server: server) else { - return server.lowercased() - } - - return Network.SOGS.defaultServer - }() - let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: targetServer) - - // Optionally try to insert a new version of the OpenGroup (it will fail if there is already an - // inactive one but that won't matter as we then activate it) - _ = try? SessionThread.upsert( - db, - id: threadId, - variant: .community, - values: SessionThread.TargetValues( - creationDateTimestamp: .useExistingOrSetTo(joinedAt), - /// When adding an open group via config handling then we want to force it to be visible (if it did come via config - /// handling then we want to wait until it actually has messages before making it visible) - shouldBeVisible: (forceVisible ? .setTo(true) : .useExisting) - ), - using: dependencies - ) - - if (try? OpenGroup.exists(db, id: threadId)) == false { - try? OpenGroup - .fetchOrCreate(db, server: targetServer, roomToken: roomToken, publicKey: publicKey) - .upsert(db) - } - - // Set the group to active and reset the sequenceNumber (handle groups which have - // been deactivated) - _ = try? OpenGroup - .filter(id: OpenGroup.idFor(roomToken: roomToken, server: targetServer)) - .updateAllAndConfig( - db, - OpenGroup.Columns.isActive.set(to: true), - OpenGroup.Columns.sequenceNumber.set(to: 0), - using: dependencies - ) - - return true - } - - public func performInitialRequestsAfterAdd( - queue: DispatchQueue, - successfullyAddedGroup: Bool, - roomToken: String, - server: String, - publicKey: String - ) -> AnyPublisher { - // Only bother performing the initial request if the network isn't suspended - guard - successfullyAddedGroup, - !dependencies[singleton: .storage].isSuspended, - !dependencies[cache: .libSessionNetwork].isSuspended - else { - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - // Store the open group information - let targetServer: String = { - guard OpenGroupManager.isSessionRunOpenGroup(server: server) else { - return server.lowercased() - } - - return Network.SOGS.defaultServer - }() - - return Result { - try Network.SOGS - .preparedCapabilitiesAndRoom( - roomToken: roomToken, - authMethod: Authentication.community( - info: LibSession.OpenGroupCapabilityInfo( - roomToken: roomToken, - server: server, - publicKey: publicKey, - capabilities: [] /// We won't have `capabilities` before the first request so just hard code - ) - ), - using: dependencies - ) - } - .publisher - .flatMap { [dependencies] in $0.send(using: dependencies) } - .flatMapStorageWritePublisher(using: dependencies) { [dependencies] (db: ObservingDatabase, response: (info: ResponseInfoType, value: Network.SOGS.CapabilitiesAndRoomResponse)) -> Void in - // Add the new open group to libSession - try LibSession.add( - db, - server: server, - rootToken: roomToken, - publicKey: publicKey, - using: dependencies - ) - - // Store the capabilities first - OpenGroupManager.handleCapabilities( - db, - capabilities: response.value.capabilities.data, - on: targetServer - ) - - // Then the room - try OpenGroupManager.handlePollInfo( - db, - pollInfo: Network.SOGS.RoomPollInfo(room: response.value.room.data), - publicKey: publicKey, - for: roomToken, - on: targetServer, - using: dependencies - ) - } - .handleEvents( - receiveCompletion: { [dependencies] result in - switch result { - case .finished: - // (Re)start the poller if needed (want to force it to poll immediately in the next - // run loop to avoid a big delay before the next poll) - dependencies.mutate(cache: .communityPollers) { cache in - let poller: CommunityPollerType = cache.getOrCreatePoller(for: server.lowercased()) - poller.stop() - poller.startIfNeeded() - } - - case .failure(let error): Log.error(.openGroup, "Failed to join open group with error: \(error).") - } - } - ) - .eraseToAnyPublisher() - } - - public func delete( - _ db: ObservingDatabase, - openGroupId: String, - skipLibSessionUpdate: Bool - ) throws { - let server: String? = try? OpenGroup - .select(.server) - .filter(id: openGroupId) - .asRequest(of: String.self) - .fetchOne(db) - let roomToken: String? = try? OpenGroup - .select(.roomToken) - .filter(id: openGroupId) - .asRequest(of: String.self) - .fetchOne(db) - - // Stop the poller if needed - // - // Note: The default room promise creates an OpenGroup with an empty `roomToken` value, - // we don't want to start a poller for this as the user hasn't actually joined a room - let numActiveRooms: Int = (try? OpenGroup - .filter(OpenGroup.Columns.server == server?.lowercased()) - .filter(OpenGroup.Columns.roomToken != "") - .filter(OpenGroup.Columns.isActive) - .fetchCount(db)) - .defaulting(to: 1) - - if numActiveRooms == 1, let server: String = server?.lowercased() { - dependencies.mutate(cache: .communityPollers) { - $0.stopAndRemovePoller(for: server) - } - } - - // Remove all the data (everything should cascade delete) - _ = try? Interaction.deleteWhere(db, .filter(Interaction.Columns.threadId == openGroupId)) - _ = try? SessionThread - .filter(id: openGroupId) - .deleteAll(db) - - db.addConversationEvent(id: openGroupId, type: .deleted) - - // Remove any dedupe records (we will want to reprocess all OpenGroup messages if they get re-added) - try MessageDeduplication.deleteIfNeeded(db, threadIds: [openGroupId], using: dependencies) - - // Remove the open group (no foreign key to the thread so it won't auto-delete) - if server?.lowercased() != Network.SOGS.defaultServer.lowercased() { - _ = try? OpenGroup - .filter(id: openGroupId) - .deleteAll(db) - } - else { - // If it's a session-run room then just set it to inactive - _ = try? OpenGroup - .filter(id: openGroupId) - .updateAllAndConfig( - db, - OpenGroup.Columns.isActive.set(to: false), - using: dependencies - ) - } - - if !skipLibSessionUpdate, let server: String = server, let roomToken: String = roomToken { - try LibSession.remove(db, server: server, roomToken: roomToken, using: dependencies) - } - } - - // MARK: - Response Processing - - internal static func handleCapabilities( - _ db: ObservingDatabase, - capabilities: Network.SOGS.CapabilitiesResponse, - on server: String - ) { - // Remove old capabilities first - _ = try? Capability - .filter(Capability.Columns.openGroupServer == server.lowercased()) - .deleteAll(db) - - // Then insert the new capabilities (both present and missing) - capabilities.capabilities.forEach { capability in - try? Capability( - openGroupServer: server.lowercased(), - variant: Capability.Variant(from: capability), - isMissing: false - ) - .upsert(db) - } - capabilities.missing?.forEach { capability in - try? Capability( - openGroupServer: server.lowercased(), - variant: Capability.Variant(from: capability), - isMissing: true - ) - .upsert(db) - } - } - - internal static func handlePollInfo( - _ db: ObservingDatabase, - pollInfo: Network.SOGS.RoomPollInfo, - publicKey maybePublicKey: String?, - for roomToken: String, - on server: String, - using dependencies: Dependencies - ) throws { - // Create the open group model and get or create the thread - let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) - - guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return } - - // Only update the database columns which have changed (this is to prevent the UI from triggering - // updates due to changing database columns to the existing value) - let hasDetails: Bool = (pollInfo.details != nil) - let permissions: OpenGroup.Permissions = OpenGroup.Permissions(roomInfo: pollInfo) - let changes: [ConfigColumnAssignment] = [] - .appending(openGroup.publicKey == maybePublicKey ? nil : - maybePublicKey.map { OpenGroup.Columns.publicKey.set(to: $0) } - ) - .appending(openGroup.userCount == pollInfo.activeUsers ? nil : - OpenGroup.Columns.userCount.set(to: pollInfo.activeUsers) - ) - .appending(openGroup.permissions == permissions ? nil : - OpenGroup.Columns.permissions.set(to: permissions) - ) - .appending(!hasDetails || openGroup.name == pollInfo.details?.name ? nil : - OpenGroup.Columns.name.set(to: pollInfo.details?.name) - ) - .appending(!hasDetails || openGroup.roomDescription == pollInfo.details?.roomDescription ? nil : - OpenGroup.Columns.roomDescription.set(to: pollInfo.details?.roomDescription) - ) - .appending(!hasDetails || openGroup.imageId == pollInfo.details?.imageId ? nil : - OpenGroup.Columns.imageId.set(to: pollInfo.details?.imageId) - ) - .appending(!hasDetails || openGroup.infoUpdates == pollInfo.details?.infoUpdates ? nil : - OpenGroup.Columns.infoUpdates.set(to: pollInfo.details?.infoUpdates) - ) - - try OpenGroup - .filter(id: openGroup.id) - .updateAllAndConfig(db, changes, using: dependencies) - - // Update the admin/moderator group members - if let roomDetails: Network.SOGS.Room = pollInfo.details { - _ = try? GroupMember - .filter(GroupMember.Columns.groupId == threadId) - .deleteAll(db) - - try roomDetails.admins.forEach { adminId in - try GroupMember( - groupId: threadId, - profileId: adminId, - role: .admin, - roleStatus: .accepted, // Community members don't have role statuses - isHidden: false - ).upsert(db) - } - - try roomDetails.hiddenAdmins - .defaulting(to: []) - .forEach { adminId in - try GroupMember( - groupId: threadId, - profileId: adminId, - role: .admin, - roleStatus: .accepted, // Community members don't have role statuses - isHidden: true - ).upsert(db) - } - - try roomDetails.moderators.forEach { moderatorId in - try GroupMember( - groupId: threadId, - profileId: moderatorId, - role: .moderator, - roleStatus: .accepted, // Community members don't have role statuses - isHidden: false - ).upsert(db) - } - - try roomDetails.hiddenModerators - .defaulting(to: []) - .forEach { moderatorId in - try GroupMember( - groupId: threadId, - profileId: moderatorId, - role: .moderator, - roleStatus: .accepted, // Community members don't have role statuses - isHidden: true - ).upsert(db) - } - } - - /// Schedule the room image download (if we don't have one or it's been updated) - if - let imageId: String = (pollInfo.details?.imageId ?? openGroup.imageId), - ( - openGroup.displayPictureOriginalUrl == nil || - openGroup.imageId != imageId - ) - { - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .community( - imageId: imageId, - roomToken: openGroup.roomToken, - server: openGroup.server - ), - timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ) - ), - canStartJob: true - ) - } - - /// Emit events - if hasDetails { - if openGroup.name != pollInfo.details?.name { - db.addConversationEvent( - id: openGroup.id, - type: .updated(.displayName(pollInfo.details?.name ?? openGroup.name)) - ) - } - - if openGroup.roomDescription == pollInfo.details?.roomDescription { - db.addConversationEvent( - id: openGroup.id, - type: .updated(.description(pollInfo.details?.roomDescription)) - ) - } - - if pollInfo.details?.imageId == nil { - db.addConversationEvent(id: openGroup.id, type: .updated(.displayPictureUrl(nil))) - } - } - } - - internal static func handleMessages( - _ db: ObservingDatabase, - messages: [Network.SOGS.Message], - for roomToken: String, - on server: String, - using dependencies: Dependencies - ) -> [MessageReceiver.InsertedInteractionInfo?] { - guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) else { - Log.error(.openGroup, "Couldn't handle open group messages due to missing group.") - return [] - } - - // Sorting the messages by server ID before importing them fixes an issue where messages - // that quote older messages can't find those older messages - let sortedMessages: [Network.SOGS.Message] = messages - .filter { $0.deleted != true } - .sorted { lhs, rhs in lhs.id < rhs.id } - var messageServerInfoToRemove: [(id: Int64, seqNo: Int64)] = messages - .filter { $0.deleted == true } - .map { ($0.id, $0.seqNo) } - var largestValidSeqNo: Int64 = openGroup.sequenceNumber - var insertedInteractionInfo: [MessageReceiver.InsertedInteractionInfo?] = [] - - // Process the messages - sortedMessages.forEach { message in - if message.base64EncodedData == nil && message.reactions == nil { - messageServerInfoToRemove.append((message.id, message.seqNo)) - return - } - - // Handle messages - if - let base64EncodedString: String = message.base64EncodedData, - let data = Data(base64Encoded: base64EncodedString), - let sender: String = message.sender - { - do { - let processedMessage: ProcessedMessage = try MessageReceiver.parse( - data: data, - origin: .community( - openGroupId: openGroup.id, - sender: sender, - timestamp: message.posted, - messageServerId: message.id, - whisper: message.whisper, - whisperMods: message.whisperMods, - whisperTo: message.whisperTo - ), - using: dependencies - ) - try MessageDeduplication.insert( - db, - processedMessage: processedMessage, - ignoreDedupeFiles: false, - using: dependencies - ) - - switch processedMessage { - case .config, .invalid: break - case .standard(_, _, _, let messageInfo, _): - insertedInteractionInfo.append( - try MessageReceiver.handle( - db, - threadId: openGroup.id, - threadVariant: .community, - message: messageInfo.message, - serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), - suppressNotifications: false, - using: dependencies - ) - ) - largestValidSeqNo = max(largestValidSeqNo, message.seqNo) - } - } - catch { - switch error { - // Ignore duplicate & selfSend message errors (and don't bother logging - // them as there will be a lot since we each service node duplicates messages) - case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, - DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE - MessageReceiverError.duplicateMessage, - MessageReceiverError.selfSend: - break - - default: Log.error(.openGroup, "Couldn't receive open group message due to error: \(error).") - } - } - } - - // Handle reactions - if message.reactions != nil { - do { - let reactions: [Reaction] = Message.processRawReceivedReactions( - db, - openGroupId: openGroup.id, - message: message, - associatedPendingChanges: dependencies[cache: .openGroupManager].pendingChanges - .filter { - guard $0.server == server && $0.room == roomToken && $0.changeType == .reaction else { - return false - } - - if case .reaction(let messageId, _, _) = $0.metadata { - return messageId == message.id - } - return false - }, - using: dependencies - ) - - try MessageReceiver.handleOpenGroupReactions( - db, - threadId: openGroup.threadId, - openGroupMessageServerId: message.id, - openGroupReactions: reactions - ) - largestValidSeqNo = max(largestValidSeqNo, message.seqNo) - } - catch { - Log.error(.openGroup, "Couldn't handle open group reactions due to error: \(error).") - } - } - } - - // Handle any deletions that are needed - if !messageServerInfoToRemove.isEmpty { - let messageServerIdsToRemove: [Int64] = messageServerInfoToRemove.map { $0.id } - _ = try? Interaction.deleteWhere( - db, - .filter(Interaction.Columns.threadId == openGroup.threadId), - .filter(messageServerIdsToRemove.contains(Interaction.Columns.openGroupServerMessageId)) - ) - - // Update the seqNo for deletions - largestValidSeqNo = max(largestValidSeqNo, (messageServerInfoToRemove.map({ $0.seqNo }).max() ?? 0)) - } - - // Now that we've finished processing all valid message changes we can update the `sequenceNumber` to - // the `largestValidSeqNo` value - _ = try? OpenGroup - .filter(id: openGroup.id) - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: largestValidSeqNo)) - - // Update pendingChange cache based on the `largestValidSeqNo` value - dependencies.mutate(cache: .openGroupManager) { - $0.pendingChanges = $0.pendingChanges - .filter { $0.seqNo == nil || $0.seqNo! > largestValidSeqNo } - } - - return insertedInteractionInfo - } - - internal static func handleDirectMessages( - _ db: ObservingDatabase, - messages: [Network.SOGS.DirectMessage], - fromOutbox: Bool, - on server: String, - using dependencies: Dependencies - ) -> [MessageReceiver.InsertedInteractionInfo?] { - // Don't need to do anything if we have no messages (it's a valid case) - guard !messages.isEmpty else { return [] } - guard let openGroup: OpenGroup = try? OpenGroup.filter(OpenGroup.Columns.server == server.lowercased()).fetchOne(db) else { - Log.error(.openGroup, "Couldn't receive inbox message due to missing group.") - return [] - } - - // Sorting the messages by server ID before importing them fixes an issue where messages - // that quote older messages can't find those older messages - let sortedMessages: [Network.SOGS.DirectMessage] = messages - .sorted { lhs, rhs in lhs.id < rhs.id } - let latestMessageId: Int64 = sortedMessages[sortedMessages.count - 1].id - var lookupCache: [String: BlindedIdLookup] = [:] // Only want this cache to exist for the current loop - var insertedInteractionInfo: [MessageReceiver.InsertedInteractionInfo?] = [] - - // Update the 'latestMessageId' value - if fromOutbox { - _ = try? OpenGroup - .filter(OpenGroup.Columns.server == server.lowercased()) - .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: latestMessageId)) - } - else { - _ = try? OpenGroup - .filter(OpenGroup.Columns.server == server.lowercased()) - .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: latestMessageId)) - } - - // Process the messages - sortedMessages.forEach { message in - guard let messageData = Data(base64Encoded: message.base64EncodedMessage) else { - Log.error(.openGroup, "Couldn't receive inbox message.") - return - } - - do { - let processedMessage: ProcessedMessage = try MessageReceiver.parse( - data: messageData, - origin: .openGroupInbox( - timestamp: message.posted, - messageServerId: message.id, - serverPublicKey: openGroup.publicKey, - senderId: message.sender, - recipientId: message.recipient - ), - using: dependencies - ) - try MessageDeduplication.insert( - db, - processedMessage: processedMessage, - ignoreDedupeFiles: false, - using: dependencies - ) - - switch processedMessage { - case .config, .invalid: break - case .standard(let threadId, _, let proto, let messageInfo, _): - /// We want to update the BlindedIdLookup cache with the message info so we can avoid using the - /// "expensive" lookup when possible - let lookup: BlindedIdLookup = try { - /// Minor optimisation to avoid processing the same sender multiple times in the same - /// 'handleMessages' call (since the 'mapping' call is done within a transaction we - /// will never have a mapping come through part-way through processing these messages) - if let result: BlindedIdLookup = lookupCache[message.recipient] { - return result - } - - return try BlindedIdLookup.fetchOrCreate( - db, - blindedId: (fromOutbox ? - message.recipient : - message.sender - ), - sessionId: (fromOutbox ? - nil : - threadId - ), - openGroupServer: server.lowercased(), - openGroupPublicKey: openGroup.publicKey, - isCheckingForOutbox: fromOutbox, - using: dependencies - ) - }() - lookupCache[message.recipient] = lookup - - // We also need to set the 'syncTarget' for outgoing messages so the behaviour - // to determine the threadId is consistent with standard messages - if fromOutbox { - let syncTarget: String = (lookup.sessionId ?? message.recipient) - - switch messageInfo.variant { - case .visibleMessage: - (messageInfo.message as? VisibleMessage)?.syncTarget = syncTarget - - case .expirationTimerUpdate: - (messageInfo.message as? ExpirationTimerUpdate)?.syncTarget = syncTarget - - default: break - } - } - - insertedInteractionInfo.append( - try MessageReceiver.handle( - db, - threadId: (lookup.sessionId ?? lookup.blindedId), - threadVariant: .contact, // Technically not open group messages - message: messageInfo.message, - serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: proto, - suppressNotifications: false, - using: dependencies - ) - ) - } - } - catch { - switch error { - // Ignore duplicate and self-send errors (we will always receive a duplicate message back - // whenever we send a message so this ends up being spam otherwise) - case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, - DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE - MessageReceiverError.duplicateMessage, - MessageReceiverError.selfSend: - break - - default: - Log.error(.openGroup, "Couldn't receive inbox message due to error: \(error).") - } - } - } - - return insertedInteractionInfo - } - - // MARK: - Convenience - - public func addPendingReaction( - emoji: String, - id: Int64, - in roomToken: String, - on server: String, - type: OpenGroupManager.PendingChange.ReactAction - ) -> OpenGroupManager.PendingChange { - let pendingChange = OpenGroupManager.PendingChange( - server: server, - room: roomToken, - changeType: .reaction, - metadata: .reaction( - messageId: id, - emoji: emoji, - action: type - ) - ) - - dependencies.mutate(cache: .openGroupManager) { - $0.pendingChanges.append(pendingChange) - } - - return pendingChange - } - - public func updatePendingChange(_ pendingChange: OpenGroupManager.PendingChange, seqNo: Int64?) { - dependencies.mutate(cache: .openGroupManager) { - if let index = $0.pendingChanges.firstIndex(of: pendingChange) { - $0.pendingChanges[index].seqNo = seqNo - } - } - } - - public func removePendingChange(_ pendingChange: OpenGroupManager.PendingChange) { - dependencies.mutate(cache: .openGroupManager) { - if let index = $0.pendingChanges.firstIndex(of: pendingChange) { - $0.pendingChanges.remove(at: index) - } - } - } - - /// This method specifies if the given capability is supported on a specified Open Group - public func doesOpenGroupSupport( - _ db: ObservingDatabase, - capability: Capability.Variant, - on server: String? - ) -> Bool { - guard let server: String = server else { return false } - - let capabilities: [Capability.Variant] = (try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == server) - .filter(Capability.Columns.isMissing == false) - .asRequest(of: Capability.Variant.self) - .fetchAll(db)) - .defaulting(to: []) - - return capabilities.contains(capability) - } - - /// This method specifies if the given publicKeys have moderator or admin permissions within a specified Open Group - public func membersWhere( - _ db: ObservingDatabase, - currentUserSessionIds: Set, - _ filters: GroupMember.Filter... - ) throws -> [GroupMember] { - var query: QueryInterfaceRequest = GroupMember.select(.allColumns) - - /// Apply the desired filters - for filter in filters { - switch filter { - case .groupIds(let ids): query = query.filter(ids.contains(GroupMember.Columns.groupId)) - case .roles(let roles): query = query.filter(roles.contains(GroupMember.Columns.role)) - - case .publicKeys(let keys): - var targetKeys: Set = Set(keys) - - /// If `currentUserSessionIds` contains one of the `publicKeys` then we want to include `currentUserSessionIds` - /// in the lookup - if !currentUserSessionIds.isDisjoint(with: targetKeys) { - targetKeys.insert(contentsOf: currentUserSessionIds) - - /// Add the users `unblinded` pubkey if we can get it, just for completeness - let userEdKeyPair: KeyPair? = dependencies[singleton: .crypto].generate( - .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) - ) - if let userEdPublicKey: [UInt8] = userEdKeyPair?.publicKey { - targetKeys.insert(SessionId(.unblinded, publicKey: userEdPublicKey).hexString) - } - } - - query = query.filter(targetKeys.contains(GroupMember.Columns.profileId)) - } - } - - return try query.fetchAll(db) - } - - public func isUserModeratorOrAdmin( - _ db: ObservingDatabase, - publicKey: String, - for roomToken: String?, - on server: String?, - currentUserSessionIds: Set - ) -> Bool { - guard let roomToken: String = roomToken, let server: String = server else { return false } - - let groupId: String = OpenGroup.idFor(roomToken: roomToken, server: server) - let members: [GroupMember]? = try? membersWhere( - db, - currentUserSessionIds: currentUserSessionIds, - .groupIds([groupId]), - .publicKeys([publicKey]), - .roles([.moderator, .admin]) - ) - - var targetKeys: Set = Set([publicKey]) - - /// If `currentUserSessionIds` contains one of the `publicKeys` then we want to include `currentUserSessionIds` - /// in the lookup - if !currentUserSessionIds.isDisjoint(with: targetKeys) { - targetKeys.insert(contentsOf: currentUserSessionIds) - - /// Add the users `unblinded` pubkey if we can get it, just for completeness - let userEdKeyPair: KeyPair? = dependencies[singleton: .crypto].generate( - .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) - ) - if let userEdPublicKey: [UInt8] = userEdKeyPair?.publicKey { - targetKeys.insert(SessionId(.unblinded, publicKey: userEdPublicKey).hexString) - } - } - - return (Set((members ?? []).map { $0.profileId }).isDisjoint(with: targetKeys) == false) - } -} - -public extension GroupMember { - enum Filter { - case groupIds(any Collection) - case publicKeys(any Collection) - case roles(any Collection) - } -} - -// MARK: - Deprecated Conveneince Functions - -public extension OpenGroupManager { - @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") - func doesOpenGroupSupport( - capability: Capability.Variant, - on server: String? - ) -> Bool { - guard let server: String = server else { return false } - - var openGroupSupportsCapability: Bool = false - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - dependencies[singleton: .storage].readAsync( - retrieve: { [weak self] db in - self?.doesOpenGroupSupport(db, capability: capability, on: server) - }, - completion: { result in - switch result { - case .failure: break - case .success(let value): openGroupSupportsCapability = (value == true) - } - semaphore.signal() - } - ) - semaphore.wait() - return openGroupSupportsCapability - } - - @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") - func isUserModeratorOrAdmin( - publicKey: String, - for roomToken: String?, - on server: String?, - currentUserSessionIds: Set - ) -> Bool { - guard let roomToken: String = roomToken, let server: String = server else { return false } - - var userIsModeratorOrAdmin: Bool = false - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - dependencies[singleton: .storage].readAsync( - retrieve: { [weak self] db in - self?.isUserModeratorOrAdmin( - db, - publicKey: publicKey, - for: roomToken, - on: server, - currentUserSessionIds: currentUserSessionIds - ) - }, - completion: { result in - switch result { - case .failure: break - case .success(let value): userIsModeratorOrAdmin = (value == true) - } - semaphore.signal() - } - ) - semaphore.wait() - return userIsModeratorOrAdmin - } -} - -// MARK: - OpenGroupManager Cache - -public extension OpenGroupManager { - class Cache: OGMCacheType { - private let dependencies: Dependencies - private let defaultRoomsSubject: CurrentValueSubject<[DefaultRoomInfo], Error> = CurrentValueSubject([]) - private var _lastSuccessfulCommunityPollTimestamp: TimeInterval? - public var pendingChanges: [OpenGroupManager.PendingChange] = [] - - public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error> { - defaultRoomsSubject - .handleEvents( - receiveSubscription: { [weak defaultRoomsSubject, dependencies] _ in - /// If we don't have any default rooms in memory then we haven't fetched this launch so schedule - /// the `RetrieveDefaultOpenGroupRoomsJob` if one isn't already running - if defaultRoomsSubject?.value.isEmpty == true { - RetrieveDefaultOpenGroupRoomsJob.run(using: dependencies) - } - } - ) - .filter { !$0.isEmpty } - .eraseToAnyPublisher() - } - - // MARK: - Initialization - - init(using dependencies: Dependencies) { - self.dependencies = dependencies - } - - // MARK: - Functions - - public func getLastSuccessfulCommunityPollTimestamp() -> TimeInterval { - if let storedTime: TimeInterval = _lastSuccessfulCommunityPollTimestamp { - return storedTime - } - - guard let lastPoll: Date = dependencies[defaults: .standard, key: .lastOpen] else { - return 0 - } - - _lastSuccessfulCommunityPollTimestamp = lastPoll.timeIntervalSince1970 - return lastPoll.timeIntervalSince1970 - } - - public func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) { - dependencies[defaults: .standard, key: .lastOpen] = Date(timeIntervalSince1970: timestamp) - _lastSuccessfulCommunityPollTimestamp = timestamp - } - - public func setDefaultRoomInfo(_ info: [DefaultRoomInfo]) { - defaultRoomsSubject.send(info) - } - } -} - -// MARK: - OGMCacheType - -/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way -public protocol OGMImmutableCacheType: ImmutableCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> { get } - - var pendingChanges: [OpenGroupManager.PendingChange] { get } -} - -public protocol OGMCacheType: OGMImmutableCacheType, MutableCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> { get } - - var pendingChanges: [OpenGroupManager.PendingChange] { get set } - - func getLastSuccessfulCommunityPollTimestamp() -> TimeInterval - func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) - func setDefaultRoomInfo(_ info: [OpenGroupManager.DefaultRoomInfo]) -} diff --git a/SessionMessagingKit/Open Groups/Types/PendingChange.swift b/SessionMessagingKit/Open Groups/Types/PendingChange.swift index 6ab6cd2145..26a1b35b02 100644 --- a/SessionMessagingKit/Open Groups/Types/PendingChange.swift +++ b/SessionMessagingKit/Open Groups/Types/PendingChange.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupManager { +extension CommunityManager { public struct PendingChange: Equatable { public enum ChangeType { case reaction @@ -24,7 +24,7 @@ extension OpenGroupManager { var seqNo: Int64? let metadata: Metadata - public static func == (lhs: OpenGroupManager.PendingChange, rhs: OpenGroupManager.PendingChange) -> Bool { + public static func == (lhs: CommunityManager.PendingChange, rhs: CommunityManager.PendingChange) -> Bool { guard lhs.server == rhs.server && lhs.room == rhs.room && diff --git a/SessionMessagingKit/Open Groups/Types/Server.swift b/SessionMessagingKit/Open Groups/Types/Server.swift new file mode 100644 index 0000000000..8d056ca06d --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/Server.swift @@ -0,0 +1,200 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionNetworkingKit +import SessionUtilitiesKit + +extension CommunityManager { + /// The `Server` type is an in-memory store of the current state of all rooms the user is subscribed to on a SOGS + public struct Server: Codable, Equatable { + public let server: String + public let publicKey: String + public let capabilities: Set + public let pollFailureCount: Int64 + public let currentUserSessionIds: Set + + public let inboxLatestMessageId: Int64 + public let outboxLatestMessageId: Int64 + + public let rooms: [String: Network.SOGS.Room] + + fileprivate init( + server: String, + publicKey: String, + capabilities: Set, + pollFailureCount: Int64, + currentUserSessionIds: Set, + inboxLatestMessageId: Int64, + outboxLatestMessageId: Int64, + rooms: [String: Network.SOGS.Room] + ) { + self.server = server.lowercased() + self.publicKey = publicKey + self.capabilities = capabilities + self.pollFailureCount = pollFailureCount + self.currentUserSessionIds = currentUserSessionIds + self.inboxLatestMessageId = inboxLatestMessageId + self.outboxLatestMessageId = outboxLatestMessageId + self.rooms = rooms + } + } +} + +// MARK: - Convenience + +public extension CommunityManager.Server { + init( + server: String, + publicKey: String, + openGroups: [OpenGroup] = [], + capabilities: Set? = nil, + roomMembers: [String: [GroupMember]]? = nil, + using dependencies: Dependencies + ) { + let currentUserSessionIds: Set = CommunityManager.Server.generateCurrentUserSessionIds( + publicKey: publicKey, + capabilities: (capabilities ?? []), + using: dependencies + ) + + self.server = server.lowercased() + self.publicKey = publicKey + self.capabilities = (capabilities ?? []) + self.pollFailureCount = (openGroups.map { $0.pollFailureCount }.max() ?? 0) + self.currentUserSessionIds = currentUserSessionIds + + self.inboxLatestMessageId = (openGroups.map { $0.inboxLatestMessageId }.max() ?? 0) + self.outboxLatestMessageId = (openGroups.map { $0.outboxLatestMessageId }.max() ?? 0) + + self.rooms = openGroups.reduce(into: [:]) { result, next in + result[next.roomToken] = Network.SOGS.Room( + openGroup: next, + members: (roomMembers?[next.roomToken] ?? []), + currentUserSessionIds: currentUserSessionIds + ) + } + } + + func with( + capabilities: Update> = .useExisting, + inboxLatestMessageId: Update = .useExisting, + outboxLatestMessageId: Update = .useExisting, + rooms: Update<[Network.SOGS.Room]> = .useExisting, + using dependencies: Dependencies + ) -> CommunityManager.Server { + let targetCapabilities: Set = capabilities.or(self.capabilities) + + return CommunityManager.Server( + server: server, + publicKey: publicKey, + capabilities: targetCapabilities, + pollFailureCount: pollFailureCount, + currentUserSessionIds: CommunityManager.Server.generateCurrentUserSessionIds( + publicKey: publicKey, + capabilities: targetCapabilities, + using: dependencies + ), + inboxLatestMessageId: inboxLatestMessageId.or(self.inboxLatestMessageId), + outboxLatestMessageId: outboxLatestMessageId.or(self.outboxLatestMessageId), + rooms: { + switch rooms { + case .useExisting: return self.rooms + case .set(let updatedRooms): + return updatedRooms.reduce(into: [:]) { result, next in + result[next.token] = next + } + } + }() + ) + } + + fileprivate static func generateCurrentUserSessionIds( + publicKey: String, + capabilities: Set, + using dependencies: Dependencies + ) -> Set { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + + /// If the SOGS explicitly **is not** blinded then don't bother generating the blinded ids + guard capabilities.isEmpty || capabilities.contains(.blind) else { + return [userSessionId.hexString] + } + + let ed25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey + let userBlinded15SessionId: SessionId? = dependencies[singleton: .crypto] + .generate(.blinded15KeyPair(serverPublicKey: publicKey, ed25519SecretKey: ed25519SecretKey)) + .map { SessionId(.blinded15, publicKey: $0.publicKey) } + let userBlinded25SessionId: SessionId? = dependencies[singleton: .crypto] + .generate(.blinded25KeyPair(serverPublicKey: publicKey, ed25519SecretKey: ed25519SecretKey)) + .map { SessionId(.blinded25, publicKey: $0.publicKey) } + + /// Add the users `unblinded` pubkey if we can get it, just for completeness + let userUnblindedSessionId: SessionId? = dependencies[singleton: .crypto] + .generate(.ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed)) + .map { SessionId(.unblinded, publicKey: $0.publicKey) } + + return Set([ + userSessionId.hexString, + userBlinded15SessionId?.hexString, + userBlinded25SessionId?.hexString, + userUnblindedSessionId?.hexString + ].compactMap { $0 }) + } +} + +// MARK: - Convenience + +internal extension Network.SOGS.Room { + init( + openGroup: OpenGroup, + members: [GroupMember]? = nil, + currentUserSessionIds: Set = [] + ) { + let admins: [String] = (members? + .filter { $0.role == .admin && !$0.isHidden } + .map { $0.profileId } ?? []) + let hiddenAdmins: [String]? = members? + .filter { $0.role == .admin && $0.isHidden } + .map { $0.profileId } + let moderators: [String] = (members? + .filter { $0.role == .moderator && !$0.isHidden } + .map { $0.profileId } ?? []) + let hiddenModerators: [String]? = members? + .filter { $0.role == .moderator && $0.isHidden } + .map { $0.profileId } + + self = Network.SOGS.Room( + token: openGroup.roomToken, + name: openGroup.name, + roomDescription: openGroup.description, + infoUpdates: openGroup.infoUpdates, + messageSequence: openGroup.sequenceNumber, + created: 0, /// Updated on first poll + activeUsers: openGroup.userCount, + activeUsersCutoff: 0, /// Updated on first poll + imageId: openGroup.imageId, + pinnedMessages: nil, /// Updated on first poll + admin: ( + !Set(admins).isDisjoint(with: currentUserSessionIds) || + !Set(hiddenAdmins ?? []).isDisjoint(with: currentUserSessionIds) + ), + globalAdmin: false, /// Updated on first poll + admins: admins, /// Updated on first poll + hiddenAdmins: hiddenAdmins, + moderator: ( + !Set(moderators).isDisjoint(with: currentUserSessionIds) || + !Set(hiddenModerators ?? []).isDisjoint(with: currentUserSessionIds) + ), + globalModerator: false, /// Updated on first poll + moderators: moderators, + hiddenModerators: hiddenModerators, + read: (openGroup.permissions?.contains(.read) == true), + defaultRead: false, /// Updated on first poll + defaultAccessible: false, /// Updated on first poll + write: (openGroup.permissions?.contains(.write) == true), + defaultWrite: false, /// Updated on first poll + upload: (openGroup.permissions?.contains(.upload) == true), + defaultUpload: false /// Updated on first poll + ) + } +} diff --git a/SessionMessagingKit/Protos/Generated/SNProto.swift b/SessionMessagingKit/Protos/Generated/SNProto.swift index 633e60c3a7..d0b9da075a 100644 --- a/SessionMessagingKit/Protos/Generated/SNProto.swift +++ b/SessionMessagingKit/Protos/Generated/SNProto.swift @@ -648,6 +648,12 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { if hasSigTimestamp { builder.setSigTimestamp(sigTimestamp) } + if let _value = proMessage { + builder.setProMessage(_value) + } + if let _value = proSigForCommunityMessageOnly { + builder.setProSigForCommunityMessageOnly(_value) + } return builder } @@ -697,6 +703,14 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { proto.sigTimestamp = valueParam } + @objc public func setProMessage(_ valueParam: SNProtoProMessage) { + proto.proMessage = valueParam.proto + } + + @objc public func setProSigForCommunityMessageOnly(_ valueParam: Data) { + proto.proSigForCommunityMessageOnly = valueParam + } + @objc public func build() throws -> SNProtoContent { return try SNProtoContent.parseProto(proto) } @@ -722,6 +736,8 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { @objc public let messageRequestResponse: SNProtoMessageRequestResponse? + @objc public let proMessage: SNProtoProMessage? + @objc public var expirationType: SNProtoContentExpirationType { return SNProtoContent.SNProtoContentExpirationTypeWrap(proto.expirationType) } @@ -743,6 +759,16 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { return proto.hasSigTimestamp } + @objc public var proSigForCommunityMessageOnly: Data? { + guard proto.hasProSigForCommunityMessageOnly else { + return nil + } + return proto.proSigForCommunityMessageOnly + } + @objc public var hasProSigForCommunityMessageOnly: Bool { + return proto.hasProSigForCommunityMessageOnly + } + private init(proto: SessionProtos_Content, dataMessage: SNProtoDataMessage?, callMessage: SNProtoCallMessage?, @@ -750,7 +776,8 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { typingMessage: SNProtoTypingMessage?, dataExtractionNotification: SNProtoDataExtractionNotification?, unsendRequest: SNProtoUnsendRequest?, - messageRequestResponse: SNProtoMessageRequestResponse?) { + messageRequestResponse: SNProtoMessageRequestResponse?, + proMessage: SNProtoProMessage?) { self.proto = proto self.dataMessage = dataMessage self.callMessage = callMessage @@ -759,6 +786,7 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { self.dataExtractionNotification = dataExtractionNotification self.unsendRequest = unsendRequest self.messageRequestResponse = messageRequestResponse + self.proMessage = proMessage } @objc @@ -807,6 +835,11 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { messageRequestResponse = try SNProtoMessageRequestResponse.parseProto(proto.messageRequestResponse) } + var proMessage: SNProtoProMessage? = nil + if proto.hasProMessage { + proMessage = try SNProtoProMessage.parseProto(proto.proMessage) + } + // MARK: - Begin Validation Logic for SNProtoContent - // MARK: - End Validation Logic for SNProtoContent - @@ -818,7 +851,8 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { typingMessage: typingMessage, dataExtractionNotification: dataExtractionNotification, unsendRequest: unsendRequest, - messageRequestResponse: messageRequestResponse) + messageRequestResponse: messageRequestResponse, + proMessage: proMessage) return result } @@ -4025,3 +4059,281 @@ extension SNProtoGroupUpdateDeleteMemberContentMessage.SNProtoGroupUpdateDeleteM } #endif + +// MARK: - SNProtoProProof + +@objc public class SNProtoProProof: NSObject { + + // MARK: - SNProtoProProofBuilder + + @objc public class func builder() -> SNProtoProProofBuilder { + return SNProtoProProofBuilder() + } + + // asBuilder() constructs a builder that reflects the proto's contents. + @objc public func asBuilder() -> SNProtoProProofBuilder { + let builder = SNProtoProProofBuilder() + if hasVersion { + builder.setVersion(version) + } + if let _value = genIndexHash { + builder.setGenIndexHash(_value) + } + if let _value = rotatingPublicKey { + builder.setRotatingPublicKey(_value) + } + if hasExpiryUnixTs { + builder.setExpiryUnixTs(expiryUnixTs) + } + if let _value = sig { + builder.setSig(_value) + } + return builder + } + + @objc public class SNProtoProProofBuilder: NSObject { + + private var proto = SessionProtos_ProProof() + + @objc fileprivate override init() {} + + @objc public func setVersion(_ valueParam: UInt32) { + proto.version = valueParam + } + + @objc public func setGenIndexHash(_ valueParam: Data) { + proto.genIndexHash = valueParam + } + + @objc public func setRotatingPublicKey(_ valueParam: Data) { + proto.rotatingPublicKey = valueParam + } + + @objc public func setExpiryUnixTs(_ valueParam: UInt64) { + proto.expiryUnixTs = valueParam + } + + @objc public func setSig(_ valueParam: Data) { + proto.sig = valueParam + } + + @objc public func build() throws -> SNProtoProProof { + return try SNProtoProProof.parseProto(proto) + } + + @objc public func buildSerializedData() throws -> Data { + return try SNProtoProProof.parseProto(proto).serializedData() + } + } + + fileprivate let proto: SessionProtos_ProProof + + @objc public var version: UInt32 { + return proto.version + } + @objc public var hasVersion: Bool { + return proto.hasVersion + } + + @objc public var genIndexHash: Data? { + guard proto.hasGenIndexHash else { + return nil + } + return proto.genIndexHash + } + @objc public var hasGenIndexHash: Bool { + return proto.hasGenIndexHash + } + + @objc public var rotatingPublicKey: Data? { + guard proto.hasRotatingPublicKey else { + return nil + } + return proto.rotatingPublicKey + } + @objc public var hasRotatingPublicKey: Bool { + return proto.hasRotatingPublicKey + } + + @objc public var expiryUnixTs: UInt64 { + return proto.expiryUnixTs + } + @objc public var hasExpiryUnixTs: Bool { + return proto.hasExpiryUnixTs + } + + @objc public var sig: Data? { + guard proto.hasSig else { + return nil + } + return proto.sig + } + @objc public var hasSig: Bool { + return proto.hasSig + } + + private init(proto: SessionProtos_ProProof) { + self.proto = proto + } + + @objc + public func serializedData() throws -> Data { + return try self.proto.serializedData() + } + + @objc public class func parseData(_ serializedData: Data) throws -> SNProtoProProof { + let proto = try SessionProtos_ProProof(serializedData: serializedData) + return try parseProto(proto) + } + + fileprivate class func parseProto(_ proto: SessionProtos_ProProof) throws -> SNProtoProProof { + // MARK: - Begin Validation Logic for SNProtoProProof - + + // MARK: - End Validation Logic for SNProtoProProof - + + let result = SNProtoProProof(proto: proto) + return result + } + + @objc public override var debugDescription: String { + return "\(proto)" + } +} + +#if DEBUG + +extension SNProtoProProof { + @objc public func serializedDataIgnoringErrors() -> Data? { + return try! self.serializedData() + } +} + +extension SNProtoProProof.SNProtoProProofBuilder { + @objc public func buildIgnoringErrors() -> SNProtoProProof? { + return try! self.build() + } +} + +#endif + +// MARK: - SNProtoProMessage + +@objc public class SNProtoProMessage: NSObject { + + // MARK: - SNProtoProMessageBuilder + + @objc public class func builder() -> SNProtoProMessageBuilder { + return SNProtoProMessageBuilder() + } + + // asBuilder() constructs a builder that reflects the proto's contents. + @objc public func asBuilder() -> SNProtoProMessageBuilder { + let builder = SNProtoProMessageBuilder() + if let _value = proof { + builder.setProof(_value) + } + if hasProfileBitset { + builder.setProfileBitset(profileBitset) + } + if hasMsgBitset { + builder.setMsgBitset(msgBitset) + } + return builder + } + + @objc public class SNProtoProMessageBuilder: NSObject { + + private var proto = SessionProtos_ProMessage() + + @objc fileprivate override init() {} + + @objc public func setProof(_ valueParam: SNProtoProProof) { + proto.proof = valueParam.proto + } + + @objc public func setProfileBitset(_ valueParam: UInt64) { + proto.profileBitset = valueParam + } + + @objc public func setMsgBitset(_ valueParam: UInt64) { + proto.msgBitset = valueParam + } + + @objc public func build() throws -> SNProtoProMessage { + return try SNProtoProMessage.parseProto(proto) + } + + @objc public func buildSerializedData() throws -> Data { + return try SNProtoProMessage.parseProto(proto).serializedData() + } + } + + fileprivate let proto: SessionProtos_ProMessage + + @objc public let proof: SNProtoProProof? + + @objc public var profileBitset: UInt64 { + return proto.profileBitset + } + @objc public var hasProfileBitset: Bool { + return proto.hasProfileBitset + } + + @objc public var msgBitset: UInt64 { + return proto.msgBitset + } + @objc public var hasMsgBitset: Bool { + return proto.hasMsgBitset + } + + private init(proto: SessionProtos_ProMessage, + proof: SNProtoProProof?) { + self.proto = proto + self.proof = proof + } + + @objc + public func serializedData() throws -> Data { + return try self.proto.serializedData() + } + + @objc public class func parseData(_ serializedData: Data) throws -> SNProtoProMessage { + let proto = try SessionProtos_ProMessage(serializedData: serializedData) + return try parseProto(proto) + } + + fileprivate class func parseProto(_ proto: SessionProtos_ProMessage) throws -> SNProtoProMessage { + var proof: SNProtoProProof? = nil + if proto.hasProof { + proof = try SNProtoProProof.parseProto(proto.proof) + } + + // MARK: - Begin Validation Logic for SNProtoProMessage - + + // MARK: - End Validation Logic for SNProtoProMessage - + + let result = SNProtoProMessage(proto: proto, + proof: proof) + return result + } + + @objc public override var debugDescription: String { + return "\(proto)" + } +} + +#if DEBUG + +extension SNProtoProMessage { + @objc public func serializedDataIgnoringErrors() -> Data? { + return try! self.serializedData() + } +} + +extension SNProtoProMessage.SNProtoProMessageBuilder { + @objc public func buildIgnoringErrors() -> SNProtoProMessage? { + return try! self.build() + } +} + +#endif diff --git a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift index 40f49dead2..e1875ae0f0 100644 --- a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift +++ b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift @@ -357,6 +357,9 @@ struct SessionProtos_Content { /// Clears the value of `expirationTimer`. Subsequent reads from it will return its default value. mutating func clearExpirationTimer() {_uniqueStorage()._expirationTimer = nil} + /// NOTE: This timestamp was added to address the issue with 1o1 message envelope timestamps were + /// unauthenticated because 1o1 messages encrypt the Content not the envelope. In Groups, the + /// entire envelope is encrypted and hence can be trusted. var sigTimestamp: UInt64 { get {return _storage._sigTimestamp ?? 0} set {_uniqueStorage()._sigTimestamp = newValue} @@ -366,6 +369,76 @@ struct SessionProtos_Content { /// Clears the value of `sigTimestamp`. Subsequent reads from it will return its default value. mutating func clearSigTimestamp() {_uniqueStorage()._sigTimestamp = nil} + var proMessage: SessionProtos_ProMessage { + get {return _storage._proMessage ?? SessionProtos_ProMessage()} + set {_uniqueStorage()._proMessage = newValue} + } + /// Returns true if `proMessage` has been explicitly set. + var hasProMessage: Bool {return _storage._proMessage != nil} + /// Clears the value of `proMessage`. Subsequent reads from it will return its default value. + mutating func clearProMessage() {_uniqueStorage()._proMessage = nil} + + /// NOTE: Temporary transition field to include the pro-signature into Content for community + /// messages to use. + /// + /// Community messages are currently sent and received as plaintext Content. We call this state of + /// the network v0. + /// + /// We will continue to send Community messages using the Content structure, but, now enhanced with + /// the optional `proSigForCommunityMessageOnly` field which contains the pro signature. We call + /// this network v1. The new clients running v1 will pack the pro-signature into the payload. We + /// maintain forwards compatibility with clients on v0 as we are still sending content + /// on the wire, they skip the new pro data. + /// + /// Simultaneously in v1 the responsibility of parsing the open groups messages will go into + /// libsession. Libsession will be setup to try and parse the open groups message as a `Content` + /// message at first, if that fails it will try to read the community message as an `Envelope`. + /// In summary in a v1 network: + /// + /// v0 will still receive messages from v1 as they send `Content` community messages. + /// + /// v1 accepts v0 (`Content`) and v1 (`Envelope`) on the wire for community messages. v1 sends + /// `Content` community messages so that there's compatibility with v0. + /// + /// After a defined transitionary period, we create a new release and update libsession to stop + /// sending `Content` for communities and transition to sending `Envelope` for messages. We mark + /// this as a v2 network: + /// + /// v0 will still receive messages from v1 (`Content`) but not v2 (`Envelope`) community + /// messages. + /// + /// v1 accepts v0 (`Content`) and v1 (`Envelope`) on the wire for community messages. v1 sends + /// `Content` community messages so that there's compatibility with v0. + /// + /// v2 swaps the parsing order. it tries parsing v1 (`envelope`) then v0 (`content`) from a + /// community message. v2 sends `envelope` community messages so compatbility is maintained with + /// v1 but not v0. + /// + /// After a final transitionary period, v3, remove parsing content entirely from libsession for + /// community messages and removes the pro-signature from `content`. in this final stage, v2 and v3 + /// are the final set of clients that can continue to talk to each other. + /// + /// +---------+----------------+-------------+------------------+-------------+-------------+ + /// | Version | Sends | Receives v0 | Receives v1 | Receives v2 | Receives v3 | + /// | | | (Content) | (Content+ProSig) | (Envelope) | (Envelope) | + /// +---------+----------------+-------------+------------------+-------------+-------------+ + /// | v0 | Content | Yes | Yes | No | No | + /// +---------+----------------+-------------+------------------+-------------+-------------+ + /// | v1 | Content+ProSig | Yes | Yes | Yes | Yes | + /// +---------+----------------+-------------+------------------+-------------+-------------+ + /// | v2 | Envelope | Yes | Yes | Yes | Yes | + /// +---------+----------------+-------------+------------------+-------------+-------------+ + /// | v3 | Envelope | No | No | Yes | Yes | + /// +---------+----------------+-------------+------------------+-------------+-------------+ + var proSigForCommunityMessageOnly: Data { + get {return _storage._proSigForCommunityMessageOnly ?? Data()} + set {_uniqueStorage()._proSigForCommunityMessageOnly = newValue} + } + /// Returns true if `proSigForCommunityMessageOnly` has been explicitly set. + var hasProSigForCommunityMessageOnly: Bool {return _storage._proSigForCommunityMessageOnly != nil} + /// Clears the value of `proSigForCommunityMessageOnly`. Subsequent reads from it will return its default value. + mutating func clearProSigForCommunityMessageOnly() {_uniqueStorage()._proSigForCommunityMessageOnly = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() enum ExpirationType: SwiftProtobuf.Enum { @@ -1707,6 +1780,112 @@ struct SessionProtos_GroupUpdateDeleteMemberContentMessage { fileprivate var _adminSignature: Data? = nil } +struct SessionProtos_ProProof { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var version: UInt32 { + get {return _version ?? 0} + set {_version = newValue} + } + /// Returns true if `version` has been explicitly set. + var hasVersion: Bool {return self._version != nil} + /// Clears the value of `version`. Subsequent reads from it will return its default value. + mutating func clearVersion() {self._version = nil} + + /// Opaque identifier of this proof produced by the Session Pro backend + var genIndexHash: Data { + get {return _genIndexHash ?? Data()} + set {_genIndexHash = newValue} + } + /// Returns true if `genIndexHash` has been explicitly set. + var hasGenIndexHash: Bool {return self._genIndexHash != nil} + /// Clears the value of `genIndexHash`. Subsequent reads from it will return its default value. + mutating func clearGenIndexHash() {self._genIndexHash = nil} + + /// Public key whose signatures is authorised to entitle messages with Session Pro + var rotatingPublicKey: Data { + get {return _rotatingPublicKey ?? Data()} + set {_rotatingPublicKey = newValue} + } + /// Returns true if `rotatingPublicKey` has been explicitly set. + var hasRotatingPublicKey: Bool {return self._rotatingPublicKey != nil} + /// Clears the value of `rotatingPublicKey`. Subsequent reads from it will return its default value. + mutating func clearRotatingPublicKey() {self._rotatingPublicKey = nil} + + /// Epoch timestamps in milliseconds + var expiryUnixTs: UInt64 { + get {return _expiryUnixTs ?? 0} + set {_expiryUnixTs = newValue} + } + /// Returns true if `expiryUnixTs` has been explicitly set. + var hasExpiryUnixTs: Bool {return self._expiryUnixTs != nil} + /// Clears the value of `expiryUnixTs`. Subsequent reads from it will return its default value. + mutating func clearExpiryUnixTs() {self._expiryUnixTs = nil} + + /// Signature produced by the Session Pro Backend signing over the hash of the proof + var sig: Data { + get {return _sig ?? Data()} + set {_sig = newValue} + } + /// Returns true if `sig` has been explicitly set. + var hasSig: Bool {return self._sig != nil} + /// Clears the value of `sig`. Subsequent reads from it will return its default value. + mutating func clearSig() {self._sig = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _version: UInt32? = nil + fileprivate var _genIndexHash: Data? = nil + fileprivate var _rotatingPublicKey: Data? = nil + fileprivate var _expiryUnixTs: UInt64? = nil + fileprivate var _sig: Data? = nil +} + +struct SessionProtos_ProMessage { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var proof: SessionProtos_ProProof { + get {return _proof ?? SessionProtos_ProProof()} + set {_proof = newValue} + } + /// Returns true if `proof` has been explicitly set. + var hasProof: Bool {return self._proof != nil} + /// Clears the value of `proof`. Subsequent reads from it will return its default value. + mutating func clearProof() {self._proof = nil} + + var profileBitset: UInt64 { + get {return _profileBitset ?? 0} + set {_profileBitset = newValue} + } + /// Returns true if `profileBitset` has been explicitly set. + var hasProfileBitset: Bool {return self._profileBitset != nil} + /// Clears the value of `profileBitset`. Subsequent reads from it will return its default value. + mutating func clearProfileBitset() {self._profileBitset = nil} + + var msgBitset: UInt64 { + get {return _msgBitset ?? 0} + set {_msgBitset = newValue} + } + /// Returns true if `msgBitset` has been explicitly set. + var hasMsgBitset: Bool {return self._msgBitset != nil} + /// Clears the value of `msgBitset`. Subsequent reads from it will return its default value. + mutating func clearMsgBitset() {self._msgBitset = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _proof: SessionProtos_ProProof? = nil + fileprivate var _profileBitset: UInt64? = nil + fileprivate var _msgBitset: UInt64? = nil +} + #if swift(>=5.5) && canImport(_Concurrency) extension SessionProtos_Envelope: @unchecked Sendable {} extension SessionProtos_Envelope.TypeEnum: @unchecked Sendable {} @@ -1746,6 +1925,8 @@ extension SessionProtos_GroupUpdateMemberLeftMessage: @unchecked Sendable {} extension SessionProtos_GroupUpdateMemberLeftNotificationMessage: @unchecked Sendable {} extension SessionProtos_GroupUpdateInviteResponseMessage: @unchecked Sendable {} extension SessionProtos_GroupUpdateDeleteMemberContentMessage: @unchecked Sendable {} +extension SessionProtos_ProProof: @unchecked Sendable {} +extension SessionProtos_ProMessage: @unchecked Sendable {} #endif // swift(>=5.5) && canImport(_Concurrency) // MARK: - Code below here is support for the SwiftProtobuf runtime. @@ -2000,6 +2181,8 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm 12: .same(proto: "expirationType"), 13: .same(proto: "expirationTimer"), 15: .same(proto: "sigTimestamp"), + 16: .same(proto: "proMessage"), + 17: .same(proto: "proSigForCommunityMessageOnly"), ] fileprivate class _StorageClass { @@ -2013,6 +2196,8 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm var _expirationType: SessionProtos_Content.ExpirationType? = nil var _expirationTimer: UInt32? = nil var _sigTimestamp: UInt64? = nil + var _proMessage: SessionProtos_ProMessage? = nil + var _proSigForCommunityMessageOnly: Data? = nil static let defaultInstance = _StorageClass() @@ -2029,6 +2214,8 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm _expirationType = source._expirationType _expirationTimer = source._expirationTimer _sigTimestamp = source._sigTimestamp + _proMessage = source._proMessage + _proSigForCommunityMessageOnly = source._proSigForCommunityMessageOnly } } @@ -2070,6 +2257,8 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm case 12: try { try decoder.decodeSingularEnumField(value: &_storage._expirationType) }() case 13: try { try decoder.decodeSingularUInt32Field(value: &_storage._expirationTimer) }() case 15: try { try decoder.decodeSingularUInt64Field(value: &_storage._sigTimestamp) }() + case 16: try { try decoder.decodeSingularMessageField(value: &_storage._proMessage) }() + case 17: try { try decoder.decodeSingularBytesField(value: &_storage._proSigForCommunityMessageOnly) }() default: break } } @@ -2112,6 +2301,12 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm try { if let v = _storage._sigTimestamp { try visitor.visitSingularUInt64Field(value: v, fieldNumber: 15) } }() + try { if let v = _storage._proMessage { + try visitor.visitSingularMessageField(value: v, fieldNumber: 16) + } }() + try { if let v = _storage._proSigForCommunityMessageOnly { + try visitor.visitSingularBytesField(value: v, fieldNumber: 17) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -2131,6 +2326,8 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm if _storage._expirationType != rhs_storage._expirationType {return false} if _storage._expirationTimer != rhs_storage._expirationTimer {return false} if _storage._sigTimestamp != rhs_storage._sigTimestamp {return false} + if _storage._proMessage != rhs_storage._proMessage {return false} + if _storage._proSigForCommunityMessageOnly != rhs_storage._proSigForCommunityMessageOnly {return false} return true } if !storagesAreEqual {return false} @@ -3527,3 +3724,111 @@ extension SessionProtos_GroupUpdateDeleteMemberContentMessage: SwiftProtobuf.Mes return true } } + +extension SessionProtos_ProProof: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".ProProof" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "version"), + 2: .same(proto: "genIndexHash"), + 3: .same(proto: "rotatingPublicKey"), + 4: .same(proto: "expiryUnixTs"), + 5: .same(proto: "sig"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt32Field(value: &self._version) }() + case 2: try { try decoder.decodeSingularBytesField(value: &self._genIndexHash) }() + case 3: try { try decoder.decodeSingularBytesField(value: &self._rotatingPublicKey) }() + case 4: try { try decoder.decodeSingularUInt64Field(value: &self._expiryUnixTs) }() + case 5: try { try decoder.decodeSingularBytesField(value: &self._sig) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._version { + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 1) + } }() + try { if let v = self._genIndexHash { + try visitor.visitSingularBytesField(value: v, fieldNumber: 2) + } }() + try { if let v = self._rotatingPublicKey { + try visitor.visitSingularBytesField(value: v, fieldNumber: 3) + } }() + try { if let v = self._expiryUnixTs { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 4) + } }() + try { if let v = self._sig { + try visitor.visitSingularBytesField(value: v, fieldNumber: 5) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: SessionProtos_ProProof, rhs: SessionProtos_ProProof) -> Bool { + if lhs._version != rhs._version {return false} + if lhs._genIndexHash != rhs._genIndexHash {return false} + if lhs._rotatingPublicKey != rhs._rotatingPublicKey {return false} + if lhs._expiryUnixTs != rhs._expiryUnixTs {return false} + if lhs._sig != rhs._sig {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension SessionProtos_ProMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".ProMessage" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "proof"), + 2: .same(proto: "profileBitset"), + 3: .same(proto: "msgBitset"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._proof) }() + case 2: try { try decoder.decodeSingularUInt64Field(value: &self._profileBitset) }() + case 3: try { try decoder.decodeSingularUInt64Field(value: &self._msgBitset) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._proof { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try { if let v = self._profileBitset { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 2) + } }() + try { if let v = self._msgBitset { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 3) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: SessionProtos_ProMessage, rhs: SessionProtos_ProMessage) -> Bool { + if lhs._proof != rhs._proof {return false} + if lhs._profileBitset != rhs._profileBitset {return false} + if lhs._msgBitset != rhs._msgBitset {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index dd848b18e9..8147a51ef8 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -68,7 +68,66 @@ message Content { optional MessageRequestResponse messageRequestResponse = 10; optional ExpirationType expirationType = 12; optional uint32 expirationTimer = 13; + + // NOTE: This timestamp was added to address the issue with 1o1 message envelope timestamps were + // unauthenticated because 1o1 messages encrypt the Content not the envelope. In Groups, the + // entire envelope is encrypted and hence can be trusted. optional uint64 sigTimestamp = 15; + optional ProMessage proMessage = 16; + + // NOTE: Temporary transition field to include the pro-signature into Content for community + // messages to use. + // + // Community messages are currently sent and received as plaintext Content. We call this state of + // the network v0. + // + // We will continue to send Community messages using the Content structure, but, now enhanced with + // the optional `proSigForCommunityMessageOnly` field which contains the pro signature. We call + // this network v1. The new clients running v1 will pack the pro-signature into the payload. We + // maintain forwards compatibility with clients on v0 as we are still sending content + // on the wire, they skip the new pro data. + // + // Simultaneously in v1 the responsibility of parsing the open groups messages will go into + // libsession. Libsession will be setup to try and parse the open groups message as a `Content` + // message at first, if that fails it will try to read the community message as an `Envelope`. + // In summary in a v1 network: + // + // v0 will still receive messages from v1 as they send `Content` community messages. + // + // v1 accepts v0 (`Content`) and v1 (`Envelope`) on the wire for community messages. v1 sends + // `Content` community messages so that there's compatibility with v0. + // + // After a defined transitionary period, we create a new release and update libsession to stop + // sending `Content` for communities and transition to sending `Envelope` for messages. We mark + // this as a v2 network: + // + // v0 will still receive messages from v1 (`Content`) but not v2 (`Envelope`) community + // messages. + // + // v1 accepts v0 (`Content`) and v1 (`Envelope`) on the wire for community messages. v1 sends + // `Content` community messages so that there's compatibility with v0. + // + // v2 swaps the parsing order. it tries parsing v1 (`envelope`) then v0 (`content`) from a + // community message. v2 sends `envelope` community messages so compatbility is maintained with + // v1 but not v0. + // + // After a final transitionary period, v3, remove parsing content entirely from libsession for + // community messages and removes the pro-signature from `content`. in this final stage, v2 and v3 + // are the final set of clients that can continue to talk to each other. + // + // +---------+----------------+-------------+------------------+-------------+-------------+ + // | Version | Sends | Receives v0 | Receives v1 | Receives v2 | Receives v3 | + // | | | (Content) | (Content+ProSig) | (Envelope) | (Envelope) | + // +---------+----------------+-------------+------------------+-------------+-------------+ + // | v0 | Content | Yes | Yes | No | No | + // +---------+----------------+-------------+------------------+-------------+-------------+ + // | v1 | Content+ProSig | Yes | Yes | Yes | Yes | + // +---------+----------------+-------------+------------------+-------------+-------------+ + // | v2 | Envelope | Yes | Yes | Yes | Yes | + // +---------+----------------+-------------+------------------+-------------+-------------+ + // | v3 | Envelope | No | No | Yes | Yes | + // +---------+----------------+-------------+------------------+-------------+-------------+ + optional bytes proSigForCommunityMessageOnly = 17; } message CallMessage { @@ -308,3 +367,17 @@ message GroupUpdateDeleteMemberContentMessage { repeated string messageHashes = 2; optional bytes adminSignature = 3; } + +message ProProof { + optional uint32 version = 1; + optional bytes genIndexHash = 2; // Opaque identifier of this proof produced by the Session Pro backend + optional bytes rotatingPublicKey = 3; // Public key whose signatures is authorised to entitle messages with Session Pro + optional uint64 expiryUnixTs = 4; // Epoch timestamps in milliseconds + optional bytes sig = 5; // Signature produced by the Session Pro Backend signing over the hash of the proof +} + +message ProMessage { + optional ProProof proof = 1; + optional uint64 profileBitset = 2; + optional uint64 msgBitset = 3; +} diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift deleted file mode 100644 index 15d5488927..0000000000 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation - -public enum MessageReceiverError: Error, CustomStringConvertible { - case duplicateMessage - case invalidMessage - case invalidSender - case unknownMessage(SNProtoContent?) - case unknownEnvelopeType - case noUserX25519KeyPair - case noUserED25519KeyPair - case invalidSignature - case noData - case senderBlocked - case noThread - case selfSend - case decryptionFailed - case noGroupKeyPair - case invalidConfigMessageHandling - case outdatedMessage - case ignorableMessage - case ignorableMessageRequestMessage - case duplicatedCall - case missingRequiredAdminPrivileges - case deprecatedMessage - case failedToProcess - - public var isRetryable: Bool { - switch self { - case .duplicateMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, - .invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed, - .invalidConfigMessageHandling, .outdatedMessage, .ignorableMessage, .ignorableMessageRequestMessage, - .missingRequiredAdminPrivileges, .failedToProcess: - return false - - default: return true - } - } - - public var shouldUpdateLastHash: Bool { - switch self { - // If we get one of these errors then we still want to update the last hash to prevent - // retrieving and attempting to process the same messages again (as well as ensure the - // next poll doesn't retrieve the same message - these errors are essentially considered - // "already successfully processed") - case .selfSend, .duplicateMessage, .outdatedMessage, .missingRequiredAdminPrivileges: - return true - - default: return false - } - } - - public var description: String { - switch self { - case .duplicateMessage: return "Duplicate message." - case .invalidMessage: return "Invalid message." - case .invalidSender: return "Invalid sender." - case .unknownMessage(let content): - switch content { - case .none: return "Unknown message type (no content)." - case .some(let content): - let protoInfo: [(String, Bool)] = [ - ("hasDataMessage", (content.dataMessage != nil)), - ("hasProfile", (content.dataMessage?.profile != nil)), - ("hasBody", (content.dataMessage?.hasBody == true)), - ("hasAttachments", (content.dataMessage?.attachments.isEmpty == false)), - ("hasReaction", (content.dataMessage?.reaction != nil)), - ("hasQuote", (content.dataMessage?.quote != nil)), - ("hasLinkPreview", (content.dataMessage?.preview != nil)), - ("hasOpenGroupInvitation", (content.dataMessage?.openGroupInvitation != nil)), - ("hasGroupV2ControlMessage", (content.dataMessage?.groupUpdateMessage != nil)), - ("hasTimestamp", (content.dataMessage?.hasTimestamp == true)), - ("hasSyncTarget", (content.dataMessage?.hasSyncTarget == true)), - ("hasBlocksCommunityMessageRequests", (content.dataMessage?.hasBlocksCommunityMessageRequests == true)), - ("hasCallMessage", (content.callMessage != nil)), - ("hasReceiptMessage", (content.receiptMessage != nil)), - ("hasTypingMessage", (content.typingMessage != nil)), - ("hasDataExtractionMessage", (content.dataExtractionNotification != nil)), - ("hasUnsendRequest", (content.unsendRequest != nil)), - ("hasMessageRequestResponse", (content.messageRequestResponse != nil)), - ("hasExpirationTimer", (content.hasExpirationTimer == true)), - ("hasExpirationType", (content.hasExpirationType == true)), - ("hasSigTimestamp", (content.hasSigTimestamp == true)) - ] - - let protoInfoString: String = protoInfo - .filter { _, val in val } - .map { name, _ in name } - .joined(separator: ", ") - return "Unknown message type (\(protoInfoString))." - } - - case .unknownEnvelopeType: return "Unknown envelope type." - case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair." - case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair." - case .invalidSignature: return "Invalid message signature." - case .noData: return "Received an empty envelope." - case .senderBlocked: return "Received a message from a blocked user." - case .noThread: return "Couldn't find thread for message." - case .selfSend: return "Message addressed at self." - case .decryptionFailed: return "Decryption failed." - - // Shared sender keys - case .noGroupKeyPair: return "Missing group key pair." - - case .invalidConfigMessageHandling: return "Invalid handling of a config message." - case .outdatedMessage: return "Message was sent before a config change which would have removed the message." - case .ignorableMessage: return "Message should be ignored." - case .ignorableMessageRequestMessage: return "Message request message should be ignored." - case .duplicatedCall: return "Duplicate call." - case .missingRequiredAdminPrivileges: return "Handling this message requires admin privileges which the current user does not have." - case .deprecatedMessage: return "This message type has been deprecated." - case .failedToProcess: return "Failed to process." - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift deleted file mode 100644 index 4a4062cfd4..0000000000 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation -import SessionUtilitiesKit - -public enum MessageSenderError: Error, CustomStringConvertible, Equatable { - case invalidMessage - case protoConversionFailed - case noUserX25519KeyPair - case noUserED25519KeyPair - case signingFailed - case encryptionFailed - case noUsername - case attachmentsNotUploaded - case attachmentsInvalid - case blindingFailed - - // Closed groups - case noThread - case noKeyPair - case invalidClosedGroupUpdate - case invalidConfigMessageHandling - case deprecatedLegacyGroup - - case other(Log.Category?, String, Error) - - internal var isRetryable: Bool { - switch self { - case .invalidMessage, .protoConversionFailed, .invalidClosedGroupUpdate, - .signingFailed, .encryptionFailed, .blindingFailed: - return false - - default: return true - } - } - - public var description: String { - switch self { - case .invalidMessage: return "Invalid message (MessageSenderError.invalidMessage)." - case .protoConversionFailed: return "Couldn't convert message to proto (MessageSenderError.protoConversionFailed)." - case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair (MessageSenderError.noUserX25519KeyPair)." - case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair (MessageSenderError.noUserED25519KeyPair)." - case .signingFailed: return "Couldn't sign message (MessageSenderError.signingFailed)." - case .encryptionFailed: return "Couldn't encrypt message (MessageSenderError.encryptionFailed)." - case .noUsername: return "Missing username (MessageSenderError.noUsername)." - case .attachmentsNotUploaded: return "Attachments for this message have not been uploaded (MessageSenderError.attachmentsNotUploaded)." - case .attachmentsInvalid: return "Attachments Invalid (MessageSenderError.attachmentsInvalid)." - case .blindingFailed: return "Couldn't blind the sender (MessageSenderError.blindingFailed)." - - // Closed groups - case .noThread: return "Couldn't find a thread associated with the given group public key (MessageSenderError.noThread)." - case .noKeyPair: return "Couldn't find a private key associated with the given group public key (MessageSenderError.noKeyPair)." - case .invalidClosedGroupUpdate: return "Invalid group update (MessageSenderError.invalidClosedGroupUpdate)." - case .invalidConfigMessageHandling: return "Invalid handling of a config message (MessageSenderError.invalidConfigMessageHandling)." - case .deprecatedLegacyGroup: return "Tried to send a message for a deprecated legacy group (MessageSenderError.deprecatedLegacyGroup)." - case .other(_, _, let error): return "\(error)" - } - } - - public static func == (lhs: MessageSenderError, rhs: MessageSenderError) -> Bool { - switch (lhs, rhs) { - case (.invalidMessage, .invalidMessage): return true - case (.protoConversionFailed, .protoConversionFailed): return true - case (.noUserX25519KeyPair, .noUserX25519KeyPair): return true - case (.noUserED25519KeyPair, .noUserED25519KeyPair): return true - case (.signingFailed, .signingFailed): return true - case (.encryptionFailed, .encryptionFailed): return true - case (.noUsername, .noUsername): return true - case (.attachmentsNotUploaded, .attachmentsNotUploaded): return true - case (.noThread, .noThread): return true - case (.noKeyPair, .noKeyPair): return true - case (.invalidClosedGroupUpdate, .invalidClosedGroupUpdate): return true - case (.deprecatedLegacyGroup, .deprecatedLegacyGroup): return true - case (.blindingFailed, .blindingFailed): return true - - case (.other(_, let lhsDescription, let lhsError), .other(_, let rhsDescription, let rhsError)): - // Not ideal but the best we can do - return ( - lhsDescription == rhsDescription && - "\(lhsError)" == "\(rhsError)" - ) - - default: return false - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index cdd2921bcf..c7132527af 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -21,11 +21,14 @@ extension MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: CallMessage, + decodedMessage: DecodedMessage, suppressNotifications: Bool, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { // Only support calls from contact threads - guard threadVariant == .contact else { throw MessageReceiverError.invalidMessage } + guard threadVariant == .contact else { + throw MessageError.invalidMessage("Calls are only supported in 1-to-1 conversations") + } switch (message.kind, message.state) { case (.preOffer, _): @@ -34,6 +37,7 @@ extension MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, using: dependencies ) @@ -55,6 +59,7 @@ extension MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, using: dependencies ) @@ -72,6 +77,7 @@ extension MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: CallMessage, + decodedMessage: DecodedMessage, suppressNotifications: Bool, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { @@ -85,18 +91,17 @@ extension MessageReceiver { // for this call would be dropped because of no Session call instance guard dependencies[singleton: .appContext].isMainApp, - let sender: String = message.sender, dependencies.mutate(cache: .libSession, { cache in !cache.isMessageRequest(threadId: threadId, threadVariant: threadVariant) }) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.missingRequiredField } guard let timestampMs = message.sentTimestampMs, TimestampUtils.isWithinOneMinute(timestampMs: timestampMs) else { // Add missed call message for call offer messages from more than one minute Log.info(.calls, "Got an expired call offer message with uuid: \(message.uuid). Sent at \(message.sentTimestampMs ?? 0), now is \(Date().timeIntervalSince1970 * 1000)") if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, threadId: threadId, threadVariant: threadVariant, for: message, state: .missed, using: dependencies), let interactionId: Int64 = interaction.id { let thread: SessionThread = try SessionThread.upsert( db, - id: sender, + id: decodedMessage.sender.hexString, variant: .contact, values: .existingOrDefault, using: dependencies @@ -163,7 +168,7 @@ extension MessageReceiver { if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, threadId: threadId, threadVariant: threadVariant, for: message, state: state, using: dependencies), let interactionId: Int64 = interaction.id { let thread: SessionThread = try SessionThread.upsert( db, - id: sender, + id: decodedMessage.sender.hexString, variant: .contact, values: .existingOrDefault, using: dependencies @@ -218,7 +223,7 @@ extension MessageReceiver { NotificationCenter.default.post( name: .missedCall, object: nil, - userInfo: [ Notification.Key.senderId.rawValue: sender ] + userInfo: [ Notification.Key.senderId.rawValue: decodedMessage.sender.hexString ] ) return (threadId, threadVariant, interactionId, interaction.variant, interaction.wasRead, 0) } @@ -236,6 +241,7 @@ extension MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, using: dependencies ) @@ -261,7 +267,7 @@ extension MessageReceiver { /// Handle UI for the new call dependencies[singleton: .callManager].showCallUIForCall( - caller: sender, + caller: decodedMessage.sender.hexString, uuid: message.uuid, mode: .answer, interactionId: interaction?.id @@ -353,37 +359,33 @@ extension MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: CallMessage, + decodedMessage: DecodedMessage, suppressNotifications: Bool, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed) guard - let caller: String = message.sender, let messageInfoData: Data = try? JSONEncoder(using: dependencies).encode(messageInfo), dependencies.mutate(cache: .libSession, { cache in - !cache.isMessageRequest(threadId: caller, threadVariant: threadVariant) + !cache.isMessageRequest(threadId: decodedMessage.sender.hexString, threadVariant: threadVariant) }) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.missingRequiredField } - let messageSentTimestampMs: Int64 = ( - message.sentTimestampMs.map { Int64($0) } ?? - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) let interaction: Interaction = try Interaction( serverHash: message.serverHash, messageUuid: message.uuid, threadId: threadId, threadVariant: threadVariant, - authorId: caller, + authorId: decodedMessage.sender.hexString, variant: .infoCall, body: String(data: messageInfoData, encoding: .utf8), - timestampMs: messageSentTimestampMs, + timestampMs: Int64(decodedMessage.sentTimestampMs), wasRead: dependencies.mutate(cache: .libSession) { cache in cache.timestampAlreadyRead( threadId: threadId, threadVariant: threadVariant, - timestampMs: messageSentTimestampMs, + timestampMs: decodedMessage.sentTimestampMs, openGroupUrlInfo: nil ) }, @@ -404,7 +406,7 @@ extension MessageReceiver { message: message, disappearingMessagesConfiguration: try? DisappearingMessagesConfiguration .fetchOne(db, id: threadId), - authMethod: try Authentication.with(db, swarmPublicKey: threadId, using: dependencies), + authMethod: try Authentication.with(swarmPublicKey: threadId, using: dependencies), onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -456,7 +458,7 @@ extension MessageReceiver { .filter(Interaction.Columns.messageUuid == message.uuid) .isEmpty(db) ).defaulting(to: false) - else { throw MessageReceiverError.duplicatedCall } + else { throw MessageError.duplicatedCall } guard let sender: String = message.sender, @@ -496,7 +498,7 @@ extension MessageReceiver { cache.timestampAlreadyRead( threadId: threadId, threadVariant: threadVariant, - timestampMs: timestampMs, + timestampMs: UInt64(timestampMs), openGroupUrlInfo: nil ) }, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift index 4c5f597204..40b05e3608 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift @@ -18,10 +18,10 @@ extension MessageReceiver { threadVariant == .contact, let sender: String = message.sender, let messageKind: DataExtractionNotification.Kind = message.kind - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Message missing required fields") } /// We no longer support the old screenshot notification - guard messageKind != .screenshot else { throw MessageReceiverError.deprecatedMessage } + guard messageKind != .screenshot else { throw MessageError.deprecatedMessage } let timestampMs: Int64 = ( message.sentTimestampMs.map { Int64($0) } ?? @@ -32,7 +32,7 @@ extension MessageReceiver { cache.timestampAlreadyRead( threadId: threadId, threadVariant: threadVariant, - timestampMs: timestampMs, + timestampMs: UInt64(timestampMs), openGroupUrlInfo: nil ) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift index 51d22cfba3..8989c19ff7 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift @@ -11,16 +11,16 @@ extension MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: Message, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, - proto: SNProtoContent, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard proto.hasExpirationType || proto.hasExpirationTimer else { throw MessageReceiverError.invalidMessage } - guard - threadVariant == .contact, // Groups are handled via the GROUP_INFO config instead - let sender: String = message.sender, - let timestampMs: UInt64 = message.sentTimestampMs - else { throw MessageReceiverError.invalidMessage } + let proto: SNProtoContent = try decodedMessage.decodeProtoContent() + + guard proto.hasExpirationType || proto.hasExpirationTimer else { + throw MessageError.invalidMessage("Message missing required fields") + } + guard threadVariant == .contact else { throw MessageError.invalidMessage("Message type should be handled by config change") } let localConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration .fetchOne(db, id: threadId) @@ -42,14 +42,14 @@ extension MessageReceiver { // If the updated config from this message is different from local config, // this control message should already be removed. if threadId == dependencies[cache: .general].sessionId.hexString && updatedConfig != localConfig { - throw MessageReceiverError.ignorableMessage + throw MessageError.ignorableMessage } return try updatedConfig.insertControlMessage( db, threadVariant: threadVariant, - authorId: sender, - timestampMs: Int64(timestampMs), + authorId: decodedMessage.sender.hexString, + timestampMs: decodedMessage.sentTimestampMs, serverHash: message.serverHash, serverExpirationTimestamp: serverExpirationTimestamp, using: dependencies @@ -60,16 +60,20 @@ extension MessageReceiver { _ db: ObservingDatabase, messageVariant: Message.Variant?, contactId: String?, - version: FeatureVersion?, + decodedMessage: DecodedMessage, using dependencies: Dependencies ) { guard let messageVariant: Message.Variant = messageVariant, let contactId: String = contactId, - let version: FeatureVersion = version + [ .visibleMessage, .expirationTimerUpdate ].contains(messageVariant), + let proto: SNProtoContent = try? decodedMessage.decodeProtoContent() else { return } - guard [ .visibleMessage, .expirationTimerUpdate ].contains(messageVariant) else { return } + let version: FeatureVersion = ((!proto.hasExpirationType && !proto.hasExpirationTimer) ? + .legacyDisappearingMessages : + .newDisappearingMessages + ) _ = try? Contact .filter(id: contactId) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 7e7fda9a59..342b29d26a 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -12,8 +12,10 @@ extension MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: Message, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, suppressNotifications: Bool, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { switch (message, try? SessionId(from: threadId)) { @@ -21,7 +23,9 @@ extension MessageReceiver { return try MessageReceiver.handleGroupInvite( db, message: message, + decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) @@ -29,7 +33,9 @@ extension MessageReceiver { return try MessageReceiver.handleGroupPromotion( db, message: message, + decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) @@ -38,6 +44,7 @@ extension MessageReceiver { db, groupSessionId: sessionId, message: message, + decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, using: dependencies ) @@ -47,6 +54,7 @@ extension MessageReceiver { db, groupSessionId: sessionId, message: message, + decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, using: dependencies ) @@ -56,6 +64,7 @@ extension MessageReceiver { db, groupSessionId: sessionId, message: message, + decodedMessage: decodedMessage, using: dependencies ) return nil @@ -65,6 +74,7 @@ extension MessageReceiver { db, groupSessionId: sessionId, message: message, + decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, using: dependencies ) @@ -74,6 +84,8 @@ extension MessageReceiver { db, groupSessionId: sessionId, message: message, + decodedMessage: decodedMessage, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) return nil @@ -83,11 +95,12 @@ extension MessageReceiver { db, groupSessionId: sessionId, message: message, + decodedMessage: decodedMessage, using: dependencies ) return nil - default: throw MessageReceiverError.invalidMessage + default: throw MessageError.invalidMessage("Attempted to handle unexpected message as group update message: \(type(of: message))") } } @@ -99,8 +112,11 @@ extension MessageReceiver { ) throws { let userSessionId: SessionId = dependencies[cache: .general].sessionId + guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { + throw MessageError.missingRequiredField("sentTimestampMs") + } + guard - let sentTimestampMs: UInt64 = message.sentTimestampMs, Authentication.verify( signature: message.adminSignature, publicKey: message.groupSessionId.publicKey, @@ -119,7 +135,7 @@ extension MessageReceiver { memberAuthData: message.memberAuthData ) ) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Unable to validate group invite") } } // MARK: - Specific Handling @@ -127,14 +143,11 @@ extension MessageReceiver { private static func handleGroupInvite( _ db: ObservingDatabase, message: GroupUpdateInviteMessage, + decodedMessage: DecodedMessage, suppressNotifications: Bool, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs - else { throw MessageReceiverError.invalidMessage } - // Ensure the message is valid try validateGroupInvite(message: message, using: dependencies) @@ -142,11 +155,13 @@ extension MessageReceiver { if let profile = message.profile { try Profile.updateIfNeeded( db, - publicKey: sender, + publicKey: decodedMessage.sender.hexString, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), + displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), + proUpdate: .contactUpdate(Profile.ProState(decodedMessage.decodedPro)), profileUpdateTimestamp: profile.updateTimestampSeconds, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) } @@ -154,8 +169,7 @@ extension MessageReceiver { return try processGroupInvite( db, message: message, - sender: sender, - sentTimestampMs: Int64(sentTimestampMs), + decodedMessage: decodedMessage, groupSessionId: message.groupSessionId, groupName: message.groupName, memberAuthData: message.memberAuthData, @@ -229,28 +243,27 @@ extension MessageReceiver { private static func handleGroupPromotion( _ db: ObservingDatabase, message: GroupUpdatePromoteMessage, + decodedMessage: DecodedMessage, suppressNotifications: Bool, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs, - let groupIdentityKeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .ed25519KeyPair(seed: Array(message.groupIdentitySeed)) - ) - else { throw MessageReceiverError.invalidMessage } - + let groupIdentityKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate( + .ed25519KeyPair(seed: Array(message.groupIdentitySeed)) + ) let groupSessionId: SessionId = SessionId(.group, publicKey: groupIdentityKeyPair.publicKey) // Update profile if needed if let profile = message.profile { try Profile.updateIfNeeded( db, - publicKey: sender, + publicKey: decodedMessage.sender.hexString, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), + displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), + proUpdate: .contactUpdate(Profile.ProState(decodedMessage.decodedPro)), profileUpdateTimestamp: profile.updateTimestampSeconds, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) } @@ -259,8 +272,7 @@ extension MessageReceiver { let insertedInteractionInfo: InsertedInteractionInfo? = try processGroupInvite( db, message: message, - sender: sender, - sentTimestampMs: Int64(sentTimestampMs), + decodedMessage: decodedMessage, groupSessionId: groupSessionId, groupName: message.groupName, memberAuthData: nil, @@ -321,22 +333,21 @@ extension MessageReceiver { _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateInfoChangeMessage, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs, Authentication.verify( signature: message.adminSignature, publicKey: groupSessionId.publicKey, verificationBytes: GroupUpdateInfoChangeMessage.generateVerificationBytes( changeType: message.changeType, - timestampMs: sentTimestampMs + timestampMs: decodedMessage.sentTimestampMs ), using: dependencies ) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Unable to verify group info change message") } // Add a record of the specific change to the conversation (the actual change is handled via // config messages so these are only for record purposes) @@ -356,13 +367,13 @@ extension MessageReceiver { serverHash: message.serverHash, threadId: groupSessionId.hexString, threadVariant: .group, - authorId: sender, + authorId: decodedMessage.sender.hexString, variant: .infoGroupInfoUpdated, body: message.updatedName .map { ClosedGroup.MessageInfo.updatedName($0) } .defaulting(to: ClosedGroup.MessageInfo.updatedNameFallback) .infoString(using: dependencies), - timestampMs: Int64(sentTimestampMs), + timestampMs: Int64(decodedMessage.sentTimestampMs), expiresInSeconds: messageExpirationInfo.expiresInSeconds, expiresStartedAtMs: messageExpirationInfo.expiresStartedAtMs, using: dependencies @@ -373,12 +384,12 @@ extension MessageReceiver { serverHash: message.serverHash, threadId: groupSessionId.hexString, threadVariant: .group, - authorId: sender, + authorId: decodedMessage.sender.hexString, variant: .infoGroupInfoUpdated, body: ClosedGroup.MessageInfo .updatedDisplayPicture .infoString(using: dependencies), - timestampMs: Int64(sentTimestampMs), + timestampMs: Int64(decodedMessage.sentTimestampMs), expiresInSeconds: messageExpirationInfo.expiresInSeconds, expiresStartedAtMs: messageExpirationInfo.expiresStartedAtMs, using: dependencies @@ -396,8 +407,8 @@ extension MessageReceiver { return try config.insertControlMessage( db, threadVariant: .group, - authorId: sender, - timestampMs: Int64(sentTimestampMs), + authorId: decodedMessage.sender.hexString, + timestampMs: decodedMessage.sentTimestampMs, serverHash: message.serverHash, serverExpirationTimestamp: serverExpirationTimestamp, using: dependencies @@ -413,22 +424,21 @@ extension MessageReceiver { _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateMemberChangeMessage, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs, Authentication.verify( signature: message.adminSignature, publicKey: groupSessionId.publicKey, verificationBytes: GroupUpdateMemberChangeMessage.generateVerificationBytes( changeType: message.changeType, - timestampMs: sentTimestampMs + timestampMs: decodedMessage.sentTimestampMs ), using: dependencies ) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Unable to verify group member change message") } let userSessionId: SessionId = dependencies[cache: .general].sessionId let profiles: [String: Profile] = (try? Profile @@ -439,7 +449,7 @@ extension MessageReceiver { let names: [String] = message.memberSessionIds .sortedById(userSessionId: userSessionId) .map { id in - profiles[id]?.displayName(for: .group) ?? + profiles[id]?.displayName() ?? id.truncated() } @@ -494,10 +504,10 @@ extension MessageReceiver { let interaction: Interaction = try Interaction( threadId: groupSessionId.hexString, threadVariant: .group, - authorId: sender, + authorId: decodedMessage.sender.hexString, variant: .infoGroupMembersUpdated, body: messageBody, - timestampMs: Int64(sentTimestampMs), + timestampMs: Int64(decodedMessage.sentTimestampMs), expiresInSeconds: messageExpirationInfo.expiresInSeconds, expiresStartedAtMs: messageExpirationInfo.expiresStartedAtMs, using: dependencies @@ -515,27 +525,26 @@ extension MessageReceiver { _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateMemberLeftMessage, + decodedMessage: DecodedMessage, using dependencies: Dependencies ) throws { // If the user is a group admin then we need to remove the member from the group, we already have a // "member left" message so `sendMemberChangedMessage` should be `false` guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs, dependencies.mutate(cache: .libSession, { cache in cache.isAdmin(groupSessionId: groupSessionId) }) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.ignorableMessage } // Trigger this removal in a separate process because it requires a number of requests to be made db.afterCommit { MessageSender .removeGroupMembers( groupSessionId: groupSessionId.hexString, - memberIds: [sender], + memberIds: [decodedMessage.sender.hexString], removeTheirMessages: false, sendMemberChangedMessage: false, - changeTimestampMs: Int64(sentTimestampMs), + changeTimestampMs: Int64(decodedMessage.sentTimestampMs), using: dependencies ) .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) @@ -547,14 +556,10 @@ extension MessageReceiver { _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateMemberLeftNotificationMessage, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs - else { throw MessageReceiverError.invalidMessage } - // Add a record of the specific change to the conversation (the actual change is handled via // config messages so these are only for record purposes) let messageExpirationInfo: Message.MessageExpirationInfo = Message.getMessageExpirationInfo( @@ -566,6 +571,7 @@ extension MessageReceiver { using: dependencies ) + let sender: String = decodedMessage.sender.hexString let interaction: Interaction = try Interaction( threadId: groupSessionId.hexString, threadVariant: .group, @@ -575,12 +581,12 @@ extension MessageReceiver { .memberLeft( wasCurrentUser: (sender == dependencies[cache: .general].sessionId.hexString), name: ( - (try? Profile.fetchOne(db, id: sender)?.displayName(for: .group)) ?? + (try? Profile.fetchOne(db, id: sender)?.displayName()) ?? sender.truncated() ) ) .infoString(using: dependencies), - timestampMs: Int64(sentTimestampMs), + timestampMs: Int64(decodedMessage.sentTimestampMs), expiresInSeconds: messageExpirationInfo.expiresInSeconds, expiresStartedAtMs: messageExpirationInfo.expiresStartedAtMs, using: dependencies @@ -595,23 +601,24 @@ extension MessageReceiver { _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateInviteResponseMessage, + decodedMessage: DecodedMessage, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws { - guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs, - message.isApproved // Only process the invite response if it was an approval - else { throw MessageReceiverError.invalidMessage } + // Only process the invite response if it was an approval + guard message.isApproved else { throw MessageError.ignorableMessage } // Update profile if needed if let profile = message.profile { try Profile.updateIfNeeded( db, - publicKey: sender, + publicKey: decodedMessage.sender.hexString, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), + displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), + proUpdate: .contactUpdate(Profile.ProState(decodedMessage.decodedPro)), profileUpdateTimestamp: profile.updateTimestampSeconds, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) } @@ -619,12 +626,12 @@ extension MessageReceiver { // Update the member approval state try MessageReceiver.updateMemberApprovalStatusIfNeeded( db, - senderSessionId: sender, + senderSessionId: decodedMessage.sender.hexString, groupSessionIdHexString: groupSessionId.hexString, profile: message.profile.map { profile in profile.displayName.map { - Profile( - id: sender, + Profile.with( + id: decodedMessage.sender.hexString, name: $0, displayPictureUrl: profile.profilePictureUrl, displayPictureEncryptionKey: profile.profileKey, @@ -640,10 +647,9 @@ extension MessageReceiver { _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateDeleteMemberContentMessage, + decodedMessage: DecodedMessage, using dependencies: Dependencies ) throws { - guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { throw MessageReceiverError.invalidMessage } - let interactionIdsToRemove: [Int64] let explicitHashesToRemove: [String] let memberSessionIdsContainsSender: Bool = message.memberSessionIds @@ -659,23 +665,23 @@ extension MessageReceiver { verificationBytes: GroupUpdateDeleteMemberContentMessage.generateVerificationBytes( memberSessionIds: message.memberSessionIds, messageHashes: message.messageHashes, - timestampMs: sentTimestampMs + timestampMs: decodedMessage.sentTimestampMs ), using: dependencies ) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Unable to verify group delete member content message") } /// Find all relevant interactions to remove let interactionIdsForRemovedHashes: [Int64] = try Interaction .filter(Interaction.Columns.threadId == groupSessionId.hexString) .filter(message.messageHashes.asSet().contains(Interaction.Columns.serverHash)) - .filter(Interaction.Columns.timestampMs < sentTimestampMs) + .filter(Interaction.Columns.timestampMs < decodedMessage.sentTimestampMs) .asRequest(of: Int64.self) .fetchAll(db) let interactionIdsSentByRemovedSenders: [Int64] = try Interaction .filter(Interaction.Columns.threadId == groupSessionId.hexString) .filter(message.memberSessionIds.asSet().contains(Interaction.Columns.authorId)) - .filter(Interaction.Columns.timestampMs < sentTimestampMs) + .filter(Interaction.Columns.timestampMs < decodedMessage.sentTimestampMs) .asRequest(of: Int64.self) .fetchAll(db) interactionIdsToRemove = interactionIdsForRemovedHashes + interactionIdsSentByRemovedSenders @@ -686,14 +692,14 @@ extension MessageReceiver { interactionIdsToRemove = try Interaction .filter(Interaction.Columns.threadId == groupSessionId.hexString) .filter(Interaction.Columns.authorId == sender) - .filter(Interaction.Columns.timestampMs < sentTimestampMs) + .filter(Interaction.Columns.timestampMs < decodedMessage.sentTimestampMs) .select(.id) .asRequest(of: Int64.self) .fetchAll(db) explicitHashesToRemove = try Interaction .filter(Interaction.Columns.threadId == groupSessionId.hexString) .filter(Interaction.Columns.authorId == sender) - .filter(Interaction.Columns.timestampMs < sentTimestampMs) + .filter(Interaction.Columns.timestampMs < decodedMessage.sentTimestampMs) .filter(Interaction.Columns.serverHash != nil) .select(.serverHash) .asRequest(of: String.self) @@ -705,7 +711,7 @@ extension MessageReceiver { .filter(Interaction.Columns.threadId == groupSessionId.hexString) .filter(Interaction.Columns.authorId == sender) .filter(message.messageHashes.asSet().contains(Interaction.Columns.serverHash)) - .filter(Interaction.Columns.timestampMs < sentTimestampMs) + .filter(Interaction.Columns.timestampMs < decodedMessage.sentTimestampMs) .select(.id) .asRequest(of: Int64.self) .fetchAll(db) @@ -713,13 +719,14 @@ extension MessageReceiver { .filter(Interaction.Columns.threadId == groupSessionId.hexString) .filter(Interaction.Columns.authorId == sender) .filter(message.messageHashes.asSet().contains(Interaction.Columns.serverHash)) - .filter(Interaction.Columns.timestampMs < sentTimestampMs) + .filter(Interaction.Columns.timestampMs < decodedMessage.sentTimestampMs) .filter(Interaction.Columns.serverHash != nil) .select(.serverHash) .asRequest(of: String.self) .fetchAll(db) - case (.none, .none, _): throw MessageReceiverError.invalidMessage + case (.none, .none, _): + throw MessageError.invalidMessage("Invalid group delete member content message configuration") } /// Retrieve the hashes which should be deleted first (these will be removed from the local @@ -747,7 +754,6 @@ extension MessageReceiver { cache.isAdmin(groupSessionId: groupSessionId) }), let authMethod: AuthenticationMethod = try? Authentication.with( - db, swarmPublicKey: groupSessionId.hexString, using: dependencies ) @@ -834,6 +840,13 @@ extension MessageReceiver { groupSessionIds: [groupSessionId.hexString], using: dependencies ) + + /// Notify of being marked as kicked + db.addConversationEvent( + id: groupSessionId.hexString, + variant: .group, + type: .updated(.markedAsKicked) + ) } /// Delete the group data (if the group is a message request then delete it entirely, otherwise we want to keep a shell of group around because @@ -862,11 +875,10 @@ extension MessageReceiver { // MARK: - Shared - internal static func processGroupInvite( + private static func processGroupInvite( _ db: ObservingDatabase, message: Message, - sender: String, - sentTimestampMs: Int64, + decodedMessage: DecodedMessage, groupSessionId: SessionId, groupName: String, memberAuthData: Data?, @@ -882,7 +894,7 @@ extension MessageReceiver { let inviteSenderIsApproved: Bool = { guard !dependencies[feature: .updatedGroupsDisableAutoApprove] else { return false } - return ((try? Contact.fetchOne(db, id: sender))?.isApproved == true) + return ((try? Contact.fetchOne(db, id: decodedMessage.sender.hexString))?.isApproved == true) }() let threadAlreadyExisted: Bool = ((try? SessionThread.exists(db, id: groupSessionId.hexString)) ?? false) @@ -897,7 +909,7 @@ extension MessageReceiver { groupIdentityPrivateKey: groupIdentityPrivateKey, name: groupName, authData: memberAuthData, - joinedAt: TimeInterval(Double(sentTimestampMs) / 1000), + joinedAt: TimeInterval(Double(decodedMessage.sentTimestampMs) / 1000), invited: !inviteSenderIsApproved, forceMarkAsInvited: wasKickedFromGroup, using: dependencies @@ -906,7 +918,7 @@ extension MessageReceiver { /// Add the sender as a group admin (so we can retrieve their profile details for Group Message Request UI) try GroupMember( groupId: groupSessionId.hexString, - profileId: sender, + profileId: decodedMessage.sender.hexString, role: .admin, roleStatus: .accepted, isHidden: false @@ -919,20 +931,17 @@ extension MessageReceiver { case .none: break case .some(let serverHash): db.afterCommit { - dependencies[singleton: .storage] - .readPublisher { db in - try Network.SnodeAPI.preparedDeleteMessages( - serverHashes: [serverHash], - requireSuccessfulDeletion: false, - authMethod: try Authentication.with( - db, - swarmPublicKey: userSessionId.hexString, - using: dependencies - ), + try? Network.SnodeAPI + .preparedDeleteMessages( + serverHashes: [serverHash], + requireSuccessfulDeletion: false, + authMethod: try Authentication.with( + swarmPublicKey: userSessionId.hexString, using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + ), + using: dependencies + ) + .send(using: dependencies) .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) .sinkUntilComplete() } @@ -953,18 +962,19 @@ extension MessageReceiver { /// Unline most control messages we don't bother setting expiration values for this message, this is because we won't actually /// have the current disappearing messages config as we won't have polled the group yet (and the settings are stored in the /// `GroupInfo` config) + let sender: String = decodedMessage.sender.hexString let interaction: Interaction = try Interaction( threadId: groupSessionId.hexString, threadVariant: .group, - authorId: sender, + authorId: decodedMessage.sender.hexString, variant: .infoGroupInfoInvited, body: { switch groupIdentityPrivateKey { case .none: return ClosedGroup.MessageInfo .invited( - (try? Profile.fetchOne(db, id: sender)?.displayName(for: .group)) - .defaulting(to: sender.truncated(threadVariant: .group)), + (try? Profile.fetchOne(db, id: sender)?.displayName()) + .defaulting(to: sender.truncated()), groupName ) .infoString(using: dependencies) @@ -972,19 +982,19 @@ extension MessageReceiver { case .some: return ClosedGroup.MessageInfo .invitedAdmin( - (try? Profile.fetchOne(db, id: sender)?.displayName(for: .group)) - .defaulting(to: sender.truncated(threadVariant: .group)), + (try? Profile.fetchOne(db, id: sender)?.displayName()) + .defaulting(to: sender.truncated()), groupName ) .infoString(using: dependencies) } }(), - timestampMs: sentTimestampMs, + timestampMs: Int64(decodedMessage.sentTimestampMs), wasRead: dependencies.mutate(cache: .libSession) { cache in cache.timestampAlreadyRead( threadId: groupSessionId.hexString, threadVariant: .group, - timestampMs: sentTimestampMs, + timestampMs: decodedMessage.sentTimestampMs, openGroupUrlInfo: nil ) }, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift index f209de30ed..587493c4ff 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift @@ -24,7 +24,7 @@ extension MessageReceiver { guard let sender: String = message.sender, let senderSessionId: SessionId = try? SessionId(from: sender) - else { throw MessageReceiverError.decryptionFailed } + else { throw MessageError.invalidSender } let supportedEncryptionDomains: [LibSession.Crypto.Domain] = [ .kickedMessage diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index aa966d9d39..4f57461383 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -12,26 +12,27 @@ extension MessageReceiver { internal static func handleMessageRequestResponse( _ db: ObservingDatabase, message: MessageRequestResponse, + decodedMessage: DecodedMessage, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { let userSessionId = dependencies[cache: .general].sessionId var blindedContactIds: [String] = [] // Ignore messages which were sent from the current user - guard - message.sender != userSessionId.hexString, - let senderId: String = message.sender - else { throw MessageReceiverError.invalidMessage } + guard message.sender != userSessionId.hexString else { throw MessageError.ignorableMessage } + guard let senderId: String = message.sender else { throw MessageError.missingRequiredField("sender") } - // Update profile if needed (want to do this regardless of whether the message exists or - // not to ensure the profile info gets sync between a users devices at every chance) + // Update profile if needed if let profile = message.profile { try Profile.updateIfNeeded( db, publicKey: senderId, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), + displayPictureUpdate: .contactUpdateTo(profile, fallback: .none), + proUpdate: .contactUpdate(Profile.ProState(decodedMessage.decodedPro)), profileUpdateTimestamp: profile.updateTimestampSeconds, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) } @@ -107,14 +108,19 @@ extension MessageReceiver { .filter(Interaction.Columns.threadId == blindedIdLookup.blindedId) .updateAll(db, Interaction.Columns.threadId.set(to: unblindedThread.id)) - _ = try SessionThread - .deleteOrLeave( - db, - type: .deleteContactConversationAndContact, // Blinded contact isn't synced anyway - threadId: blindedIdLookup.blindedId, - threadVariant: .contact, - using: dependencies - ) + _ = try SessionThread.deleteOrLeave( + db, + type: .deleteContactConversationAndContact, // Blinded contact isn't synced anyway + threadId: blindedIdLookup.blindedId, + threadVariant: .contact, + using: dependencies + ) + + // Notify about unblinding event + db.addContactEvent( + id: blindedIdLookup.blindedId, + change: .unblinded(blindedId: blindedIdLookup.blindedId, unblindedId: senderId) + ) } // Update the `didApproveMe` state of the sender diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift index ec2aa46540..6d577293d9 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import SessionNetworkingKit import SessionUtilitiesKit @@ -68,13 +69,12 @@ extension MessageReceiver { switch threadVariant { case .legacyGroup, .group, .community: break case .contact: - dependencies[singleton: .storage] - .readPublisher { db in + AnyPublisher + .lazy { try Network.SnodeAPI.preparedDeleteMessages( serverHashes: Array(hashes), requireSuccessfulDeletion: false, authMethod: try Authentication.with( - db, swarmPublicKey: dependencies[cache: .general].sessionId.hexString, using: dependencies ), diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index ea756faa8d..4e744234e0 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -20,31 +20,25 @@ extension MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: VisibleMessage, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, - associatedWithProto proto: SNProtoContent, suppressNotifications: Bool, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> InsertedInteractionInfo { - guard let sender: String = message.sender, let dataMessage = proto.dataMessage else { - throw MessageReceiverError.invalidMessage - } - - // Note: `message.sentTimestamp` is in ms (convert to TimeInterval before converting to - // seconds to maintain the accuracy) - let messageSentTimestampMs: UInt64 = message.sentTimestampMs ?? 0 - let messageSentTimestamp: TimeInterval = TimeInterval(Double(messageSentTimestampMs) / 1000) let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive] - // Update profile if needed (want to do this regardless of whether the message exists or - // not to ensure the profile info gets sync between a users devices at every chance) + // Update profile if needed if let profile = message.profile { try Profile.updateIfNeeded( db, - publicKey: sender, + publicKey: decodedMessage.sender.hexString, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), + displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), + proUpdate: .contactUpdate(Profile.ProState(decodedMessage.decodedPro)), profileUpdateTimestamp: profile.updateTimestampSeconds, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) } @@ -55,13 +49,13 @@ extension MessageReceiver { case .community: // Only process visible messages for communities if they have an existing thread guard (try? SessionThread.exists(db, id: threadId)) == true else { - throw MessageReceiverError.noThread + throw MessageError.messageRequiresThreadToExistButThreadDoesNotExist } case .legacyGroup, .group: // Only process visible messages for groups if they have a ClosedGroup record guard (try? ClosedGroup.exists(db, id: threadId)) == true else { - throw MessageReceiverError.noThread + throw MessageError.messageRequiresThreadToExistButThreadDoesNotExist } } @@ -72,7 +66,9 @@ extension MessageReceiver { id: threadId, variant: threadVariant, values: SessionThread.TargetValues( - creationDateTimestamp: .useExistingOrSetTo(messageSentTimestamp), + creationDateTimestamp: .useExistingOrSetTo( + TimeInterval(Double(decodedMessage.sentTimestampMs) / 1000) + ), shouldBeVisible: .useExisting ), using: dependencies @@ -83,24 +79,21 @@ extension MessageReceiver { return try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: threadId) }() let variant: Interaction.Variant = try { - guard - let senderSessionId: SessionId = try? SessionId(from: sender), - let openGroupUrlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo - else { - return (sender == userSessionId.hexString ? + guard let openGroupUrlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo else { + return (decodedMessage.sender == userSessionId ? .standardOutgoing : .standardIncoming ) } // Need to check if the blinded id matches for open groups - switch senderSessionId.prefix { + switch decodedMessage.sender.prefix { case .blinded15, .blinded25: guard dependencies[singleton: .crypto].verify( .sessionId( userSessionId.hexString, - matchesBlindedId: sender, + matchesBlindedId: decodedMessage.sender.hexString, serverPublicKey: openGroupUrlInfo.publicKey ) ) @@ -109,14 +102,14 @@ extension MessageReceiver { return .standardOutgoing case .standard, .unblinded: - return (sender == userSessionId.hexString ? + return (decodedMessage.sender == userSessionId ? .standardOutgoing : .standardIncoming ) case .group, .versionBlinded07: Log.info(.messageReceiver, "Ignoring message with invalid sender.") - throw MessageReceiverError.invalidSender + throw MessageError.invalidSender } }() let generateCurrentUserSessionIds: () -> Set = { @@ -149,9 +142,7 @@ extension MessageReceiver { db, thread: thread, message: message, - associatedWithProto: proto, - sender: sender, - messageSentTimestamp: messageSentTimestamp, + decodedMessage: decodedMessage, openGroupUrlInfo: openGroupUrlInfo, currentUserSessionIds: generateCurrentUserSessionIds(), suppressNotifications: suppressNotifications, @@ -173,7 +164,7 @@ extension MessageReceiver { cache.timestampAlreadyRead( threadId: thread.id, threadVariant: thread.variant, - timestampMs: Int64(messageSentTimestamp * 1000), + timestampMs: decodedMessage.sentTimestampMs, openGroupUrlInfo: openGroupUrlInfo ) } @@ -187,10 +178,10 @@ extension MessageReceiver { using: dependencies ) do { - let isProMessage: Bool = dependencies.mutate(cache: .libSession, { $0.validateProProof(for: message) }) - let processedMessageBody: String? = Self.truncateMessageTextIfNeeded( + let processedMessageBody: String? = processMessageBody( message.text, - isProMessage: isProMessage, + decodedMessage: decodedMessage, + threadVariant: thread.variant, dependencies: dependencies ) @@ -198,16 +189,16 @@ extension MessageReceiver { serverHash: message.serverHash, // Keep track of server hash threadId: thread.id, threadVariant: thread.variant, - authorId: sender, + authorId: decodedMessage.sender.hexString, variant: variant, body: processedMessageBody, - timestampMs: Int64(messageSentTimestamp * 1000), + timestampMs: Int64(decodedMessage.sentTimestampMs), wasRead: wasRead, hasMention: Interaction.isUserMentioned( db, threadId: thread.id, body: processedMessageBody, - quoteAuthorId: dataMessage.quote?.author, + quoteAuthorId: message.quote?.authorId, using: dependencies ), expiresInSeconds: messageExpirationInfo.expiresInSeconds, @@ -222,7 +213,8 @@ extension MessageReceiver { // If we received an outgoing message then we can assume the interaction has already // been sent, otherwise we should just use whatever the default state is state: (variant == .standardOutgoing ? .sent : nil), - isProMessage: isProMessage, + proMessageFeatures: (message.proMessageFeatures ?? .none), + proProfileFeatures: (message.proProfileFeatures ?? .none), using: dependencies ).inserted(db) } @@ -231,11 +223,12 @@ extension MessageReceiver { case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: guard variant == .standardOutgoing, - let existingInteractionId: Int64 = try? thread.interactions + let existingInteractionId: Int64 = try? Interaction .select(.id) - .filter(Interaction.Columns.timestampMs == (messageSentTimestamp * 1000)) + .filter(Interaction.Columns.threadId == thread.id) + .filter(Interaction.Columns.timestampMs == decodedMessage.sentTimestampMs) .filter(Interaction.Columns.variant == variant) - .filter(Interaction.Columns.authorId == sender) + .filter(Interaction.Columns.authorId == decodedMessage.sender.hexString) .asRequest(of: Int64.self) .fetchOne(db) else { break } @@ -247,7 +240,7 @@ extension MessageReceiver { db, thread: thread, interactionId: existingInteractionId, - messageSentTimestamp: messageSentTimestamp, + messageSentTimestampMs: decodedMessage.sentTimestampMs, variant: variant, syncTarget: message.syncTarget, using: dependencies @@ -276,7 +269,7 @@ extension MessageReceiver { db, thread: thread, interactionId: interactionId, - messageSentTimestamp: messageSentTimestamp, + messageSentTimestampMs: decodedMessage.sentTimestampMs, variant: variant, syncTarget: message.syncTarget, using: dependencies @@ -303,45 +296,61 @@ extension MessageReceiver { expireInSeconds: message.expiresInSeconds, using: dependencies ) - + // Parse & persist attachments - let attachments: [Attachment] = try dataMessage.attachments - .compactMap { proto -> Attachment? in - let attachment: Attachment = Attachment(proto: proto) - - // Attachments on received messages must have a 'downloadUrl' otherwise - // they are invalid and we can ignore them - return (attachment.downloadUrl != nil ? attachment : nil) - } - .enumerated() - .map { index, attachment in - let savedAttachment: Attachment = try attachment.upserted(db) - - // Link the attachment to the interaction and add to the id lookup - try InteractionAttachment( - albumIndex: index, - interactionId: interactionId, - attachmentId: savedAttachment.id - ).insert(db) - - return savedAttachment - } + let proto: SNProtoContent = try decodedMessage.decodeProtoContent() + var attachments: [Attachment] = [] - message.attachmentIds = attachments.map { $0.id } + if + let protoAttachments: [SNProtoAttachmentPointer] = proto.dataMessage?.attachments, + !protoAttachments.isEmpty + { + attachments = try protoAttachments + .compactMap { proto -> Attachment? in + let attachment: Attachment = Attachment(proto: proto) + + // Attachments on received messages must have a 'downloadUrl' otherwise + // they are invalid and we can ignore them + return (attachment.downloadUrl != nil ? attachment : nil) + } + .enumerated() + .map { index, attachment in + let savedAttachment: Attachment = try attachment.upserted(db) + + // Link the attachment to the interaction and add to the id lookup + try InteractionAttachment( + albumIndex: index, + interactionId: interactionId, + attachmentId: savedAttachment.id + ).insert(db) + + return savedAttachment + } + + message.attachmentIds = attachments.map { $0.id } + } // Persist quote if needed - try? Quote( - proto: dataMessage, - interactionId: interactionId, - thread: thread - )?.insert(db) + if let quote: VisibleMessage.VMQuote = message.quote { + try? Quote( + interactionId: interactionId, + authorId: quote.authorId, + timestampMs: Int64(quote.timestamp) + ).insert(db) + } // Parse link preview if needed - let linkPreview: LinkPreview? = try? LinkPreview( - db, - proto: dataMessage, - sentTimestampMs: (messageSentTimestamp * 1000) - )?.upserted(db) + var linkPreviewAttachmentId: String? + if let linkPreview: VisibleMessage.VMLinkPreview = message.linkPreview { + let linkPreview: LinkPreview? = try? LinkPreview( + db, + linkPreview: linkPreview, + sentTimestampMs: decodedMessage.sentTimestampMs + ) + _ = try? linkPreview?.upserted(db) + + linkPreviewAttachmentId = linkPreview?.attachmentId + } // Open group invitations are stored as LinkPreview values so create one if needed if @@ -350,7 +359,7 @@ extension MessageReceiver { { try LinkPreview( url: openGroupInvitationUrl, - timestamp: LinkPreview.timestampFor(sentTimestampMs: (messageSentTimestamp * 1000)), + timestamp: LinkPreview.timestampFor(sentTimestampMs: decodedMessage.sentTimestampMs), variant: .openGroupInvitation, title: openGroupInvitationName, using: dependencies @@ -359,12 +368,12 @@ extension MessageReceiver { // Start attachment downloads if needed (ie. trusted contact or group thread) // FIXME: Replace this to check the `autoDownloadAttachments` flag we are adding to threads - let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: sender))?.isTrusted ?? false) + let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: decodedMessage.sender.hexString))?.isTrusted ?? false) if isContactTrusted || thread.variant != .contact { attachments .map { $0.id } - .appending(linkPreview?.attachmentId) + .appending(linkPreviewAttachmentId) .forEach { attachmentId in dependencies[singleton: .jobRunner].add( db, @@ -401,7 +410,7 @@ extension MessageReceiver { case .contact: try MessageReceiver.updateContactApprovalStatusIfNeeded( db, - senderSessionId: sender, + senderSessionId: decodedMessage.sender.hexString, threadId: thread.id, using: dependencies ) @@ -409,7 +418,7 @@ extension MessageReceiver { case .group: try MessageReceiver.updateMemberApprovalStatusIfNeeded( db, - senderSessionId: sender, + senderSessionId: decodedMessage.sender.hexString, groupSessionIdHexString: thread.id, profile: nil, // Don't update the profile in this case using: dependencies @@ -508,18 +517,13 @@ extension MessageReceiver { _ db: ObservingDatabase, thread: SessionThread, message: VisibleMessage, - associatedWithProto proto: SNProtoContent, - sender: String, - messageSentTimestamp: TimeInterval, + decodedMessage: DecodedMessage, openGroupUrlInfo: LibSession.OpenGroupUrlInfo?, currentUserSessionIds: Set, suppressNotifications: Bool, using dependencies: Dependencies ) throws -> Int64? { - guard - let vmReaction: VisibleMessage.VMReaction = message.reaction, - proto.dataMessage?.reaction != nil - else { return nil } + guard let vmReaction: VisibleMessage.VMReaction = message.reaction else { return nil } // Since we have database access here make sure the original message for this reaction exists // before handling it or showing a notification @@ -548,22 +552,29 @@ extension MessageReceiver { // Determine whether the app is active based on the prefs rather than the UIApplication state to avoid // requiring main-thread execution let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive] - let timestampMs: Int64 = Int64(messageSentTimestamp * 1000) let userSessionId: SessionId = dependencies[cache: .general].sessionId - _ = try Reaction( + try Reaction( interactionId: interactionId, serverHash: message.serverHash, - timestampMs: timestampMs, - authorId: sender, + timestampMs: Int64(decodedMessage.sentTimestampMs), + authorId: decodedMessage.sender.hexString, emoji: vmReaction.emoji, count: 1, sortId: sortId - ).inserted(db) + ).insert(db) + + // Notify of reaction event + db.addReactionEvent( + id: db.lastInsertedRowID, + messageId: interactionId, + change: .added(vmReaction.emoji) + ) + let timestampAlreadyRead: Bool = dependencies.mutate(cache: .libSession) { cache in cache.timestampAlreadyRead( threadId: thread.id, threadVariant: thread.variant, - timestampMs: timestampMs, + timestampMs: decodedMessage.sentTimestampMs, openGroupUrlInfo: openGroupUrlInfo ) } @@ -572,9 +583,9 @@ extension MessageReceiver { // the conversation or the reaction is for the sender's own message if !suppressNotifications && - sender != userSessionId.hexString && + decodedMessage.sender != userSessionId && !timestampAlreadyRead && - vmReaction.publicKey != sender + vmReaction.publicKey != decodedMessage.sender.hexString { try? dependencies[singleton: .notificationsManager].notifyUser( cat: .messageReceiver, @@ -618,11 +629,27 @@ extension MessageReceiver { } case .remove: + let rowIds: [Int64] = try Reaction + .select(Column.rowID) + .filter(Reaction.Columns.interactionId == interactionId) + .filter(Reaction.Columns.authorId == decodedMessage.sender.hexString) + .filter(Reaction.Columns.emoji == vmReaction.emoji) + .asRequest(of: Int64.self) + .fetchAll(db) try Reaction .filter(Reaction.Columns.interactionId == interactionId) - .filter(Reaction.Columns.authorId == sender) + .filter(Reaction.Columns.authorId == decodedMessage.sender.hexString) .filter(Reaction.Columns.emoji == vmReaction.emoji) .deleteAll(db) + + // Notify of reaction event + rowIds.forEach { + db.addReactionEvent( + id: $0, + messageId: interactionId, + change: .removed(vmReaction.emoji) + ) + } } return interactionId @@ -632,7 +659,7 @@ extension MessageReceiver { _ db: ObservingDatabase, thread: SessionThread, interactionId: Int64, - messageSentTimestamp: TimeInterval, + messageSentTimestampMs: UInt64, variant: Interaction.Variant, syncTarget: String?, using dependencies: Dependencies @@ -668,7 +695,7 @@ extension MessageReceiver { // Process any PendingReadReceipt values let maybePendingReadReceipt: PendingReadReceipt? = try PendingReadReceipt .filter(PendingReadReceipt.Columns.threadId == thread.id) - .filter(PendingReadReceipt.Columns.interactionTimestampMs == Int64(messageSentTimestamp * 1000)) + .filter(PendingReadReceipt.Columns.interactionTimestampMs == messageSentTimestampMs) .fetchOne(db) if let pendingReadReceipt: PendingReadReceipt = maybePendingReadReceipt { @@ -684,24 +711,46 @@ extension MessageReceiver { } } - private static func truncateMessageTextIfNeeded( + private static func processMessageBody( _ text: String?, - isProMessage: Bool, + decodedMessage: DecodedMessage, + threadVariant: SessionThread.Variant, dependencies: Dependencies ) -> String? { - guard let text = text else { return nil } + guard let text: String = text else { return nil } + + /// Extract the features used for the message + let info: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].messageFeatures(for: text) + let proStatus: SessionPro.DecodedStatus? = dependencies[singleton: .sessionProManager].proStatus( + for: decodedMessage.decodedPro?.proProof, + verifyPubkey: { + switch threadVariant { + case .community: return Array(Data(hex: Network.SessionPro.serverEdPublicKey)) + default: return decodedMessage.senderEd25519Pubkey + } + }(), + atTimestampMs: decodedMessage.sentTimestampMs + ) + + /// Check if the message is too long + guard + info.status == .exceedsCharacterLimit || ( + proStatus != .valid && + info.features.contains(.largerCharacterLimit) + ) + else { return text } + // FIXME: Replace this with a libSession-based truncation solution let utf16View = text.utf16 - // TODO: Remove after Session Pro is enabled - let isSessionProEnabled: Bool = (dependencies.hasSet(feature: .sessionProEnabled) && dependencies[feature: .sessionProEnabled]) - let offset: Int = (isSessionProEnabled && !isProMessage) ? - LibSession.CharacterLimit : - LibSession.ProCharacterLimit + let characterLimit: Int = (proStatus == .valid ? + SessionPro.ProCharacterLimit : + SessionPro.CharacterLimit + ) - guard utf16View.count > offset else { return text } + guard utf16View.count > characterLimit else { return text } // Get the index at the maxUnits position in UTF16 - let endUTF16Index = utf16View.index(utf16View.startIndex, offsetBy: offset) + let endUTF16Index = utf16View.index(utf16View.startIndex, offsetBy: characterLimit) // Try converting that UTF16 index back to a String.Index if let endIndex = String.Index(endUTF16Index, within: text) { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index 401bbe1647..d89149040c 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -79,7 +79,7 @@ extension MessageSender { .addedUsers( hasCurrentUser: false, names: sortedOtherMembers.map { id, profile in - profile?.displayName(for: .group) ?? + profile?.displayName() ?? id.truncated() }, historyShared: false @@ -98,7 +98,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: createdInfo.group.id, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: createdInfo.group.id), + destination: .group(publicKey: createdInfo.group.id), message: GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: sortedOtherMembers.map { id, _ in id }, @@ -131,6 +131,10 @@ extension MessageSender { try await ConfigurationSyncJob.run( swarmPublicKey: preparedGroupData.groupSessionId.hexString, requireAllRequestsSucceed: true, + customAuthMethod: Authentication.groupAdmin( + groupSessionId: preparedGroupData.groupSessionId, + ed25519SecretKey: preparedGroupData.identityKeyPair.secretKey + ), using: dependencies ).values.first { _ in true } } @@ -238,7 +242,7 @@ extension MessageSender { using dependencies: Dependencies ) -> AnyPublisher { guard let sessionId: SessionId = try? SessionId(from: groupSessionId), sessionId.prefix == .group else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() + return Fail(error: MessageError.requiresGroupId(groupSessionId)).eraseToAnyPublisher() } return dependencies[singleton: .storage] @@ -246,7 +250,7 @@ extension MessageSender { guard let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: sessionId.hexString), let groupIdentityPrivateKey: Data = closedGroup.groupIdentityPrivateKey - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } let userSessionId: SessionId = dependencies[cache: .general].sessionId let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() @@ -258,12 +262,17 @@ extension MessageSender { if name != closedGroup.name { groupChanges.append(ClosedGroup.Columns.name.set(to: name)) - db.addConversationEvent(id: groupSessionId, type: .updated(.displayName(name))) + db.addConversationEvent( + id: groupSessionId, + variant: .group, + type: .updated(.displayName(name)) + ) } if groupDescription != closedGroup.groupDescription { groupChanges.append(ClosedGroup.Columns.groupDescription.set(to: groupDescription)) db.addConversationEvent( id: groupSessionId, + variant: .group, type: .updated(.description(groupDescription)) ) } @@ -310,7 +319,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: sessionId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: sessionId.hexString), + destination: .group(publicKey: sessionId.hexString), message: GroupUpdateInfoChangeMessage( changeType: .name, updatedName: name, @@ -342,7 +351,7 @@ extension MessageSender { using dependencies: Dependencies ) async throws { guard let sessionId: SessionId = try? SessionId(from: groupSessionId), sessionId.prefix == .group else { - throw MessageSenderError.invalidClosedGroupUpdate + throw MessageError.requiresGroupId(groupSessionId) } try await dependencies[singleton: .storage].writeAsync { db in @@ -352,7 +361,7 @@ extension MessageSender { .select(.groupIdentityPrivateKey) .asRequest(of: Data.self) .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } let userSessionId: SessionId = dependencies[cache: .general].sessionId let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() @@ -381,7 +390,7 @@ extension MessageSender { using: dependencies ) - default: throw MessageSenderError.invalidClosedGroupUpdate + default: throw MessageError.invalidGroupUpdate("Invalid display picture update provided: \(displayPictureUpdate)") } } } @@ -413,7 +422,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: sessionId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: sessionId.hexString), + destination: .group(publicKey: sessionId.hexString), message: GroupUpdateInfoChangeMessage( changeType: .avatar, sentTimestampMs: UInt64(changeTimestampMs), @@ -442,7 +451,7 @@ extension MessageSender { using dependencies: Dependencies ) -> AnyPublisher { guard let sessionId: SessionId = try? SessionId(from: groupSessionId), sessionId.prefix == .group else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() + return Fail(error: MessageError.requiresGroupId(groupSessionId)).eraseToAnyPublisher() } return dependencies[singleton: .storage] @@ -453,9 +462,9 @@ extension MessageSender { .select(.groupIdentityPrivateKey) .asRequest(of: Data.self) .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } - let currentOffsetTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let currentOffsetTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() /// Perform the config changes without triggering a config sync (we will trigger one manually as part of the process) try dependencies.mutate(cache: .libSession) { cache in @@ -494,7 +503,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: sessionId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: sessionId.hexString), + destination: .group(publicKey: sessionId.hexString), message: GroupUpdateInfoChangeMessage( changeType: .disappearingMessages, updatedExpiration: UInt32(updatedConfig.isEnabled ? @@ -529,7 +538,7 @@ extension MessageSender { using dependencies: Dependencies ) -> AnyPublisher { guard let sessionId: SessionId = try? SessionId(from: groupSessionId), sessionId.prefix == .group else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() + return Fail(error: MessageError.requiresGroupId(groupSessionId)).eraseToAnyPublisher() } typealias MemberJobData = ( @@ -551,7 +560,7 @@ extension MessageSender { .select(.groupIdentityPrivateKey) .asRequest(of: Data.self) .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() var maybeSupplementalKeyRequest: Network.PreparedRequest? @@ -626,6 +635,12 @@ extension MessageSender { roleStatus: .sending, isHidden: false ).upsert(db) + + db.addGroupMemberEvent( + profileId: id, + threadId: sessionId.hexString, + type: .created + ) } } } @@ -684,7 +699,7 @@ extension MessageSender { .addedUsers( hasCurrentUser: members.contains { id, _ in id == userSessionId.hexString }, names: sortedMembers.map { id, profile in - profile?.displayName(for: .group) ?? + profile?.displayName() ?? id.truncated() }, historyShared: allowAccessToHistoricMessages @@ -706,7 +721,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: sessionId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: sessionId.hexString), + destination: .group(publicKey: sessionId.hexString), message: GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: sortedMembers.map { id, _ in id }, @@ -770,7 +785,7 @@ extension MessageSender { using dependencies: Dependencies ) -> AnyPublisher { guard let sessionId: SessionId = try? SessionId(from: groupSessionId), sessionId.prefix == .group else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() + return Fail(error: MessageError.requiresGroupId(groupSessionId)).eraseToAnyPublisher() } return dependencies[singleton: .storage] @@ -781,7 +796,7 @@ extension MessageSender { .select(.groupIdentityPrivateKey) .asRequest(of: Data.self) .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() var maybeSupplementalKeyRequest: Network.PreparedRequest? @@ -962,7 +977,7 @@ extension MessageSender { .select(.groupIdentityPrivateKey) .asRequest(of: Data.self) .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } /// Perform the config changes without triggering a config sync (we will do so manually after the process completes) try dependencies.mutate(cache: .libSession) { cache in @@ -1020,7 +1035,7 @@ extension MessageSender { .removedUsers( hasCurrentUser: memberIds.contains(userSessionId.hexString), names: sortedMemberIds.map { id in - removedMemberProfiles[id]?.displayName(for: .group) ?? + removedMemberProfiles[id]?.displayName() ?? id.truncated() } ) @@ -1041,7 +1056,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: sessionId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: sessionId.hexString), + destination: .group(publicKey: sessionId.hexString), message: GroupUpdateMemberChangeMessage( changeType: .removed, memberSessionIds: sortedMemberIds, @@ -1084,7 +1099,7 @@ extension MessageSender { .select(.groupIdentityPrivateKey) .asRequest(of: Data.self) .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } /// Determine which members actually need to be promoted (rather than just resent promotions) let memberIds: Set = Set(members.map { id, _ in id }) @@ -1137,6 +1152,14 @@ extension MessageSender { GroupMember.Columns.roleStatus.set(to: GroupMember.RoleStatus.sending), using: dependencies ) + + memberIds.forEach { id in + db.addGroupMemberEvent( + profileId: id, + threadId: groupSessionId.hexString, + type: .updated(.role(role: .admin, status: .sending)) + ) + } } } @@ -1163,7 +1186,7 @@ extension MessageSender { .map { id, _ in id } .contains(userSessionId.hexString), names: sortedMembersReceivingPromotions.map { id, profile in - profile?.displayName(for: .group) ?? + profile?.displayName() ?? id.truncated() } ) @@ -1184,7 +1207,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: groupSessionId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupSessionId.hexString), + destination: .group(publicKey: groupSessionId.hexString), message: GroupUpdateMemberChangeMessage( changeType: .promoted, memberSessionIds: sortedMembersReceivingPromotions.map { id, _ in id }, diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 6c9eb17945..96cfdc0508 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -22,220 +22,168 @@ public enum MessageReceiver { origin: Message.Origin, using dependencies: Dependencies ) throws -> ProcessedMessage { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let uniqueIdentifier: String - var plaintext: Data - var customProto: SNProtoContent? = nil - var customMessage: Message? = nil - let sender: String - let sentTimestampMs: UInt64? - let serverHash: String? - let openGroupServerMessageId: UInt64? - let openGroupWhisper: Bool - let openGroupWhisperMods: Bool - let openGroupWhisperTo: String? + /// Config messages are custom-handled internally within `libSession` so just return the data directly + guard !origin.isConfigNamespace else { + guard case .swarm(let publicKey, let namespace, let serverHash, let timestampMs, _) = origin else { + throw MessageError.invalidConfigMessageHandling + } + + return .config( + publicKey: publicKey, + namespace: namespace, + serverHash: serverHash, + serverTimestampMs: timestampMs, + data: data, + uniqueIdentifier: serverHash + ) + } + + /// The group "revoked retrievable" namespace uses custom encryption so we need to custom handle it + guard !origin.isRevokedRetrievableNamespace else { + guard case .swarm(let publicKey, _, let serverHash, _, let serverExpirationTimestamp) = origin else { + throw MessageError.invalidRevokedRetrievalMessageHandling + } + + let message: LibSessionMessage = LibSessionMessage(ciphertext: data) + message.sender = publicKey /// The "group" sends these messages + message.serverHash = serverHash + + return .standard( + threadId: publicKey, + threadVariant: .group, + messageInfo: MessageReceiveJob.Details.MessageInfo( + message: message, + variant: .libSessionMessage, + threadVariant: .group, + serverExpirationTimestamp: serverExpirationTimestamp, + decodedMessage: .empty /// LibSession system message doesn't need a `decodedMessage` + ), + uniqueIdentifier: serverHash + ) + } + + /// For all other cases we can just decode the message + let decodedMessage: DecodedMessage = try dependencies[singleton: .crypto].tryGenerate( + .decodedMessage( + encodedMessage: data, + origin: origin + ) + ) + + let threadId: String let threadVariant: SessionThread.Variant - let threadIdGenerator: (Message) throws -> String + let serverExpirationTimestamp: TimeInterval? + let uniqueIdentifier: String + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let proto: SNProtoContent = try decodedMessage.decodeProtoContent() + let message: Message = try Message.createMessageFrom(proto, decodedMessage: decodedMessage, using: dependencies) + message.sender = decodedMessage.sender.hexString + message.sentTimestampMs = decodedMessage.sentTimestampMs + message.sigTimestampMs = (proto.hasSigTimestamp ? proto.sigTimestamp : nil) + message.receivedTimestampMs = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - switch (origin.isConfigNamespace, origin) { - // Config messages are custom-handled via 'libSession' so just return the data directly - case (true, .swarm(let publicKey, let namespace, let serverHash, let serverTimestampMs, _)): - return .config( - publicKey: publicKey, - namespace: namespace, - serverHash: serverHash, - serverTimestampMs: serverTimestampMs, - data: data, - uniqueIdentifier: serverHash - ) + /// If the `decodedPro` content on the message is not `valid` or `expired` then we should remove any pro content from + /// the message itself as it's invalid + /// + /// **Note:** We sync the `expired` case because it's possible another device received and synced it while the data was + /// `valid` and we don't want to incorrectly remove pro state (or cause a config ping-pong due to inconsistent behaviours) + switch decodedMessage.decodedPro?.status { + case .valid, .expired: break + case .none, .invalidProBackendSig, .invalidUserSig: + message.proMessageFeatures = nil + message.proProfileFeatures = nil + message.proProof = nil + } + + /// Perform validation and assign additional message values based on the origin + switch origin { + case .community(let openGroupId, _, _, let messageServerId, let whisper, let whisperMods, let whisperTo): + /// Don't allow control messages in community conversations + guard message is VisibleMessage else { + throw MessageError.communitiesDoNotSupportControlMessages + } - case (_, .community(let openGroupId, let messageSender, let timestamp, let messageServerId, let messageWhisper, let messageWhisperMods, let messageWhisperTo)): - uniqueIdentifier = "\(messageServerId)" - plaintext = data.removePadding() // Remove the padding - sender = messageSender - sentTimestampMs = timestamp.map { UInt64(floor($0 * 1000)) } // Convert to ms for database consistency - serverHash = nil - openGroupServerMessageId = UInt64(messageServerId) - openGroupWhisper = messageWhisper - openGroupWhisperMods = messageWhisperMods - openGroupWhisperTo = messageWhisperTo + threadId = openGroupId threadVariant = .community - threadIdGenerator = { message in - // Guard against control messages in open groups - guard message is VisibleMessage else { throw MessageReceiverError.invalidMessage } - - return openGroupId - } + serverExpirationTimestamp = nil + uniqueIdentifier = "\(messageServerId)" + message.openGroupServerMessageId = UInt64(messageServerId) + message.openGroupWhisper = whisper + message.openGroupWhisperMods = whisperMods + message.openGroupWhisperTo = whisperTo - case (_, .openGroupInbox(let timestamp, let messageServerId, let serverPublicKey, let senderId, let recipientId)): - (plaintext, sender) = try dependencies[singleton: .crypto].tryGenerate( - .plaintextWithSessionBlindingProtocol( - ciphertext: data, - senderId: senderId, - recipientId: recipientId, - serverPublicKey: serverPublicKey - ) - ) + case .communityInbox(_, let messageServerId, _, _, _): + /// Don't process community inbox messages if the sender is blocked + guard + dependencies.mutate(cache: .libSession, { cache in + !cache.isContactBlocked(contactId: decodedMessage.sender.hexString) + }) || + message.processWithBlockedSender + else { throw MessageError.senderBlocked } - uniqueIdentifier = "\(messageServerId)" - plaintext = plaintext.removePadding() // Remove the padding - sentTimestampMs = UInt64(floor(timestamp * 1000)) // Convert to ms for database consistency - serverHash = nil - openGroupServerMessageId = UInt64(messageServerId) - openGroupWhisper = false - openGroupWhisperMods = false - openGroupWhisperTo = nil + /// Ignore self sends if needed + guard message.isSelfSendValid || decodedMessage.sender != userSessionId else { + throw MessageError.selfSend + } + + threadId = decodedMessage.sender.hexString threadVariant = .contact - threadIdGenerator = { _ in sender } + serverExpirationTimestamp = nil + uniqueIdentifier = "\(messageServerId)" + message.openGroupServerMessageId = UInt64(messageServerId) - case (_, .swarm(let publicKey, let namespace, let swarmServerHash, _, _)): - uniqueIdentifier = swarmServerHash - serverHash = swarmServerHash + case .swarm(let publicKey, let namespace, let serverHash, _, let expirationTimestamp): + /// Don't process 1-to-1 or group messages if the sender is blocked + guard + dependencies.mutate(cache: .libSession, { cache in + !cache.isContactBlocked(contactId: decodedMessage.sender.hexString) + }) || + message.processWithBlockedSender + else { throw MessageError.senderBlocked } + + /// Ignore self sends if needed + guard message.isSelfSendValid || decodedMessage.sender != userSessionId else { + throw MessageError.selfSend + } switch namespace { case .default: - let envelope: SNProtoEnvelope = try Result( - catching: { try MessageWrapper.unwrap(data: data, namespace: namespace) }) - .onFailure { error in Log.warn(.messageReceiver, "\(error)") } - .mapError { _ in MessageReceiverError.invalidMessage } - .successOrThrow() - let ciphertext: Data = try Result( - catching: { try envelope.content ?? { throw MessageReceiverError.noData }() }) - .onFailure { error in Log.warn(.messageReceiver, "Failed to unwrap message from '\(namespace)' namespace due to error: \(error).") } - .mapError { _ in MessageReceiverError.invalidMessage } - .successOrThrow() - - (plaintext, sender) = try dependencies[singleton: .crypto].tryGenerate( - .plaintextWithSessionProtocol(ciphertext: ciphertext) + threadId = Message.threadId( + forMessage: message, + destination: .contact(publicKey: decodedMessage.sender.hexString), + using: dependencies ) - plaintext = plaintext.removePadding() // Remove the padding - sentTimestampMs = envelope.timestamp - openGroupServerMessageId = nil - openGroupWhisper = false - openGroupWhisperMods = false - openGroupWhisperTo = nil threadVariant = .contact - threadIdGenerator = { message in - Message.threadId(forMessage: message, destination: .contact(publicKey: sender), using: dependencies) - } case .groupMessages: - let plaintextEnvelope: Data - (plaintextEnvelope, sender) = try dependencies[singleton: .crypto].tryGenerate( - .plaintextForGroupMessage( - groupSessionId: SessionId(.group, hex: publicKey), - ciphertext: Array(data) - ) - ) - - let envelope: SNProtoEnvelope = try Result(catching: { - try MessageWrapper.unwrap( - data: plaintextEnvelope, - namespace: namespace, - includesWebSocketMessage: false - ) - }) - .onFailure { error in Log.warn(.messageReceiver, "\(error)") } - .mapError { _ in MessageReceiverError.invalidMessage } - .successOrThrow() - let envelopeContent: Data = try Result( - catching: { try envelope.content ?? { throw MessageReceiverError.noData }() }) - .onFailure { error in Log.warn(.messageReceiver, "Failed to unwrap message from '\(namespace)' namespace due to error: \(error).") } - .mapError { _ in MessageReceiverError.invalidMessage } - .successOrThrow() - - plaintext = envelopeContent // Padding already removed for updated groups - sentTimestampMs = envelope.timestamp - openGroupServerMessageId = nil - openGroupWhisper = false - openGroupWhisperMods = false - openGroupWhisperTo = nil - threadVariant = .group - threadIdGenerator = { _ in publicKey } - - case .revokedRetrievableGroupMessages: - plaintext = Data() // Requires custom decryption - - let contentProto: SNProtoContent.SNProtoContentBuilder = SNProtoContent.builder() - contentProto.setSigTimestamp(0) - customProto = try contentProto.build() - customMessage = LibSessionMessage(ciphertext: data) - sender = publicKey // The "group" sends these messages - sentTimestampMs = 0 - openGroupServerMessageId = nil - openGroupWhisper = false - openGroupWhisperMods = false - openGroupWhisperTo = nil + threadId = publicKey threadVariant = .group - threadIdGenerator = { _ in publicKey } - case .configUserProfile, .configContacts, .configConvoInfoVolatile, .configUserGroups: - throw MessageReceiverError.invalidConfigMessageHandling - - case .configGroupInfo, .configGroupMembers, .configGroupKeys: - throw MessageReceiverError.invalidConfigMessageHandling - - case .legacyClosedGroup: throw MessageReceiverError.deprecatedMessage - case .configLocal, .all, .unknown: + default: Log.warn(.messageReceiver, "Couldn't process message due to invalid namespace.") - throw MessageReceiverError.unknownMessage(nil) + throw MessageError.unknownMessage(decodedMessage) } + + serverExpirationTimestamp = expirationTimestamp + uniqueIdentifier = serverHash + message.serverHash = serverHash + message.attachDisappearingMessagesConfiguration(from: proto) } - let proto: SNProtoContent = try (customProto ?? Result(catching: { try SNProtoContent.parseData(plaintext) }) - .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } - .successOrThrow()) - let message: Message = try (customMessage ?? Message.createMessageFrom(proto, sender: sender, using: dependencies)) - message.sender = sender - message.serverHash = serverHash - message.sentTimestampMs = sentTimestampMs - message.sigTimestampMs = (proto.hasSigTimestamp ? proto.sigTimestamp : nil) - message.receivedTimestampMs = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - message.openGroupServerMessageId = openGroupServerMessageId - message.openGroupWhisper = openGroupWhisper - message.openGroupWhisperMods = openGroupWhisperMods - message.openGroupWhisperTo = openGroupWhisperTo - - // Ignore disappearing message settings in communities (in case of modified clients) - if threadVariant != .community { - message.attachDisappearingMessagesConfiguration(from: proto) - } - - // Don't process the envelope any further if the sender is blocked - guard - dependencies.mutate(cache: .libSession, { cache in - !cache.isContactBlocked(contactId: sender) - }) || - message.processWithBlockedSender - else { throw MessageReceiverError.senderBlocked } - - // Ignore self sends if needed - guard message.isSelfSendValid || sender != userSessionId.hexString else { - throw MessageReceiverError.selfSend - } - - // Guard against control messages in open groups - guard !origin.isCommunity || message is VisibleMessage else { - throw MessageReceiverError.invalidMessage - } - - // Validate - guard message.isValid(isSending: false) else { - throw MessageReceiverError.invalidMessage - } + /// Ensure the message is valid + try message.validateMessage(isSending: false) return .standard( - threadId: try threadIdGenerator(message), + threadId: threadId, threadVariant: threadVariant, - proto: proto, - messageInfo: try MessageReceiveJob.Details.MessageInfo( + messageInfo: MessageReceiveJob.Details.MessageInfo( message: message, variant: try Message.Variant(from: message) ?? { - throw MessageReceiverError.invalidMessage + throw MessageError.invalidMessage("Unknown message type: \(type(of: message))") }(), threadVariant: threadVariant, - serverExpirationTimestamp: origin.serverExpirationTimestamp, - proto: proto + serverExpirationTimestamp: serverExpirationTimestamp, + decodedMessage: decodedMessage ), uniqueIdentifier: uniqueIdentifier ) @@ -248,9 +196,10 @@ public enum MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: Message, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, - associatedWithProto proto: SNProtoContent, suppressNotifications: Bool, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { /// Throw if the message is outdated and shouldn't be processed (this is based on pretty flaky logic which checks if the config @@ -267,12 +216,9 @@ public enum MessageReceiver { MessageReceiver.updateContactDisappearingMessagesVersionIfNeeded( db, - messageVariant: .init(from: message), + messageVariant: Message.Variant(from: message), contactId: message.sender, - version: ((!proto.hasExpirationType && !proto.hasExpirationTimer) ? - .legacyDisappearingMessages : - .newDisappearingMessages - ), + decodedMessage: decodedMessage, using: dependencies ) @@ -306,8 +252,10 @@ public enum MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, suppressNotifications: suppressNotifications, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) @@ -327,8 +275,8 @@ public enum MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, - proto: proto, using: dependencies ) @@ -348,6 +296,7 @@ public enum MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, using: dependencies ) @@ -356,6 +305,8 @@ public enum MessageReceiver { interactionInfo = try MessageReceiver.handleMessageRequestResponse( db, message: message, + decodedMessage: decodedMessage, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) @@ -365,9 +316,10 @@ public enum MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, - associatedWithProto: proto, suppressNotifications: suppressNotifications, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) @@ -381,7 +333,7 @@ public enum MessageReceiver { using: dependencies ) - default: throw MessageReceiverError.unknownMessage(proto) + default: throw MessageError.unknownMessage(decodedMessage) } // Perform any required post-handling logic @@ -460,11 +412,13 @@ public enum MessageReceiver { guard !isCurrentlyVisible else { return } - try SessionThread.updateVisibility( + try SessionThread.update( db, - threadId: threadId, - isVisible: true, - additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], + id: threadId, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true), + isDraft: .setTo(false) + ), using: dependencies ) } @@ -475,28 +429,54 @@ public enum MessageReceiver { openGroupMessageServerId: Int64, openGroupReactions: [Reaction] ) throws { - struct Info: Decodable, FetchableRecord { + struct InteractionInfo: Decodable, FetchableRecord { let id: Int64 let variant: Interaction.Variant } + struct ReactionInfo: Decodable, FetchableRecord { + let rowID: Int64 + let emoji: String + } - guard let interactionInfo: Info = try? Interaction + guard let interactionInfo: InteractionInfo = try? Interaction .select(.id, .variant) .filter(Interaction.Columns.threadId == threadId) .filter(Interaction.Columns.openGroupServerMessageId == openGroupMessageServerId) - .asRequest(of: Info.self) + .asRequest(of: InteractionInfo.self) .fetchOne(db) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Could not find message reaction is associated to") } // If the user locally deleted the message then we don't want to process reactions for it guard !interactionInfo.variant.isDeletedMessage else { return } + let removedReactions: [ReactionInfo] = try Reaction + .select(Column.rowID, Reaction.Columns.emoji) + .filter(Reaction.Columns.interactionId == interactionInfo.id) + .asRequest(of: ReactionInfo.self) + .fetchAll(db) + _ = try Reaction .filter(Reaction.Columns.interactionId == interactionInfo.id) .deleteAll(db) + // Send events + removedReactions.forEach { reaction in + db.addReactionEvent( + id: reaction.rowID, + messageId: interactionInfo.id, + change: .removed(reaction.emoji) + ) + } + for reaction in openGroupReactions { try reaction.with(interactionId: interactionInfo.id).insert(db) + + // Send event + db.addReactionEvent( + id: db.lastInsertedRowID, + messageId: interactionInfo.id, + change: .added(reaction.emoji) + ) } } @@ -542,7 +522,7 @@ public enum MessageReceiver { cache.hasCredentials(groupSessionId: groupSessionId), !cache.groupIsDestroyed(groupSessionId: groupSessionId), !cache.wasKickedFromGroup(groupSessionId: groupSessionId) - else { throw MessageReceiverError.outdatedMessage } + else { throw MessageError.outdatedMessage } return } @@ -561,7 +541,7 @@ public enum MessageReceiver { (message as? VisibleMessage)?.dataMessageHasAttachments == false || messageSentTimestamp > deleteAttachmentsBefore ) - else { throw MessageReceiverError.outdatedMessage } + else { throw MessageError.outdatedMessage } return } @@ -585,7 +565,7 @@ public enum MessageReceiver { ) switch (conversationInConfig, canPerformConfigChange) { - case (false, false): throw MessageReceiverError.outdatedMessage + case (false, false): throw MessageError.outdatedMessage default: break } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 5dc79f6026..29b5124808 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -18,16 +18,14 @@ extension MessageSender { using dependencies: Dependencies ) throws { // Only 'VisibleMessage' types can be sent via this method - guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } + guard interaction.variant == .standardOutgoing else { + throw MessageError.invalidMessage("Message was not an outgoing message") + } guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } send( db, - message: VisibleMessage.from( - db, - interaction: interaction, - proProof: dependencies.mutate(cache: .libSession, { $0.getCurrentUserProProof() }) - ), + message: VisibleMessage.from(db, interaction: interaction), threadId: threadId, interactionId: interactionId, to: try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), @@ -208,7 +206,7 @@ extension MessageSender { case (false, .syncMessage): try interaction.with(state: .sent).update(db) - case (true, .syncMessage), (_, .contact), (_, .closedGroup), (_, .openGroup), (_, .openGroupInbox): + case (true, .syncMessage), (_, .contact), (_, .group), (_, .community), (_, .communityInbox): // The timestamp to use for scheduling message deletion. This is generated // when the message is successfully sent to ensure the deletion timer starts // from the correct time. @@ -319,13 +317,13 @@ extension MessageSender { threadId: String, message: Message, destination: Message.Destination?, - error: MessageSenderError, + error: MessageError, interactionId: Int64?, using dependencies: Dependencies ) -> Error { - // Log a message for any 'other' errors + // Log a message for any 'sendFailure' errors switch error { - case .other(let cat, let description, let error): + case .sendFailure(let cat, let description, let error): Log.error([.messageSender, cat].compactMap { $0 }, "\(description) due to error: \(error).") default: break } @@ -425,17 +423,20 @@ extension MessageSender { // MARK: - Database Type Conversion public extension VisibleMessage { - static func from(_ db: ObservingDatabase, interaction: Interaction, proProof: String? = nil) -> VisibleMessage { - let linkPreview: LinkPreview? = try? interaction.linkPreview.fetchOne(db) - let shouldAttachProProof: Bool = ((interaction.body ?? "").utf16.count > LibSession.CharacterLimit) + static func from(_ db: ObservingDatabase, interaction: Interaction) -> VisibleMessage { + let linkPreview: LinkPreview? = try? Interaction + .linkPreview(url: interaction.linkPreviewUrl, timestampMs: interaction.timestampMs)? + .fetchOne(db) + let attachments: [Attachment]? = try? Interaction + .attachments(interactionId: interaction.id)? + .fetchAll(db) let visibleMessage: VisibleMessage = VisibleMessage( sender: interaction.authorId, sentTimestampMs: UInt64(interaction.timestampMs), syncTarget: nil, text: interaction.body, - attachmentIds: ((try? interaction.attachments.fetchAll(db)) ?? []) - .map { $0.id }, + attachmentIds: (attachments ?? []).map { $0.id }, quote: (try? Quote .filter(Quote.Columns.interactionId == interaction.id) .fetchOne(db)) @@ -458,7 +459,6 @@ public extension VisibleMessage { expiresInSeconds: interaction.expiresInSeconds, expiresStartedAtMs: interaction.expiresStartedAtMs ) - .with(proProof: (shouldAttachProProof ? proProof : nil)) return visibleMessage } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 6c114daedd..c750e84c54 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -19,7 +19,7 @@ public final class MessageSender { public enum Event { case willSend(Message, Message.Destination, interactionId: Int64?) case success(Message, Message.Destination, interactionId: Int64?, serverTimestampMs: Int64?, serverExpirationMs: Int64?) - case failure(Message, Message.Destination, interactionId: Int64?, error: MessageSenderError) + case failure(Message, Message.Destination, interactionId: Int64?, error: MessageError) var message: Message { switch self { @@ -65,7 +65,7 @@ public final class MessageSender { let preparedRequest: Network.PreparedRequest switch destination { - case .contact, .syncMessage, .closedGroup: + case .contact, .syncMessage, .group: preparedRequest = try preparedSendToSnodeDestination( message: updatedMessage, to: destination, @@ -78,8 +78,8 @@ public final class MessageSender { using: dependencies ) - case .openGroup: - preparedRequest = try preparedSendToOpenGroupDestination( + case .community: + preparedRequest = try preparedSendToCommunityDestination( message: updatedMessage, to: destination, interactionId: interactionId, @@ -90,8 +90,8 @@ public final class MessageSender { using: dependencies ) - case .openGroupInbox: - preparedRequest = try preparedSendToOpenGroupInboxDestination( + case .communityInbox: + preparedRequest = try preparedSendToCommunityInboxDestination( message: message, to: destination, interactionId: interactionId, @@ -122,14 +122,14 @@ public final class MessageSender { message, destination, interactionId: interactionId, - error: .other(nil, "Couldn't send message", error) + error: .sendFailure(nil, "Couldn't send message", error) )) } } ) .map { _, response in response.message } } - catch let error as MessageSenderError { + catch let error as MessageError { onEvent?(.failure(message, destination, interactionId: interactionId, error: error)) throw error } @@ -147,7 +147,7 @@ public final class MessageSender { using dependencies: Dependencies ) throws -> Network.PreparedRequest { guard let namespace: Network.SnodeAPI.Namespace = namespace else { - throw MessageSenderError.invalidMessage + throw MessageError.missingRequiredField("namespace") } /// Set the sender/recipient info (needed to be valid) @@ -168,14 +168,7 @@ public final class MessageSender { case (_, .some(var messageWithProfile)): messageWithProfile.profile = dependencies .mutate(cache: .libSession) { $0.profile(contactId: userSessionId.hexString) } - .map { profile in - VisibleMessage.VMProfile( - displayName: profile.name, - profileKey: profile.displayPictureEncryptionKey, - profilePictureUrl: profile.displayPictureUrl, - updateTimestampSeconds: profile.profileLastUpdated - ) - } + .map { profile in VisibleMessage.VMProfile(profile: profile) } } // Convert and prepare the data for sending @@ -183,8 +176,8 @@ public final class MessageSender { switch destination { case .contact(let publicKey): return publicKey case .syncMessage: return userSessionId.hexString - case .closedGroup(let groupPublicKey): return groupPublicKey - case .openGroup, .openGroupInbox: preconditionFailure() + case .group(let publicKey): return publicKey + case .community, .communityInbox: preconditionFailure() } }() let snodeMessage = SnodeMessage( @@ -194,7 +187,6 @@ public final class MessageSender { destination: destination, message: message, attachments: attachments, - authMethod: authMethod, using: dependencies ), ttl: Message.getSpecifiedTTL(message: message, destination: destination, using: dependencies), @@ -220,7 +212,7 @@ public final class MessageSender { } } - private static func preparedSendToOpenGroupDestination( + private static func preparedSendToCommunityDestination( message: Message, to destination: Message.Destination, interactionId: Int64?, @@ -236,12 +228,12 @@ public final class MessageSender { guard let message: VisibleMessage = message as? VisibleMessage, case .community(let server, let publicKey, let hasCapabilities, let supportsBlinding, _) = authMethod.info, - case .openGroup(let roomToken, let destinationServer, let whisperTo, let whisperMods) = destination, + case .community(let roomToken, let destinationServer, let whisperTo, let whisperMods) = destination, server == destinationServer, let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) ) - else { throw MessageSenderError.invalidMessage } + else { throw MessageError.invalidMessage("Configuration doesn't meet requirements to send to a community") } // Set the sender/recipient info (needed to be valid) let userSessionId: SessionId = dependencies[cache: .general].sessionId @@ -257,7 +249,7 @@ public final class MessageSender { ed25519SecretKey: userEdKeyPair.secretKey ) ) - else { throw MessageSenderError.signingFailed } + else { throw MessageError.requiredSignatureMissing } return SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString }() @@ -269,22 +261,18 @@ public final class MessageSender { } .map { profile, checkForCommunityMessageRequests in VisibleMessage.VMProfile( - displayName: profile.name, - profileKey: profile.displayPictureEncryptionKey, - profilePictureUrl: profile.displayPictureUrl, - updateTimestampSeconds: profile.profileLastUpdated, + profile: profile, blocksCommunityMessageRequests: !checkForCommunityMessageRequests ) } - guard !(message.profile?.displayName ?? "").isEmpty else { throw MessageSenderError.noUsername } + guard !(message.profile?.displayName ?? "").isEmpty else { throw MessageError.invalidSender } let plaintext: Data = try MessageSender.encodeMessageForSending( namespace: .default, destination: destination, message: message, attachments: attachments, - authMethod: authMethod, using: dependencies ) @@ -310,7 +298,7 @@ public final class MessageSender { } } - private static func preparedSendToOpenGroupInboxDestination( + private static func preparedSendToCommunityInboxDestination( message: Message, to destination: Message.Destination, interactionId: Int64?, @@ -320,11 +308,11 @@ public final class MessageSender { onEvent: ((Event) -> Void)?, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - // The `openGroupInbox` destination does not support attachments + /// The `communityInbox` destination does not support attachments guard (attachments ?? []).isEmpty, - case .openGroupInbox(_, _, let recipientBlindedPublicKey) = destination - else { throw MessageSenderError.invalidMessage } + case .communityInbox(_, _, let recipientBlindedPublicKey) = destination + else { throw MessageError.invalidMessage("Configuration doesn't meet requirements to send to community inbox") } let userSessionId: SessionId = dependencies[cache: .general].sessionId message.sender = userSessionId.hexString @@ -334,14 +322,7 @@ public final class MessageSender { case .some(var messageWithProfile): messageWithProfile.profile = dependencies .mutate(cache: .libSession) { $0.profile(contactId: userSessionId.hexString) } - .map { profile in - VisibleMessage.VMProfile( - displayName: profile.name, - profileKey: profile.displayPictureEncryptionKey, - profilePictureUrl: profile.displayPictureUrl, - updateTimestampSeconds: profile.profileLastUpdated - ) - } + .map { profile in VisibleMessage.VMProfile(profile: profile) } default: break } @@ -350,7 +331,6 @@ public final class MessageSender { destination: destination, message: message, attachments: nil, - authMethod: authMethod, using: dependencies ) @@ -380,128 +360,38 @@ public final class MessageSender { destination: Message.Destination, message: Message, attachments: [(attachment: Attachment, fileId: String)]?, - authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Data { /// Check the message itself is valid - guard - message.isValid(isSending: true), - let sentTimestampMs: UInt64 = message.sentTimestampMs - else { throw MessageSenderError.invalidMessage } - - let plaintext: Data = try { - switch (namespace, destination) { - case (.revokedRetrievableGroupMessages, _): - return try BencodeEncoder(using: dependencies).encode(message) - - case (_, .openGroup), (_, .openGroupInbox): - guard - let proto: SNProtoContent = try message.toProto()? - .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment }) - else { throw MessageSenderError.protoConversionFailed } - - return try Result { try proto.serializedData().paddedMessageBody() } - .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } - .successOrThrow() - - default: - guard - let proto: SNProtoContent = try message.toProto()? - .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment }) - else { throw MessageSenderError.protoConversionFailed } - - return try Result { try proto.serializedData() } - .map { serialisedData -> Data in - switch destination { - case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: - return serialisedData - - default: return serialisedData.paddedMessageBody() - } - } - .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } - .successOrThrow() - } - }() + try message.validateMessage(isSending: true) - switch (destination, namespace) { - /// Updated group messages should be wrapped _before_ encrypting - case (.closedGroup(let groupId), .groupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: - let messageData: Data = try Result { - try MessageWrapper.wrap( - type: .closedGroupMessage, - timestampMs: sentTimestampMs, - content: plaintext, - wrapInWebSocketMessage: false - ) - } - .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } - .successOrThrow() - - let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( - .ciphertextForGroupMessage( - groupSessionId: SessionId(.group, hex: groupId), - message: Array(messageData) - ) - ) - return ciphertext - - /// `revokedRetrievableGroupMessages` should be sent in plaintext (their content has custom encryption) - case (.closedGroup(let groupId), .revokedRetrievableGroupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: - return plaintext - - // Standard one-to-one messages and legacy groups (which used a `05` prefix) - case (.contact, .default), (.syncMessage, _), (.closedGroup, _): - let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( - .ciphertextWithSessionProtocol( - plaintext: plaintext, - destination: destination - ) - ) - - return try Result { - try MessageWrapper.wrap( - type: try { - switch destination { - case .contact, .syncMessage: return .sessionMessage - case .closedGroup: return .closedGroupMessage - default: throw MessageSenderError.invalidMessage - } - }(), - timestampMs: sentTimestampMs, - senderPublicKey: { - switch destination { - case .closedGroup: return try authMethod.swarmPublicKey // Needed for Android - default: return "" // Empty for all other cases - } - }(), - content: ciphertext - ) - } - .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } - .successOrThrow() - - /// Community messages should be sent in plaintext - case (.openGroup, _): return plaintext - - /// Blinded community messages have their own special encryption - case (.openGroupInbox(_, let serverPublicKey, let recipientBlindedPublicKey), _): - return try dependencies[singleton: .crypto].generateResult( - .ciphertextWithSessionBlindingProtocol( - plaintext: plaintext, - recipientBlindedId: recipientBlindedPublicKey, - serverPublicKey: serverPublicKey - ) - ) - .mapError { MessageSenderError.other(nil, "Couldn't encrypt message for destination: \(destination)", $0) } - .successOrThrow() - - /// Config messages should be sent directly rather than via this method - case (.closedGroup(let groupId), _) where (try? SessionId.Prefix(from: groupId)) == .group: - throw MessageSenderError.invalidConfigMessageHandling - - /// Config messages should be sent directly rather than via this method - case (.contact, _): throw MessageSenderError.invalidConfigMessageHandling + guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { + throw MessageError.missingRequiredField("sentTimestampMs") + } + + /// Messages sent to `revokedRetrievableGroupMessages` should be sent directly instead of via the `MessageSender` + guard namespace != .revokedRetrievableGroupMessages else { + throw MessageError.invalidMessage("Attempted to send to namespace \(namespace) via the wrong pipeline") } + + /// Add Session Pro data if needed + let finalMessage: Message = dependencies[singleton: .sessionProManager].attachProInfoIfNeeded(message: message) + + /// Add attachments if needed and convert to serialised proto data + guard + let plaintext: Data = try? finalMessage.toProto()? + .addingAttachmentsIfNeeded(finalMessage, attachments?.map { $0.attachment })? + .serializedData() + else { throw MessageError.protoConversionFailed } + + return try dependencies[singleton: .crypto].tryGenerate( + .encodedMessage( + plaintext: Array(plaintext), + proMessageFeatures: (finalMessage.proMessageFeatures ?? .none), + proProfileFeatures: (finalMessage.proProfileFeatures ?? .none), + destination: destination, + sentTimestampMs: sentTimestampMs + ) + ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift index 8e65f4550d..2d091dd97b 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -69,23 +69,23 @@ public extension NotificationsManagerType { shouldShowForMessageRequest: () -> Bool, using dependencies: Dependencies ) throws { - guard let sender: String = message.sender else { throw MessageReceiverError.invalidSender } + guard let sender: String = message.sender else { throw MessageError.invalidSender } /// Don't show notifications for the `Note to Self` thread or messages sent from the current user guard !currentUserSessionIds.contains(threadId) && !currentUserSessionIds.contains(sender) else { - throw MessageReceiverError.selfSend + throw MessageError.selfSend } /// Ensure that the thread isn't muted guard dependencies.dateNow.timeIntervalSince1970 > (notificationSettings.mutedUntil ?? 0) else { - throw MessageReceiverError.ignorableMessage + throw MessageError.ignorableMessage } switch message { /// For a `VisibleMessage` we should only notify if the notification mode is `all` or if `mentionsOnly` and the /// user was actually mentioned case let visibleMessage as VisibleMessage: - guard interactionVariant == .standardIncoming else { throw MessageReceiverError.ignorableMessage } + guard interactionVariant == .standardIncoming else { throw MessageError.ignorableMessage } guard !notificationSettings.mentionsOnly || Interaction.isUserMentioned( @@ -93,7 +93,7 @@ public extension NotificationsManagerType { body: visibleMessage.text, quoteAuthorId: visibleMessage.quote?.authorId ) - else { throw MessageReceiverError.ignorableMessage } + else { throw MessageError.ignorableMessage } /// If the message is a reaction then we only want to show notifications for `contact` conversations, any only if the /// reaction isn't added to a message sent by the reactor @@ -101,30 +101,32 @@ public extension NotificationsManagerType { switch threadVariant { case .contact: guard visibleMessage.reaction?.publicKey != sender else { - throw MessageReceiverError.ignorableMessage + throw MessageError.ignorableMessage } break - case .legacyGroup, .group, .community: throw MessageReceiverError.ignorableMessage + case .legacyGroup, .group, .community: throw MessageError.ignorableMessage } } break /// Calls are only supported in `contact` conversations and we only want to notify for missed calls case let callMessage as CallMessage: - guard threadVariant == .contact else { throw MessageReceiverError.invalidMessage } - guard case .preOffer = callMessage.kind else { throw MessageReceiverError.ignorableMessage } + guard threadVariant == .contact else { + throw MessageError.invalidMessage("Calls are only supported in 1-to-1 conversations") + } + guard case .preOffer = callMessage.kind else { throw MessageError.ignorableMessage } switch callMessage.state { case .missed, .permissionDenied, .permissionDeniedMicrophone: break - default: throw MessageReceiverError.ignorableMessage + default: throw MessageError.ignorableMessage } /// Group invitations and promotions may show notifications in some cases case is GroupUpdateInviteMessage, is GroupUpdatePromoteMessage: break /// No other messages should have notifications - default: throw MessageReceiverError.ignorableMessage + default: throw MessageError.ignorableMessage } /// Ensure the sender isn't blocked (this should be checked when parsing the message but we should also check here in case @@ -133,7 +135,7 @@ public extension NotificationsManagerType { dependencies.mutate(cache: .libSession, { cache in !cache.isContactBlocked(contactId: sender) }) - else { throw MessageReceiverError.senderBlocked } + else { throw MessageError.senderBlocked } /// Ensure the message hasn't already been maked as read (don't want to show notification in that case) guard @@ -141,18 +143,18 @@ public extension NotificationsManagerType { !cache.timestampAlreadyRead( threadId: threadId, threadVariant: threadVariant, - timestampMs: (message.sentTimestampMs.map { Int64($0) } ?? 0), /// Default to unread + timestampMs: (message.sentTimestampMs.map { UInt64($0) } ?? 0), /// Default to unread openGroupUrlInfo: openGroupUrlInfo ) }) - else { throw MessageReceiverError.ignorableMessage } + else { throw MessageError.ignorableMessage } /// If the thread is a message request then we only want to show a notification for the first message switch (threadVariant, isMessageRequest) { case (.community, _), (.legacyGroup, _), (.contact, false), (.group, false): break case (.contact, true), (.group, true): guard shouldShowForMessageRequest() else { - throw MessageReceiverError.ignorableMessageRequestMessage + throw MessageError.ignorableMessageRequestMessage } break } @@ -167,7 +169,7 @@ public extension NotificationsManagerType { threadVariant: SessionThread.Variant, isMessageRequest: Bool, notificationSettings: Preferences.NotificationSettings, - displayNameRetriever: (String, Bool) -> String?, + displayNameRetriever: DisplayNameRetriever, groupNameRetriever: (String, SessionThread.Variant) -> String?, using dependencies: Dependencies ) throws -> String { @@ -186,12 +188,12 @@ public extension NotificationsManagerType { case (.nameNoPreview, .some(let sender), _, .contact), (.nameAndPreview, .some(let sender), _, .contact): return displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + .defaulting(to: sender.truncated()) case (.nameNoPreview, .some(let sender), _, .group), (.nameAndPreview, .some(let sender), _, .group), (.nameNoPreview, .some(let sender), _, .community), (.nameAndPreview, .some(let sender), _, .community): let senderName: String = displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + .defaulting(to: sender.truncated()) let groupName: String = groupNameRetriever(threadId, threadVariant) .defaulting(to: "groupUnknown".localized()) @@ -200,7 +202,7 @@ public extension NotificationsManagerType { .put(key: "conversation_name", value: groupName) .localized() - case (_, _, _, .legacyGroup): throw MessageReceiverError.ignorableMessage + case (_, _, _, .legacyGroup): throw MessageError.ignorableMessage } } @@ -213,7 +215,7 @@ public extension NotificationsManagerType { interactionVariant: Interaction.Variant?, attachmentDescriptionInfo: [Attachment.DescriptionInfo]?, currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String?, + displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) -> String { /// If it's a message request then use something generic @@ -243,11 +245,10 @@ public extension NotificationsManagerType { variant: variant, body: visibleMessage.text, authorDisplayName: displayNameRetriever(sender, true) - .defaulting(to: sender.truncated(threadVariant: threadVariant)), + .defaulting(to: sender.truncated()), attachmentDescriptionInfo: attachmentDescriptionInfo?.first, attachmentCount: (attachmentDescriptionInfo?.count ?? 0), - isOpenGroupInvitation: (visibleMessage.openGroupInvitation != nil), - using: dependencies + isOpenGroupInvitation: (visibleMessage.openGroupInvitation != nil) ) }? .filteredForDisplay @@ -267,24 +268,21 @@ public extension NotificationsManagerType { } case let callMessage as CallMessage where callMessage.state == .permissionDenied: - let senderName: String = displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + let senderName: String = (displayNameRetriever(sender, false) ?? sender.truncated()) return "callsYouMissedCallPermissions" .put(key: "name", value: senderName) .localizedDeformatted() case is CallMessage: - let senderName: String = displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + let senderName: String = (displayNameRetriever(sender, false) ?? sender.truncated()) return "callsMissedCallFrom" .put(key: "name", value: senderName) .localizedDeformatted() case let inviteMessage as GroupUpdateInviteMessage: - let senderName: String = displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + let senderName: String = (displayNameRetriever(sender, false) ?? sender.truncated()) let bodyText: String? = ClosedGroup.MessageInfo .invited(senderName, inviteMessage.groupName) .previewText @@ -300,8 +298,7 @@ public extension NotificationsManagerType { } case let promotionMessage as GroupUpdatePromoteMessage: - let senderName: String = displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + let senderName: String = (displayNameRetriever(sender, false) ?? sender.truncated()) let bodyText: String? = ClosedGroup.MessageInfo .invitedAdmin(senderName, promotionMessage.groupName) .previewText @@ -337,7 +334,7 @@ public extension NotificationsManagerType { applicationState: UIApplication.State, extensionBaseUnreadCount: Int?, currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String?, + displayNameRetriever: DisplayNameRetriever, groupNameRetriever: (String, SessionThread.Variant) -> String?, shouldShowForMessageRequest: () -> Bool ) throws { diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift index b868c35b3b..4b45a8423a 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift @@ -25,10 +25,20 @@ public extension Network.PushNotification { } return dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in + .readPublisher { db -> Set in + try ClosedGroup + .select(.threadId) + .filter( + ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && + ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString + ) + .filter(ClosedGroup.Columns.shouldPoll) + .asRequest(of: String.self) + .fetchSet(db) + } + .tryMap { groupIds in let userSessionId: SessionId = dependencies[cache: .general].sessionId let userAuthMethod: AuthenticationMethod = try Authentication.with( - db, swarmPublicKey: userSessionId.hexString, using: dependencies ) @@ -37,32 +47,22 @@ public extension Network.PushNotification { .preparedSubscribe( token: token, swarms: [(userSessionId, userAuthMethod)] - .appending(contentsOf: try ClosedGroup - .select(.threadId) - .filter( - ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && - ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString - ) - .filter(ClosedGroup.Columns.shouldPoll) - .asRequest(of: String.self) - .fetchSet(db) - .compactMap { threadId in - do { - return ( - SessionId(.group, hex: threadId), - try Authentication.with( - db, - swarmPublicKey: threadId, - using: dependencies - ) + .appending(contentsOf: groupIds.compactMap { threadId in + do { + return ( + SessionId(.group, hex: threadId), + try Authentication.with( + swarmPublicKey: threadId, + using: dependencies ) - } - catch { - Log.warn(.pushNotificationAPI, "Unable to subscribe for push notifications to \(threadId) due to error: \(error).") - return nil - } + ) } - ), + catch { + Log.warn(.pushNotificationAPI, "Skipping attempt to subscribe for push notifications for \(threadId) due to error: \(error).") + return nil + } + } + ), using: dependencies ) .handleEvents( @@ -85,10 +85,19 @@ public extension Network.PushNotification { using dependencies: Dependencies ) -> AnyPublisher { return dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in + .readPublisher { db -> Set in + ((try? ClosedGroup + .select(.threadId) + .filter( + ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && + ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString + ) + .asRequest(of: String.self) + .fetchSet(db)) ?? []) + } + .tryMap { groupIds in let userSessionId: SessionId = dependencies[cache: .general].sessionId let userAuthMethod: AuthenticationMethod = try Authentication.with( - db, swarmPublicKey: userSessionId.hexString, using: dependencies ) @@ -97,31 +106,21 @@ public extension Network.PushNotification { .preparedUnsubscribe( token: token, swarms: [(userSessionId, userAuthMethod)] - .appending(contentsOf: (try? ClosedGroup - .select(.threadId) - .filter( - ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && - ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString - ) - .asRequest(of: String.self) - .fetchSet(db)) - .defaulting(to: []) - .compactMap { threadId in - do { - return ( - SessionId(.group, hex: threadId), - try Authentication.with( - db, - swarmPublicKey: threadId, - using: dependencies - ) + .appending(contentsOf: groupIds.compactMap { threadId in + do { + return ( + SessionId(.group, hex: threadId), + try Authentication.with( + swarmPublicKey: threadId, + using: dependencies ) - } - catch { - Log.info(.pushNotificationAPI, "Unable to unsubscribe for push notifications to \(threadId) due to error: \(error).") - return nil - } - }), + ) + } + catch { + Log.info(.pushNotificationAPI, "Skippint attempt to unsubscribe for push notifications from \(threadId) due to error: \(error).") + return nil + } + }), using: dependencies ) .handleEvents( diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift index 2f1f0be09b..85a0da358d 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift @@ -163,7 +163,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { let roomIds: Set = try OpenGroup .filter( OpenGroup.Columns.server == pollerDestination.target && - OpenGroup.Columns.isActive == true + OpenGroup.Columns.shouldPoll == true ) .select(.roomToken) .asRequest(of: String.self) @@ -181,7 +181,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { .fetchSet(db) try hiddenRoomIds.forEach { id in - try dependencies[singleton: .openGroupManager].delete( + try dependencies[singleton: .communityManager].delete( db, openGroupId: id, /// **Note:** We pass `skipLibSessionUpdate` as `true` @@ -224,17 +224,27 @@ public final class CommunityPoller: CommunityPollerType & PollerType { .subscribe(on: pollerQueue, using: dependencies) .receive(on: pollerQueue, using: dependencies) .tryMap { [dependencies] authMethod in - try Network.SOGS.preparedCapabilities( - authMethod: authMethod, - using: dependencies + ( + authMethod, + try Network.SOGS.preparedCapabilities( + authMethod: authMethod, + using: dependencies + ) ) } - .flatMap { [dependencies] in $0.send(using: dependencies) } - .flatMapStorageWritePublisher(using: dependencies) { [pollerDestination] (db: ObservingDatabase, response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesResponse)) in - OpenGroupManager.handleCapabilities( + .flatMap { [dependencies] authMethod, request in + request.send(using: dependencies).map { ($0.0, $0.1, authMethod) } + } + .flatMapStorageWritePublisher(using: dependencies) { [pollerDestination, dependencies] (db: ObservingDatabase, response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesResponse, authMethod: AuthenticationMethod)) in + guard case .community(_, let publicKey, _, _, _) = response.authMethod.info else { + throw CryptoError.invalidAuthentication + } + + dependencies[singleton: .communityManager].handleCapabilities( db, capabilities: response.data, - on: pollerDestination.target + server: pollerDestination.target, + publicKey: publicKey ) } .tryCatch { try handleError($0) } @@ -274,9 +284,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { ) let lastSuccessfulPollTimestamp: TimeInterval = (self.lastPollStart > 0 ? lastPollStart : - dependencies.mutate(cache: .openGroupManager) { cache in - cache.getLastSuccessfulCommunityPollTimestamp() - } + dependencies[singleton: .communityManager].getLastSuccessfulCommunityPollTimestampSync() ) return dependencies[singleton: .storage] @@ -286,8 +294,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { let roomInfo: [Network.SOGS.PollRoomInfo] = try OpenGroup .select(.roomToken, .infoUpdates, .sequenceNumber) .filter(OpenGroup.Columns.server == server) - .filter(OpenGroup.Columns.isActive == true) - .filter(OpenGroup.Columns.roomToken != "") + .filter(OpenGroup.Columns.shouldPoll == true) .asRequest(of: Network.SOGS.PollRoomInfo.self) .fetchAll(db) @@ -310,7 +317,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { try Authentication.with(db, server: server, using: dependencies) ) } - .tryFlatMap { [pollCount, dependencies] pollInfo -> AnyPublisher<(ResponseInfoType, Network.BatchResponseMap), Error> in + .tryFlatMap { [pollCount, dependencies] pollInfo -> AnyPublisher<(ResponseInfoType, Network.BatchResponseMap, AuthenticationMethod), Error> in try Network.SOGS .preparedPoll( roomInfo: pollInfo.roomInfo, @@ -325,11 +332,18 @@ public final class CommunityPoller: CommunityPollerType & PollerType { using: dependencies ) .send(using: dependencies) + .map { ($0.0, $0.1, pollInfo.authMethod) } + .eraseToAnyPublisher() } - .flatMapOptional { [weak self, failureCount, dependencies] info, response in - self?.handlePollResponse( + .tryFlatMapOptional { [weak self, failureCount, dependencies] info, response, authMethod in + guard case .community(_, let publicKey, _, _, _) = authMethod.info else { + throw CryptoError.invalidAuthentication + } + + return self?.handlePollResponse( info: info, response: response, + publicKey: publicKey, failureCount: failureCount, using: dependencies ) @@ -341,10 +355,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { Task { [weak self] in await self?.pollCountStream.send(updatedPollCount) - } - - dependencies.mutate(cache: .openGroupManager) { cache in - cache.setLastSuccessfulCommunityPollTimestamp( + await dependencies[singleton: .communityManager].setLastSuccessfulCommunityPollTimestamp( dependencies.dateNow.timeIntervalSince1970 ) } @@ -356,6 +367,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { private func handlePollResponse( info: ResponseInfoType, response: Network.BatchResponseMap, + publicKey: String, failureCount: Int, using dependencies: Dependencies ) -> AnyPublisher, Error> { @@ -544,10 +556,11 @@ public final class CommunityPoller: CommunityPollerType & PollerType { let responseBody: Network.SOGS.CapabilitiesResponse = responseData.body else { return } - OpenGroupManager.handleCapabilities( + dependencies[singleton: .communityManager].handleCapabilities( db, capabilities: responseBody, - on: pollerDestination.target + server: pollerDestination.target, + publicKey: publicKey ) case .roomPollInfo(let roomToken, _): @@ -556,13 +569,12 @@ public final class CommunityPoller: CommunityPollerType & PollerType { let responseBody: Network.SOGS.RoomPollInfo = responseData.body else { return } - try OpenGroupManager.handlePollInfo( + try dependencies[singleton: .communityManager].handlePollInfo( db, pollInfo: responseBody, - publicKey: nil, - for: roomToken, - on: pollerDestination.target, - using: dependencies + server: pollerDestination.target, + roomToken: roomToken, + publicKey: publicKey ) case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): @@ -571,13 +583,16 @@ public final class CommunityPoller: CommunityPollerType & PollerType { let responseBody: [Failable] = responseData.body else { return } + /// Might have been updated when handling one of the other responses so re-fetch the value + let currentUserSessionIds: Set = dependencies[singleton: .communityManager] + .currentUserSessionIdsSync(pollerDestination.target.lowercased()) interactionInfo.append( - contentsOf: OpenGroupManager.handleMessages( + contentsOf: dependencies[singleton: .communityManager].handleMessages( db, messages: responseBody.compactMap { $0.value }, - for: roomToken, - on: pollerDestination.target, - using: dependencies + server: pollerDestination.target, + roomToken: roomToken, + currentUserSessionIds: currentUserSessionIds ) ) @@ -596,13 +611,16 @@ public final class CommunityPoller: CommunityPollerType & PollerType { } }() + /// Might have been updated when handling one of the other responses so re-fetch the value + let currentUserSessionIds: Set = dependencies[singleton: .communityManager] + .currentUserSessionIdsSync(pollerDestination.target.lowercased()) interactionInfo.append( - contentsOf: OpenGroupManager.handleDirectMessages( + contentsOf: dependencies[singleton: .communityManager].handleDirectMessages( db, messages: messages, fromOutbox: fromOutbox, - on: pollerDestination.target, - using: dependencies + server: pollerDestination.target, + currentUserSessionIds: currentUserSessionIds ) ) @@ -712,8 +730,7 @@ public extension CommunityPoller { OpenGroup.Columns.server, max(OpenGroup.Columns.pollFailureCount).forKey(Info.Columns.pollFailureCount) ) - .filter(OpenGroup.Columns.isActive == true) - .filter(OpenGroup.Columns.roomToken != "") + .filter(OpenGroup.Columns.shouldPoll == true) .group(OpenGroup.Columns.server) .asRequest(of: Info.self) .fetchAll(db) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift index 6a47a74fbf..b22c240b02 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift @@ -190,7 +190,7 @@ public extension GroupPoller { @discardableResult public func getOrCreatePoller(for swarmPublicKey: String) -> SwarmPollerType { guard let poller: GroupPoller = _pollers[swarmPublicKey.lowercased()] else { let poller: GroupPoller = GroupPoller( - pollerName: "Closed group poller with public key: \(swarmPublicKey)", // stringlint:ignore + pollerName: "Group poller with public key: \(swarmPublicKey)", // stringlint:ignore pollerQueue: Threading.groupPollerQueue, pollerDestination: .swarm(swarmPublicKey), pollerDrainBehaviour: .alwaysRandom, diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift index 59229e3df0..8444dc9493 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift @@ -135,7 +135,6 @@ public class SwarmPoller: SwarmPollerType & PollerType { .tryFlatMapWithRandomSnode(drainBehaviour: _pollerDrainBehaviour, using: dependencies) { [pollerDestination, customAuthMethod, namespaces, dependencies] snode -> AnyPublisher<(LibSession.Snode, Network.PreparedRequest), Error> in dependencies[singleton: .storage].readPublisher { db -> (LibSession.Snode, Network.PreparedRequest) in let authMethod: AuthenticationMethod = try (customAuthMethod ?? Authentication.with( - db, swarmPublicKey: pollerDestination.target, using: dependencies )) @@ -310,6 +309,7 @@ public class SwarmPoller: SwarmPollerType & PollerType { } /// Since the hashes are still accurate we can now process the messages + let currentUserSessionId: SessionId = dependencies[cache: .general].sessionId let allProcessedMessages: [ProcessedMessage] = sortedMessages .compactMap { namespace, messages, _ -> [ProcessedMessage]? in let processedMessages: [ProcessedMessage] = messages.compactMap { message -> ProcessedMessage? in @@ -339,7 +339,7 @@ public class SwarmPoller: SwarmPollerType & PollerType { } catch { /// For some error cases we want to update the last hash so do so - if (error as? MessageReceiverError)?.shouldUpdateLastHash == true { + if (error as? MessageError)?.shouldUpdateLastHash == true { hadValidHashUpdate = (message.info?.storeUpdatedLastHash(db) == true) } @@ -348,8 +348,8 @@ public class SwarmPoller: SwarmPollerType & PollerType { /// will be a lot since we each service node duplicates messages) case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, DatabaseError.SQLITE_CONSTRAINT, /// Sometimes thrown for UNIQUE - MessageReceiverError.duplicateMessage, - MessageReceiverError.selfSend: + MessageError.duplicateMessage, + MessageError.selfSend: break case DatabaseError.SQLITE_ABORT: @@ -392,7 +392,7 @@ public class SwarmPoller: SwarmPollerType & PollerType { else { /// Individually process non-config messages processedMessages.forEach { processedMessage in - guard case .standard(let threadId, let threadVariant, let proto, let messageInfo, _) = processedMessage else { + guard case .standard(let threadId, let threadVariant, let messageInfo, _) = processedMessage else { return } @@ -402,9 +402,10 @@ public class SwarmPoller: SwarmPollerType & PollerType { threadId: threadId, threadVariant: threadVariant, message: messageInfo.message, + decodedMessage: messageInfo.decodedMessage, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: proto, - suppressNotifications: (source == .pushNotification), /// Have already shown + suppressNotifications: (source == .pushNotification), /// Have already shown + currentUserSessionIds: [currentUserSessionId.hexString], /// Swarm poller only has one using: dependencies ) diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index c29c7c1979..95a75623a8 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -6,49 +6,61 @@ import GRDB import SessionUIKit import SessionUtilitiesKit -public struct QuotedReplyModel { +public struct QuotedReplyModel: Sendable, Equatable, Hashable { public let threadId: String + public let quotedInteractionId: Int64? public let authorId: String + public let authorName: String public let timestampMs: Int64 public let body: String? public let attachment: Attachment? public let contentType: String? public let sourceFileName: String? public let thumbnailDownloadFailed: Bool + public let proMessageFeatures: SessionPro.MessageFeatures public let currentUserSessionIds: Set // MARK: - Initialization - init( + private init( threadId: String, + quotedInteractionId: Int64?, authorId: String, + authorName: String, timestampMs: Int64, body: String?, attachment: Attachment?, contentType: String?, sourceFileName: String?, thumbnailDownloadFailed: Bool, + proMessageFeatures: SessionPro.MessageFeatures, currentUserSessionIds: Set ) { - self.attachment = attachment self.threadId = threadId + self.quotedInteractionId = quotedInteractionId self.authorId = authorId + self.authorName = authorName self.timestampMs = timestampMs self.body = body + self.attachment = attachment self.contentType = contentType self.sourceFileName = sourceFileName self.thumbnailDownloadFailed = thumbnailDownloadFailed + self.proMessageFeatures = proMessageFeatures self.currentUserSessionIds = currentUserSessionIds } public static func quotedReplyForSending( threadId: String, + quotedInteractionId: Int64?, authorId: String, + authorName: String, variant: Interaction.Variant, body: String?, timestampMs: Int64, attachments: [Attachment]?, linkPreviewAttachment: Attachment?, + proMessageFeatures: SessionPro.MessageFeatures, currentUserSessionIds: Set ) -> QuotedReplyModel? { guard variant == .standardOutgoing || variant == .standardIncoming else { return nil } @@ -58,13 +70,16 @@ public struct QuotedReplyModel { return QuotedReplyModel( threadId: threadId, + quotedInteractionId: quotedInteractionId, authorId: authorId, + authorName: authorName, timestampMs: timestampMs, body: body, attachment: targetAttachment, contentType: targetAttachment?.contentType, sourceFileName: targetAttachment?.sourceFilename, thumbnailDownloadFailed: false, + proMessageFeatures: proMessageFeatures, currentUserSessionIds: currentUserSessionIds ) } diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index 3486db29e6..ca590e5331 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -82,6 +82,10 @@ public actor TypingIndicators { } } + public func isRecipientTyping(threadId: String) async -> Bool { + return (self.incoming[threadId] != nil) + } + fileprivate func handleRefresh(threadId: String, threadVariant: SessionThread.Variant) async { try? await dependencies[singleton: .storage].writeAsync { db in try? MessageSender.send( @@ -135,10 +139,13 @@ public extension TypingIndicators { switch direction { case .outgoing: scheduleRefreshCallback(using: dependencies) case .incoming: - try? await dependencies[singleton: .storage].writeAsync { [threadId, initialTimestampMs] db in - try ThreadTypingIndicator(threadId: threadId, timestampMs: initialTimestampMs).upsert(db) - db.addTypingIndicatorEvent(threadId: threadId, change: .started) - } + await dependencies.notify( + key: .typingIndicator(threadId), + value: TypingIndicatorEvent( + threadId: threadId, + change: .started + ) + ) } await refreshTimeout(sentTimestampMs: initialTimestampMs, using: dependencies) @@ -149,9 +156,9 @@ public extension TypingIndicators { /// `refreshTask` (and if one of those triggered this call then the code would otherwise stop executing because the /// parent task is cancelled Task.detached { [threadId, threadVariant, direction, storage = dependencies[singleton: .storage]] in - try? await storage.writeAsync { db in - switch direction { - case .outgoing: + switch direction { + case .outgoing: + try? await storage.writeAsync { db in try MessageSender.send( db, message: TypingIndicator(kind: .stopped), @@ -160,13 +167,16 @@ public extension TypingIndicators { threadVariant: threadVariant, using: dependencies ) - - case .incoming: - _ = try ThreadTypingIndicator - .filter(ThreadTypingIndicator.Columns.threadId == threadId) - .deleteAll(db) - db.addTypingIndicatorEvent(threadId: threadId, change: .stopped) - } + } + + case .incoming: + await dependencies.notify( + key: .typingIndicator(threadId), + value: TypingIndicatorEvent( + threadId: threadId, + change: .stopped + ) + ) } } diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift new file mode 100644 index 0000000000..b94b7a65d8 --- /dev/null +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -0,0 +1,1037 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import StoreKit +import SessionUtil +import SessionUIKit +import SessionNetworkingKit +import SessionUtilitiesKit + +// MARK: - Singleton + +public extension Singleton { + static let sessionProManager: SingletonConfig = Dependencies.create( + identifier: "sessionProManager", + createInstance: { dependencies in SessionProManager(using: dependencies) } + ) +} + +// MARK: - SessionPro + +public enum SessionPro { + public static var CharacterLimit: Int { SESSION_PROTOCOL_PRO_STANDARD_CHARACTER_LIMIT } + public static var ProCharacterLimit: Int { SESSION_PROTOCOL_PRO_HIGHER_CHARACTER_LIMIT } + public static var PinnedConversationLimit: Int { SESSION_PROTOCOL_PRO_STANDARD_PINNED_CONVERSATION_LIMIT } +} + +// MARK: - SessionProManager + +public actor SessionProManager: SessionProManagerType { + private let dependencies: Dependencies + nonisolated private let syncState: SessionProManagerSyncState + private var revocationListTask: Task? + private var transactionObservingTask: Task? + private var entitlementsObservingTask: Task? + private var proMockingObservationTask: Task? + + private var isRefreshingState: Bool = false + private var rotatingKeyPair: KeyPair? + + nonisolated private let stateStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.invalid) + + nonisolated public var currentUserCurrentRotatingKeyPair: KeyPair? { syncState.rotatingKeyPair } + nonisolated public var currentUserCurrentProState: SessionPro.State { syncState.state } + nonisolated public var currentUserIsCurrentlyPro: Bool { syncState.state.status == .active } + + nonisolated public var pinnedConversationLimit: Int { SessionPro.PinnedConversationLimit } + nonisolated public var characterLimit: Int { + (currentUserIsCurrentlyPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit) + } + + nonisolated public var state: AsyncStream { stateStream.stream } + nonisolated public var currentUserIsPro: AsyncStream { + stateStream.stream + .map { $0.status == .active } + .asAsyncStream() + } + + // MARK: - Initialization + + public init(using dependencies: Dependencies) { + self.dependencies = dependencies + self.syncState = SessionProManagerSyncState(using: dependencies) + + Task.detached(priority: .medium) { [weak self] in + await self?.startProMockingObservations() + + // TODO: [PRO] Probably need to kick of the below tasks within 'startProMockingObservations' if Session Pro gets enabled (will need to check that they aren't already running though) + guard dependencies[feature: .sessionProEnabled] else { return } + + await self?.updateWithLatestFromUserConfig() + await self?.startRevocationListTask() + await self?.startStoreKitObservations() + + /// Kick off a refresh so we know we have the latest state (if it's the main app) + if dependencies[singleton: .appContext].isMainApp { + try? await self?.refreshProState() + } + } + } + + deinit { + revocationListTask?.cancel() + transactionObservingTask?.cancel() + entitlementsObservingTask?.cancel() + proMockingObservationTask?.cancel() + } + + // MARK: - Functions + + nonisolated public func numberOfCharactersLeft(for content: String) -> Int { + let features: SessionPro.FeaturesForMessage = messageFeatures(for: content) + + switch features.status { + case .utfDecodingError: + /// If we got a decoding error then fallback + Log.error(.sessionPro, "Failed to decode content length due to error: \(features.error ?? "Unknown error")") + return (characterLimit - content.utf16.count) + + case .success, .exceedsCharacterLimit: return (characterLimit - features.codePointCount) + } + } + + nonisolated public func proStatus( + for proof: Network.SessionPro.ProProof?, + verifyPubkey: I?, + atTimestampMs timestampMs: UInt64 + ) -> SessionPro.DecodedStatus? { + guard let proof: Network.SessionPro.ProProof else { return nil } + + var cProProof: session_protocol_pro_proof = proof.libSessionValue + let cVerifyPubkey: [UInt8] = (verifyPubkey.map { Array($0) } ?? []) + + return SessionPro.DecodedStatus( + session_protocol_pro_proof_status( + &cProProof, + cVerifyPubkey, + cVerifyPubkey.count, + timestampMs, + nil + ) + ) + } + + nonisolated public func proProofIsActive( + for proof: Network.SessionPro.ProProof?, + atTimestampMs timestampMs: UInt64 + ) -> Bool { + guard let proof: Network.SessionPro.ProProof else { return false } + + var cProProof: session_protocol_pro_proof = proof.libSessionValue + + return session_protocol_pro_proof_is_active(&cProProof, timestampMs) + } + + nonisolated public func messageFeatures(for message: String) -> SessionPro.FeaturesForMessage { + guard let cMessage: [CChar] = message.cString(using: .utf8) else { + return SessionPro.FeaturesForMessage.invalidString + } + + return SessionPro.FeaturesForMessage( + session_protocol_pro_features_for_utf8( + cMessage, + (cMessage.count - 1) /// Need to `- 1` to avoid counting the null-termination character + ) + ) + } + + nonisolated public func profileFeatures(for profile: Profile?) -> SessionPro.ProfileFeatures { + guard syncState.dependencies[feature: .sessionProEnabled] else { return .none } + guard let profile else { + /// If we are forcing the pro badge to appear everywhere then insert it + if syncState.dependencies[feature: .proBadgeEverywhere] { + return .proBadge + } + + return .none + } + + var result: SessionPro.ProfileFeatures = profile.proFeatures + + /// Check if the pro status on the profile has expired (if so clear the features) + switch (profile.proGenIndexHashHex, profile.proExpiryUnixTimestampMs) { + case (.some(let proGenIndexHashHex), let expiryUnixTimestampMs) where expiryUnixTimestampMs > 0: + // TODO: [PRO] Need to check the `proGenIndexHashHex` against the revocation list to see if the user still has pro + let proWasRevoked: Bool = false + let proHasExpired: Bool = (syncState.dependencies.dateNow.timeIntervalSince1970 > (Double(expiryUnixTimestampMs) / 1000)) + + if proWasRevoked || proHasExpired { + result = .none + } + + + /// If we don't have either `proExpiryUnixTimestampMs` or `proGenIndexHashHex` then the pro state is invalid + /// so the user shouldn't have any pro features + default: result = .none + } + + /// If we are forcing the pro badge to appear everywhere then insert it + if syncState.dependencies[feature: .proBadgeEverywhere] { + result.insert(.proBadge) + } + + return result + } + + nonisolated public func attachProInfoIfNeeded(message: Message) -> Message { + let featuresForMessage: SessionPro.FeaturesForMessage = messageFeatures( + for: ((message as? VisibleMessage)?.text ?? "") + ) + let profileFeatures: SessionPro.ProfileFeatures = syncState.state.profileFeatures + + /// We only want to attach the `proFeatures` and `proProof` if a pro feature is _actually_ used + guard + featuresForMessage.status == .success, ( + profileFeatures != .none || + featuresForMessage.features != .none + ), + let proof: Network.SessionPro.ProProof = syncState.state.proof + else { + if featuresForMessage.status != .success { + Log.error(.sessionPro, "Failed to get features for outgoing message due to error: \(featuresForMessage.error ?? "Unknown error")") + } + return message + } + + let updatedMessage: Message = message + updatedMessage.proMessageFeatures = featuresForMessage.features + updatedMessage.proProfileFeatures = profileFeatures + updatedMessage.proProof = proof + + return updatedMessage + } + + @discardableResult @MainActor public func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + onConfirm: (() -> Void)?, + onCancel: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + guard syncState.dependencies[feature: .sessionProEnabled] else { return false } + + switch variant { + case .groupLimit: break /// The `groupLimit` CTA can be shown for Session Pro users as well + default: + guard syncState.state.status != .active else { return false } + + break + } + + let sessionProModal: ModalHostingViewController = ModalHostingViewController( + modal: ProCTAModal( + variant: variant, + dataManager: syncState.dependencies[singleton: .imageDataManager], + sessionProUIManager: self, + dismissType: dismissType, + onConfirm: onConfirm, + onCancel: onCancel, + afterClosed: afterClosed + ) + ) + presenting?(sessionProModal) + + return true + } + + @MainActor public func showSessionProBottomSheetIfNeeded( + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) { + let viewModel: SessionProSettingsViewModel = SessionProSettingsViewModel( + isInBottomSheet: true, + using: syncState.dependencies + ) + let sessionProBottomSheet: BottomSheetHostingViewController = BottomSheetHostingViewController( + bottomSheet: BottomSheet( + hasCloseButton: true, + afterClosed: afterClosed + ) { + SessionListScreen(viewModel: viewModel) + } + ) + presenting?(sessionProBottomSheet) + } + + public func sessionProExpiringCTAInfo() async -> (variant: ProCTAModal.Variant, paymentFlow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow, planInfo: [SessionProPaymentScreenContent.SessionProPlanInfo])? { + let state: SessionPro.State = await stateStream.getCurrent() + let dateNow: Date = dependencies.dateNow + let expiryInSeconds: TimeInterval = (state.accessExpiryTimestampMs + .map { Date(timeIntervalSince1970: (Double($0) / 1000)).timeIntervalSince(dateNow) } ?? 0) + let variant: ProCTAModal.Variant + + switch (state.status, state.autoRenewing, state.refundingStatus) { + case (.neverBeenPro, _, _), (.active, _, .refunding), (.active, true, .notRefunding): return nil + case (.active, false, .notRefunding): + guard expiryInSeconds <= 7 * 24 * 60 * 60 else { return nil } + + variant = .expiring( + timeLeft: expiryInSeconds.formatted( + format: .long, + allowedUnits: [ .day, .hour, .minute ] + ) + ) + + case (.expired, _, _): + guard expiryInSeconds <= 30 * 24 * 60 * 60 else { return nil } + + variant = .expiring(timeLeft: nil) + } + + // TODO: [PRO] Do we need to remove this flag if it's re-purchased or extended? + guard !dependencies[defaults: .standard, key: .hasShownProExpiringCTA] else { return nil } + + let paymentFlow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow = SessionProPaymentScreenContent.SessionProPlanPaymentFlow(state: state) + let planInfo: [SessionProPaymentScreenContent.SessionProPlanInfo] = state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } + + return (variant, paymentFlow, planInfo) + } + + // MARK: - State Management + + public func updateWithLatestFromUserConfig() async { + if #available(iOS 16.0, *) { + do { try await dependencies.waitUntilInitialised(cache: .libSession) } + catch { return Log.error(.sessionPro, "Failed to wait until libSession initialised: \(error)") } + } + else { + /// iOS 15 doesn't support dependency observation so work around it with a loop + while true { + try? await Task.sleep(for: .milliseconds(500)) + + /// If `libSession` has data we can stop waiting + if !dependencies[cache: .libSession].isEmpty { + break + } + } + } + + /// Get the cached pro state from libSession + typealias ProInfo = ( + proConfig: SessionPro.ProConfig?, + profile: Profile, + accessExpiryTimestampMs: UInt64 + ) + let proInfo: ProInfo = dependencies.mutate(cache: .libSession) { + ($0.proConfig, $0.profile, $0.proAccessExpiryTimestampMs) + } + + let rotatingKeyPair: KeyPair? = try? proInfo.proConfig.map { config in + guard config.rotatingPrivateKey.count >= 32 else { return nil } + + return try dependencies[singleton: .crypto].tryGenerate( + .ed25519KeyPair(seed: config.rotatingPrivateKey.prefix(upTo: 32)) + ) + } + + /// Infer the `proStatus` based on the config state (since we don't sync the status) + let proStatus: Network.SessionPro.BackendUserProStatus = { + guard let proof: Network.SessionPro.ProProof = proInfo.proConfig?.proProof else { + return .neverBeenPro + } + + let proofIsActive: Bool = proProofIsActive( + for: proof, + atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + return (proofIsActive ? .active : .expired) + }() + let oldState: SessionPro.State = await stateStream.getCurrent() + let updatedState: SessionPro.State = oldState.with( + status: .set(to: proStatus), + proof: .set(to: proInfo.proConfig?.proProof), + profileFeatures: .set(to: proInfo.profile.proFeatures), + accessExpiryTimestampMs: .set(to: proInfo.accessExpiryTimestampMs), + using: dependencies + ) + + /// Store the updated events and emit updates + self.syncState.update( + rotatingKeyPair: .set(to: rotatingKeyPair), + state: .set(to: updatedState) + ) + self.rotatingKeyPair = rotatingKeyPair + await self.stateStream.send(updatedState) + + /// If the `accessExpiryTimestampMs` value changed then we should trigger a refresh because it generally means that + /// other device did something that should refresh the pro state + if updatedState.accessExpiryTimestampMs != oldState.accessExpiryTimestampMs { + try? await refreshProState() + + await dependencies.notify( + key: .proAccessExpiryUpdated, + value: proInfo.accessExpiryTimestampMs + ) + } + } + + public func purchasePro(productId: String) async throws { + // TODO: [PRO] Show a modal indicating that we are doing a "DEV" purchase when on the simulator + guard !dependencies[feature: .fakeAppleSubscriptionForDev] else { + let bytes: [UInt8] = try dependencies[singleton: .crypto].tryGenerate(.randomBytes(8)) + return try await addProPayment(transactionId: "DEV.\(bytes.toHexString())") // stringlint:ignore + } + + let state: SessionPro.State = await stateStream.getCurrent() + + guard let product: Product = state.products.first(where: { $0.id == productId }) else { + Log.error(.sessionPro, "Attempted to purchase invalid product: \(productId)") + throw SessionProError.productNotFound + } + + let result: Product.PurchaseResult = try await product.purchase() + + guard case .success(let verificationResult) = result else { + switch result { + case .success: throw SessionProError.unhandledBehaviour /// Invalid case + case .pending: + // TODO: [PRO] Need to handle this case, new designs are now available (the `transactionObservingTask` will detect this case) + throw SessionProError.unhandledBehaviour + + case .userCancelled: throw SessionProError.purchaseCancelled + + @unknown default: + Log.critical(.sessionPro, "An unhandled purchase result was received: \(result)") + throw SessionProError.unhandledBehaviour + } + } + + let transaction: Transaction = try verificationResult.payloadValue + + /// There is a race condition where the client can try to register their payment before the Pro Backend has received the notification + /// from Apple that the payment has happened, due to this we need to try add the payment a few times with a small delay before + /// considering it an actual failure + let maxRetries: Int = 3 + + for index in 1...maxRetries { + do { + try await addProPayment(transactionId: "\(transaction.id)") + break /// Successfully registered the payment with the backend so no need to retry + } + catch { + /// If we reached the last retry then throw the error + if index == maxRetries { + Log.error(.sessionPro, "Failed to notify Pro backend of purchase due to error(s): \(error)") + throw error + } + + /// Small incremental backoff before trying again + try await Task.sleep(for: .milliseconds(index * 300)) + } + } + await transaction.finish() + } + + public func addProPayment(transactionId: String) async throws { + // TODO: [PRO] Need to sort out logic for rotating this key pair. + /// First we need to add the pro payment to the Pro backend + let rotatingKeyPair: KeyPair = try ( + self.rotatingKeyPair ?? + dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + ) + let request = try Network.SessionPro.addProPayment( + transactionId: transactionId, + masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), + rotatingKeyPair: rotatingKeyPair, + requestTimeout: 5, /// 5s timeout as per PRD + using: dependencies + ) + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SessionPro.AddProPaymentOrGenerateProProofResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + // TODO: [PRO] Need to show the error modal + let errorString: String = response.header.errors.joined(separator: ", ") + throw SessionProError.purchaseFailed(errorString) + } + + /// Update the config + try await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.updateProConfig( + proConfig: SessionPro.ProConfig( + rotatingPrivateKey: rotatingKeyPair.secretKey, + proProof: response.proof + ) + ) + } + } + } + + /// Send the proof and status events on the streams + /// + /// **Note:** We can assume that the users status is `active` since they just successfully added a pro payment and + /// received a pro proof + let proofIsActive: Bool = proProofIsActive( + for: response.proof, + atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + let proStatus: Network.SessionPro.BackendUserProStatus = (proofIsActive ? .active : .expired) + let oldState: SessionPro.State = await stateStream.getCurrent() + let updatedState: SessionPro.State = oldState.with( + status: .set(to: proStatus), + proof: .set(to: response.proof), + using: dependencies + ) + + syncState.update( + rotatingKeyPair: .set(to: rotatingKeyPair), + state: .set(to: updatedState) + ) + self.rotatingKeyPair = rotatingKeyPair + await self.stateStream.send(updatedState) + + /// Just in case we refresh the pro state (this will avoid needless requests based on the current state but will resolve other + /// edge-cases since it's the main driver to the Pro state) + try? await refreshProState() + } + + // MARK: - Pro State Management + + private func updateProState(to newState: SessionPro.State) async { + syncState.update(state: .set(to: newState)) + await self.stateStream.send(newState) + } + + public func refreshProState(forceLoadingState: Bool) async throws { + /// No point refreshing the state if there is a refresh in progress + guard !isRefreshingState else { return } + + isRefreshingState = true + defer { isRefreshingState = false } + + /// Only reset the `loadingState` if it's currently in an error state + var oldState: SessionPro.State = await stateStream.getCurrent() + var updatedState: SessionPro.State = oldState + + if forceLoadingState || oldState.loadingState == .error { + updatedState = oldState.with( + loadingState: .set(to: .loading), + using: dependencies + ) + + syncState.update(state: .set(to: updatedState)) + await self.stateStream.send(updatedState) + oldState = updatedState + } + + /// Get the product list from the AppStore first (need this to populate the UI) + if oldState.products.isEmpty || oldState.plans.isEmpty { + let result: (products: [Product], plans: [SessionPro.Plan]) = try await SessionPro.Plan + .retrieveProductsAndPlans() + updatedState = oldState.with( + products: .set(to: result.products), + plans: .set(to: result.plans), + using: dependencies + ) + + syncState.update(state: .set(to: updatedState)) + await self.stateStream.send(updatedState) + oldState = updatedState + } + + // FIXME: Await network connectivity when the refactored networking is merged + let request = try? Network.SessionPro.getProDetails( + masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), + using: dependencies + ) + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SessionPro.GetProDetailsResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + let errorString: String = response.header.errors.joined(separator: ", ") + Log.error(.sessionPro, "Failed to retrieve pro details due to error(s): \(errorString)") + + updatedState = oldState.with( + loadingState: .set(to: .error), + using: dependencies + ) + + syncState.update(state: .set(to: updatedState)) + await self.stateStream.send(updatedState) + throw SessionProError.getProDetailsFailed(errorString) + } + updatedState = oldState.with( + status: .set(to: response.status), + autoRenewing: .set(to: response.autoRenewing), + accessExpiryTimestampMs: .set(to: response.expiryTimestampMs), + latestPaymentItem: .set(to: response.items.first), + using: dependencies + ) + + syncState.update(state: .set(to: updatedState)) + await self.stateStream.send(updatedState) + oldState = updatedState + + // TODO: [PRO] Make sure we _actually_ want to remove this state (doing so might mean that we can't tell that the user used to be pro) + switch response.status { + case .active: + try await refreshProProofIfNeeded( + currentProof: updatedState.proof, + accessExpiryTimestampMs: (updatedState.accessExpiryTimestampMs ?? 0), + autoRenewing: updatedState.autoRenewing, + status: updatedState.status + ) + + case .neverBeenPro, .expired: + try await clearStateFromConfig( + accessExpiryTimestampMs: updatedState.accessExpiryTimestampMs + ) + } + + updatedState = oldState.with( + loadingState: .set(to: .success), + using: dependencies + ) + + syncState.update(state: .set(to: updatedState)) + await self.stateStream.send(updatedState) + oldState = updatedState + } + + public func refreshProProofIfNeeded( + currentProof: Network.SessionPro.ProProof?, + accessExpiryTimestampMs: UInt64, + autoRenewing: Bool, + status: Network.SessionPro.BackendUserProStatus + ) async throws { + guard status == .active else { return } + + let needsNewProof: Bool = { + guard let currentProof else { return true } + + let sixtyMinutesBeforeAccessExpiry: UInt64 = (accessExpiryTimestampMs - (60 * 60)) + let sixtyMinutesBeforeProofExpiry: UInt64 = (currentProof.expiryUnixTimestampMs - (60 * 60)) + let now: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + + return ( + sixtyMinutesBeforeProofExpiry < now && + now < sixtyMinutesBeforeAccessExpiry && + autoRenewing + ) + }() + + /// Only generate a new proof if we need one + guard needsNewProof else { return } + + let rotatingKeyPair: KeyPair = try ( + self.rotatingKeyPair ?? + dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + ) + + let request = try Network.SessionPro.generateProProof( + masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), + rotatingKeyPair: rotatingKeyPair, + using: dependencies + ) + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SessionPro.AddProPaymentOrGenerateProProofResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + let errorString: String = response.header.errors.joined(separator: ", ") + Log.error(.sessionPro, "Failed to generate new pro proof due to error(s): \(errorString)") + throw SessionProError.generateProProofFailed(errorString) + } + + /// Update the config + try await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.updateProConfig( + proConfig: SessionPro.ProConfig( + rotatingPrivateKey: rotatingKeyPair.secretKey, + proProof: response.proof + ) + ) + cache.updateProAccessExpiryTimestampMs(accessExpiryTimestampMs) + } + } + } + + /// Send the proof and status events on the streams + /// + /// **Note:** We can assume that the users status is `active` since they just successfully generated a pro proof + let proofIsActive: Bool = proProofIsActive( + for: response.proof, + atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + let proStatus: Network.SessionPro.BackendUserProStatus = (proofIsActive ? .active : .expired) + let oldState: SessionPro.State = await stateStream.getCurrent() + let updatedState: SessionPro.State = oldState.with( + status: .set(to: proStatus), + using: dependencies + ) + + syncState.update( + rotatingKeyPair: .set(to: rotatingKeyPair), + state: .set(to: updatedState) + ) + self.rotatingKeyPair = rotatingKeyPair + await self.stateStream.send(updatedState) + } + + @MainActor public func cancelPro(scene: UIWindowScene) async throws { + do { + try await AppStore.showManageSubscriptions(in: scene) + + // TODO: [PRO] Is there anything else we need to do here? Can we detect what the user did? (eg. via the transaction observation or something similar) + /// Need to refresh the pro state in case the user cancelled their pro (force the UI into the "loading" state just to be sure) + try await refreshProState(forceLoadingState: true) + } + catch { + throw SessionProError.failedToShowStoreKitUI("Manage Subscriptions") + } + } + + @MainActor public func requestRefund(scene: UIWindowScene) async throws { + guard let latestPaymentItem: Network.SessionPro.PaymentItem = await stateStream.getCurrent().latestPaymentItem else { + throw SessionProError.noLatestPaymentItem + } + + /// User has already requested a refund for this item + guard latestPaymentItem.refundRequestedTimestampMs == 0 else { + throw SessionProError.refundAlreadyRequestedForLatestPayment + } + + /// Only Apple support refunding via this mechanism so no point continuing if we don't have a `appleTransactionId` + guard let transactionId: String = latestPaymentItem.appleTransactionId else { + throw SessionProError.nonOriginatedLatestPayment + } + + /// If we don't have the `fakeAppleSubscriptionForDev` feature enabled then we need to actually request the refund from Apple + if !syncState.dependencies[feature: .fakeAppleSubscriptionForDev] { + var transactions: [Transaction] = [] + + for await result in Transaction.currentEntitlements { + if case .verified(let transaction) = result { + transactions.append(transaction) + } + } + + let sortedTransactions: [Transaction] = transactions.sorted { $0.purchaseDate > $1.purchaseDate } + let latestTransaction: Transaction? = sortedTransactions.first + let latestPaymentItemTransaction: Transaction? = sortedTransactions.first(where: { "\($0.id)" == latestPaymentItem.appleTransactionId }) + + if latestTransaction != latestPaymentItemTransaction { + Log.warn(.sessionPro, "The latest transaction didn't match the latest payment item") + } + + /// Prioritise the transaction that matches the latest payment item + guard let targetTransaction: Transaction = (latestPaymentItemTransaction ?? latestTransaction) else { + throw SessionProError.transactionNotFound + } + + let status: Transaction.RefundRequestStatus = try await targetTransaction.beginRefundRequest(in: scene) + + switch status { + case .success: break /// Continue on to send the refund to our backend + case .userCancelled: throw SessionProError.refundCancelled + @unknown default: + Log.critical(.sessionPro, "Unknown refund request status: \(status)") + throw SessionProError.unhandledBehaviour + } + } + + let refundRequestedTimestampMs: UInt64 = syncState.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let request = try Network.SessionPro.setPaymentRefundRequested( + transactionId: transactionId, + refundRequestedTimestampMs: refundRequestedTimestampMs, + masterKeyPair: try syncState.dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), + using: syncState.dependencies + ) + + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SessionPro.SetPaymentRefundRequestedResponse = try await request + .send(using: syncState.dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + let errorString: String = response.header.errors.joined(separator: ", ") + Log.error(.sessionPro, "Refund submission failed due to error(s): \(errorString)") + throw SessionProError.refundFailed(errorString) + } + + /// Need to refresh the pro state to get the updated payment item (which should now include a `refundRequestedTimestampMs`) + try await refreshProState() + } + + // MARK: - Internal Functions + + private func startRevocationListTask() { + revocationListTask = Task { + // TODO: [PRO] Load current revocation list into memory and add to `syncState` + + while true { + do { + let ticket: UInt32 = try await Result( + catching: { + try await dependencies[singleton: .storage].readAsync { db in + UInt32(db[.proRevocationsTicket] ?? 0) + } + } + ) + .mapError { SessionProError.getProRevocationsFailed("Could not retrieve ticket (\($0))") } + .get() + let request = try Network.SessionPro.getProRevocations( + ticket: ticket, + using: dependencies + ) + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SessionPro.GetProRevocationsResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + let errorString: String = response.header.errors.joined(separator: ", ") + throw SessionProError.getProRevocationsFailed(errorString) + } + + try await dependencies[singleton: .storage].writeAsync { db in + db[.proRevocationsTicket] = Int(response.ticket) + + // TODO: [PRO] Need to store the revocations in the database + } + + /// Send out a notification that the revocations list was updated, in case something wants to immediately respond + await dependencies.notify( + key: .proRevocationListUpdated, + value: response.items + ) + + Log.info(.sessionPro, (response.ticket != ticket ? "Successfully updated revocation list to \(response.ticket)." : "Revocation list already up-to-date.")) + try? await Task.sleep(for: .seconds(15 * 60)) /// Wait for 15 mins before trying again + } + catch { + Log.warn(.sessionPro, "\(error), will retry in 10s.") + try? await Task.sleep(for: .seconds(10)) + continue + } + } + } + } + + private func startStoreKitObservations() { + transactionObservingTask = Task { + for await result in Transaction.updates { + do { + switch result { + case .verified(let transaction): + // let transaction: Transaction = try result.payloadValue + // await transaction.finish() + // TODO: [PRO] Need to actually handle this case (send to backend) + break + + case .unverified(_, let error): + Log.error(.sessionPro, "Received an unverified transaction update: \(error)") + } + + } + catch { + Log.error(.sessionPro, "Failed to retrieve transaction from update: \(error)") + } + } + } + + // TODO: [PRO] Do we want this to run in a loop with a sleep in case the user purchases pro on another device? + // TODO: [PRO] Could potentially kick off this task from `updateLatestFromUserConfig` if `updatedState.accessExpiryTimestampMs != oldState.accessExpiryTimestampMs`??? (would get triggered if a user purchased pro using the same account on a separate iOS device while the app is open on this one) + entitlementsObservingTask = Task { [weak self] in + guard let self else { return } + + var currentEntitledTransactions: [Transaction] = [] + + for await result in Transaction.currentEntitlements { + guard case .verified(let transaction) = result else { continue } + + /// Ensure it's a subscription product + guard transaction.productType == .autoRenewable else { continue } + + currentEntitledTransactions.append(transaction) + } + + let oldState: SessionPro.State = await stateStream.getCurrent() + let updatedState: SessionPro.State = oldState.with( + entitledTransactions: .set(to: currentEntitledTransactions), + using: syncState.dependencies + ) + await updateProState(to: updatedState) + } + } + + private func clearStateFromConfig(accessExpiryTimestampMs: UInt64?) async throws { + try await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.removeProConfig() + + /// We should also update the `accessExpiryTimestampMs` stored in the config just in case + cache.updateProAccessExpiryTimestampMs(accessExpiryTimestampMs ?? 0) + } + } + } + } +} + +// MARK: - SyncState + +private final class SessionProManagerSyncState { + private let lock: NSLock = NSLock() + private let _dependencies: Dependencies + private var _rotatingKeyPair: KeyPair? = nil + private var _state: SessionPro.State = .invalid + + fileprivate var dependencies: Dependencies { lock.withLock { _dependencies } } + fileprivate var rotatingKeyPair: KeyPair? { lock.withLock { _rotatingKeyPair } } + fileprivate var state: SessionPro.State { lock.withLock { _state } } + + fileprivate init(using dependencies: Dependencies) { + self._dependencies = dependencies + } + + fileprivate func update( + rotatingKeyPair: Update = .useExisting, + state: Update = .useExisting + ) { + lock.withLock { + self._rotatingKeyPair = rotatingKeyPair.or(self._rotatingKeyPair) + self._state = state.or(self._state) + } + } +} + +// MARK: - SessionProManagerType + +public protocol SessionProManagerType: SessionProUIManagerType { + nonisolated var characterLimit: Int { get } + nonisolated var currentUserCurrentRotatingKeyPair: KeyPair? { get } + nonisolated var currentUserCurrentProState: SessionPro.State { get } + + nonisolated var state: AsyncStream { get } + + nonisolated func proStatus( + for proof: Network.SessionPro.ProProof?, + verifyPubkey: I?, + atTimestampMs timestampMs: UInt64 + ) -> SessionPro.DecodedStatus? + nonisolated func proProofIsActive( + for proof: Network.SessionPro.ProProof?, + atTimestampMs timestampMs: UInt64 + ) -> Bool + nonisolated func messageFeatures(for message: String) -> SessionPro.FeaturesForMessage + nonisolated func profileFeatures(for profile: Profile?) -> SessionPro.ProfileFeatures + nonisolated func attachProInfoIfNeeded(message: Message) -> Message + func sessionProExpiringCTAInfo() async -> (variant: ProCTAModal.Variant, paymentFlow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow, planInfo: [SessionProPaymentScreenContent.SessionProPlanInfo])? + + // MARK: - State Management + + func updateWithLatestFromUserConfig() async + + func purchasePro(productId: String) async throws + func addProPayment(transactionId: String) async throws + func refreshProState(forceLoadingState: Bool) async throws + @MainActor func requestRefund(scene: UIWindowScene) async throws + @MainActor func cancelPro(scene: UIWindowScene) async throws +} + +public extension SessionProManagerType { + func refreshProState() async throws { + try await refreshProState(forceLoadingState: false) + } +} + +// MARK: - Observations + +// stringlint:ignore_contents +public extension ObservableKey { + static func currentUserProState(_ manager: SessionProManagerType) -> ObservableKey { + return ObservableKey.stream( + key: "currentUserProState", + generic: .currentUserProState + ) { [weak manager] in manager?.state } + } + + static let proAccessExpiryUpdated: ObservableKey = ObservableKey( + "proAccessExpiryUpdated", + .proAccessExpiryUpdated + ) + + static let proRevocationListUpdated: ObservableKey = ObservableKey( + "proRevocationListUpdated", + .proRevocationListUpdated + ) +} + +// stringlint:ignore_contents +public extension GenericObservableKey { + static let currentUserProState: GenericObservableKey = "currentUserProState" + static let proAccessExpiryUpdated: GenericObservableKey = "proAccessExpiryUpdated" + static let proRevocationListUpdated: GenericObservableKey = "proRevocationListUpdated" +} + +// MARK: - Mocking + +private extension SessionProManager { + private func startProMockingObservations() { + proMockingObservationTask = ObservationBuilder + .initialValue(SessionPro.MockState(using: dependencies)) + .debounce(for: .milliseconds(10)) + .using(dependencies: dependencies) + .query { previousState, _, _, dependencies in + SessionPro.MockState(previousInfo: previousState.info, using: dependencies) + } + .assign { [weak self] state in + Task.detached(priority: .userInitiated) { + /// If the entire Session Pro feature is disabled then clear any state + guard state.info.sessionProEnabled else { + self?.syncState.update( + rotatingKeyPair: .set(to: nil), + state: .set(to: .invalid) + ) + + await self?.stateStream.send(.invalid) + return + } + + /// If we need a state refresh then start a new task to do so (we don't want the mocking to be dependant on the + /// result of the refresh so don't wait for it to complete before doing any mock changes) + if state.needsRefresh { + Task.detached { [weak self] in try await self?.refreshProState() } + } + + /// While it would be easier to just rely on `refreshProState` to update the mocked values, that would + /// mean the mocking requires network connectivity which isn't ideal, so we also explicitly send out any mock + /// changes separately + guard + let oldState: SessionPro.State = await self?.stateStream.getCurrent(), + let dependencies: Dependencies = self?.syncState.dependencies + else { return } + + let updatedState: SessionPro.State = oldState.with(using: dependencies) + self?.syncState.update(state: .set(to: updatedState)) + await self?.stateStream.send(updatedState) + } + } + } +} diff --git a/SessionMessagingKit/SessionPro/SessionProPaymentScreenContent.swift b/SessionMessagingKit/SessionPro/SessionProPaymentScreenContent.swift index ce1f812adf..8630fd9f70 100644 --- a/SessionMessagingKit/SessionPro/SessionProPaymentScreenContent.swift +++ b/SessionMessagingKit/SessionPro/SessionProPaymentScreenContent.swift @@ -8,50 +8,43 @@ import SessionUtilitiesKit extension SessionProPaymentScreenContent { public class ViewModel: ViewModelType { public var dataModel: DataModel + public var dateNow: Date { dependencies.dateNow } public var isRefreshing: Bool = false public var errorString: String? public var isFromBottomSheet: Bool private var dependencies: Dependencies - public init(dependencies: Dependencies, dataModel: DataModel, isFromBottomSheet: Bool) { + public init(dataModel: DataModel, isFromBottomSheet: Bool, using dependencies: Dependencies) { self.dependencies = dependencies self.dataModel = dataModel self.isFromBottomSheet = isFromBottomSheet } - public func purchase(planInfo: SessionProPlanInfo, success: (() -> Void)?, failure: (() -> Void)?) async { - let plan: SessionProPlan = SessionProPlan.from(planInfo) - await dependencies[singleton: .sessionProState].upgradeToPro( - plan: plan, - originatingPlatform: .iOS - ) { result in - if result { - success?() - } else { - failure?() - } - } + @MainActor public func purchase(planInfo: SessionProPlanInfo) async throws { + try await Task.detached(priority: .userInitiated) { [dependencies] in + try await dependencies[singleton: .sessionProManager].purchasePro( + productId: planInfo.id + ) + }.value } - public func cancelPro(success: (() -> Void)?, failure: (() -> Void)?) async { - await dependencies[singleton: .sessionProState].cancelPro { result in - if result { - success?() - } else { - failure?() - } + @MainActor public func cancelPro(scene: UIWindowScene?) async throws { + guard let scene else { + Log.error(.sessionPro, "Failed to being refund request: Unable to get UIWindowScene") + throw SessionProError.windowSceneRequired } + + try await dependencies[singleton: .sessionProManager].cancelPro(scene: scene) } - public func requestRefund(success: (() -> Void)?, failure: (() -> Void)?) async { - await dependencies[singleton: .sessionProState].requestRefund { result in - if result { - success?() - } else { - failure?() - } + @MainActor public func requestRefund(scene: UIWindowScene?) async throws { + guard let scene else { + Log.error(.sessionPro, "Failed to being refund request: Unable to get UIWindowScene") + throw SessionProError.windowSceneRequired } + + try await dependencies[singleton: .sessionProManager].requestRefund(scene: scene) } } } diff --git a/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift b/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift index 6f272e3700..b4cf0f4757 100644 --- a/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift +++ b/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift @@ -6,18 +6,26 @@ import SwiftUI import GRDB import DifferenceKit import SessionUIKit +import SessionNetworkingKit import SessionUtilitiesKit +// MARK: - Log.Category + +public extension Log.Category { + static let proSettingsViewModel: Log.Category = .create("ProSettingsViewModel", defaultLevel: .warn) +} + +// MARK: - SessionProSettingsViewModel + public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType, NavigatableStateHolder, NavigatableStateHolder_SwiftUI { public let dependencies: Dependencies public let navigatableState: NavigatableState = NavigatableState() public var navigatableStateSwiftUI: NavigatableState_SwiftUI = NavigatableState_SwiftUI() public let title: String = "" public let state: SessionListScreenContent.ListItemDataState = SessionListScreenContent.ListItemDataState() - public let isInBottomSheet: Bool /// This value is the current state of the view - @MainActor @Published private(set) var internalState: ViewModelState + @MainActor @Published private(set) var internalState: State private var observationTask: Task? // MARK: - Initialization @@ -27,8 +35,10 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType using dependencies: Dependencies ) { self.dependencies = dependencies - self.isInBottomSheet = isInBottomSheet - self.internalState = ViewModelState.initialState() + self.internalState = State.initialState( + isInBottomSheet: isInBottomSheet, + using: dependencies + ) self.observationTask = ObservationBuilder .initialValue(self.internalState) @@ -119,16 +129,16 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType // MARK: - Content - public struct ViewModelState: ObservableKeyProvider { + public struct State: ObservableKeyProvider { + let isInBottomSheet: Bool + let profile: Profile + let proState: SessionPro.State let numberOfGroupsUpgraded: Int let numberOfPinnedConversations: Int let numberOfProBadgesSent: Int let numberOfLongerMessagesSent: Int - let isProBadgeEnabled: Bool - let currentProPlanState: SessionProPlanState - let loadingState: SessionProLoadingState - @MainActor public func sections(viewModel: SessionProSettingsViewModel, previousState: ViewModelState) -> [SectionModel] { + @MainActor public func sections(viewModel: SessionProSettingsViewModel, previousState: State) -> [SectionModel] { SessionProSettingsViewModel.sections( state: self, previousState: previousState, @@ -136,208 +146,247 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) } - public let observedKeys: Set = [ - .anyConversationPinnedPriorityChanged, - .setting(.groupsUpgradedCounter), - .setting(.proBadgesSentCounter), - .setting(.longerMessagesSentCounter), - .setting(.isProBadgeEnabled), - .feature(.mockCurrentUserSessionProState), // TODO: [PRO] real data from libSession - .feature(.mockCurrentUserSessionProLoadingState) // TODO: [PRO] real loading status - ] + /// We need `dependencies` to generate the keys in this case so set the variable `observedKeys` to an empty array to + /// suppress the conformance warning + public let observedKeys: Set = [] + public func observedKeys(using dependencies: Dependencies) -> Set { + let sessionProManager: SessionProManagerType = dependencies[singleton: .sessionProManager] + + return [ + .anyConversationPinnedPriorityChanged, + .profile(profile.id), + .currentUserProState(sessionProManager), + .setting(.groupsUpgradedCounter), + .setting(.proBadgesSentCounter), + .setting(.longerMessagesSentCounter) + ] + } - static func initialState() -> ViewModelState { - return ViewModelState( + static func initialState(isInBottomSheet: Bool, using dependencies: Dependencies) -> State { + return State( + isInBottomSheet: isInBottomSheet, + profile: dependencies.mutate(cache: .libSession) { $0.profile }, + proState: dependencies[singleton: .sessionProManager].currentUserCurrentProState, numberOfGroupsUpgraded: 0, numberOfPinnedConversations: 0, numberOfProBadgesSent: 0, - numberOfLongerMessagesSent: 0, - isProBadgeEnabled: false, - currentProPlanState: .none, - loadingState: .loading + numberOfLongerMessagesSent: 0 ) } } @Sendable private static func queryState( - previousState: ViewModelState, + previousState: State, events: [ObservedEvent], isInitialQuery: Bool, using dependencies: Dependencies - ) async -> ViewModelState { + ) async -> State { + var profile: Profile = previousState.profile + var proState: SessionPro.State = previousState.proState var numberOfGroupsUpgraded: Int = previousState.numberOfGroupsUpgraded var numberOfPinnedConversations: Int = previousState.numberOfPinnedConversations var numberOfProBadgesSent: Int = previousState.numberOfProBadgesSent var numberOfLongerMessagesSent: Int = previousState.numberOfLongerMessagesSent - var isProBadgeEnabled: Bool = previousState.isProBadgeEnabled - var currentProPlanState: SessionProPlanState = previousState.currentProPlanState - var loadingState: SessionProLoadingState = previousState.loadingState + + /// Store a local copy of the events so we can manipulate it based on the state changes + let eventsToProcess: [ObservedEvent] = events /// If we have no previous state then we need to fetch the initial state if isInitialQuery { - dependencies.mutate(cache: .libSession) { libSession in - isProBadgeEnabled = libSession.get(.isProBadgeEnabled) - } - dependencies[singleton: .storage].read { db in - numberOfGroupsUpgraded = db[.groupsUpgradedCounter] ?? 0 - numberOfPinnedConversations = ( - try? SessionThread - .filter(SessionThread.Columns.pinnedPriority > 0) - .fetchCount(db) + do { + proState = await dependencies[singleton: .sessionProManager].state + .first(defaultValue: .invalid) + + try await dependencies[singleton: .storage].readAsync { db in + numberOfGroupsUpgraded = (db[.groupsUpgradedCounter] ?? 0) + numberOfPinnedConversations = ( + try? SessionThread + .filter(SessionThread.Columns.pinnedPriority > 0) + .fetchCount(db) ).defaulting(to: 0) - numberOfProBadgesSent = db[.proBadgesSentCounter] ?? 0 - numberOfLongerMessagesSent = db[.longerMessagesSentCounter] ?? 0 + numberOfProBadgesSent = (db[.proBadgesSentCounter] ?? 0) + numberOfLongerMessagesSent = (db[.longerMessagesSentCounter] ?? 0) + } + } + catch { + Log.critical(.proSettingsViewModel, "Failed to fetch initial state, due to error: \(error)") } } - /// Process any event changes - events.forEach { event in + /// Split the events between those that need database access and those that don't + let changes: EventChangeset = eventsToProcess.split(by: { $0.handlingStrategy }) + + /// Process any general event changes + if let value = changes.latestGeneric(.currentUserProState, as: SessionPro.State.self) { + proState = value + } + + changes.forEach(.profile, as: ProfileEvent.self) { event in + switch event.change { + case .name(let name): profile = profile.with(name: name) + case .nickname(let nickname): profile = profile.with(nickname: .set(to: nickname)) + case .displayPictureUrl(let url): profile = profile.with(displayPictureUrl: .set(to: url)) + case .proStatus(_, let features, let expiryUnixTimestampMs, let genIndexHashHex): + profile = profile.with( + proFeatures: .set(to: features), + proExpiryUnixTimestampMs: .set(to: expiryUnixTimestampMs), + proGenIndexHashHex: .set(to: genIndexHashHex) + ) + } + } + + changes.forEachEvent(.setting, as: Int.self) { event, value in switch event.key { - case .anyConversationPinnedPriorityChanged: - dependencies[singleton: .storage].read { db in + case .setting(.groupsUpgradedCounter): numberOfGroupsUpgraded = value + case .setting(.proBadgesSentCounter): numberOfProBadgesSent = value + case .setting(.longerMessagesSentCounter): numberOfLongerMessagesSent = value + default: break + } + } + + /// Then handle database events + if !dependencies[singleton: .storage].isSuspended, !changes.databaseEvents.isEmpty { + do { + try await dependencies[singleton: .storage].readAsync { db in + if changes.latest(.anyConversationPinnedPriorityChanged) != nil { numberOfPinnedConversations = ( try? SessionThread .filter(SessionThread.Columns.pinnedPriority > 0) .fetchCount(db) ).defaulting(to: 0) } - case .setting(.groupsUpgradedCounter): - guard let updatedValue = event.value as? Int else { return } - numberOfGroupsUpgraded = updatedValue - case .setting(.proBadgesSentCounter): - guard let updatedValue = event.value as? Int else { return } - numberOfProBadgesSent = updatedValue - case .setting(.longerMessagesSentCounter): - guard let updatedValue = event.value as? Int else { return } - numberOfLongerMessagesSent = updatedValue - case .setting(.isProBadgeEnabled): - guard let updatedValue = event.value as? Bool else { return } - isProBadgeEnabled = updatedValue - default: break + } + } catch { + let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") + Log.critical(.proSettingsViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)") } } + else if !changes.databaseEvents.isEmpty { + Log.warn(.proSettingsViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") + } - currentProPlanState = dependencies[singleton: .sessionProState].sessionProStateSubject.value - loadingState = dependencies[feature: .mockCurrentUserSessionProLoadingState] - - return ViewModelState( + return State( + isInBottomSheet: previousState.isInBottomSheet, + profile: profile, + proState: proState, numberOfGroupsUpgraded: numberOfGroupsUpgraded, numberOfPinnedConversations: numberOfPinnedConversations, numberOfProBadgesSent: numberOfProBadgesSent, - numberOfLongerMessagesSent: numberOfLongerMessagesSent, - isProBadgeEnabled: isProBadgeEnabled, - currentProPlanState: currentProPlanState, - loadingState: loadingState + numberOfLongerMessagesSent: numberOfLongerMessagesSent ) } private static func sections( - state: ViewModelState, - previousState: ViewModelState, + state: State, + previousState: State, viewModel: SessionProSettingsViewModel ) -> [SectionModel] { - let logo: SectionModel = SectionModel( + var logo: SectionModel = SectionModel( model: .logoWithPro, elements: [ SessionListScreenContent.ListItemInfo( id: .logoWithPro, variant: .logoWithPro( - info: .init( - themeStyle:{ - switch (state.currentProPlanState, viewModel.isInBottomSheet) { + info: ListItemLogoWithPro.Info( + themeStyle: { + switch (state.proState.status, state.isInBottomSheet) { case (.expired, false): .disabled default: .normal } }(), glowingBackgroundStyle: .base, state: { - switch state.loadingState { - case .loading: + switch (state.proState.loadingState, state.proState.status) { + case (.success, _): return .success + case (.loading, .expired), (.loading, .neverBeenPro): + return .loading( + message: "checkingProStatus" + .put(key: "pro", value: Constants.pro) + .localized() + ) + + case (.loading, .active): return .loading( - message: { - switch state.currentProPlanState { - case .expired, .none: - "checkingProStatus" - .put(key: "pro", value: Constants.pro) - .localized() - default: - "proStatusLoading" - .put(key: "pro", value: Constants.pro) - .localized() - } - }() + message: "proStatusLoading" + .put(key: "pro", value: Constants.pro) + .localized() ) - case .error: + + case (.error, .expired), (.error, .neverBeenPro): return .error( - message: { - switch state.currentProPlanState { - case .expired, .none: - "errorCheckingProStatus" - .put(key: "pro", value: Constants.pro) - .localized() - default: - "proErrorRefreshingStatus" - .put(key: "pro", value: Constants.pro) - .localized() - } - }() + message: "errorCheckingProStatus" + .put(key: "pro", value: Constants.pro) + .localized() + ) + + case (.error, .active): + return .error( + message: "proErrorRefreshingStatus" + .put(key: "pro", value: Constants.pro) + .localized() ) - case .success: - return .success } }(), description: { - switch (state.currentProPlanState, viewModel.isInBottomSheet) { + switch (state.proState.status, state.isInBottomSheet) { case (.expired, true): return "proAccessRenewStart" .put(key: "pro", value: Constants.pro) .put(key: "app_pro", value: Constants.app_pro) .localizedFormatted() - case (.none, _): + + case (.neverBeenPro, _): return "proFullestPotential" .put(key: "app_name", value: Constants.app_name) .put(key: "app_pro", value: Constants.app_pro) .localizedFormatted() - default: - return nil - } + + default: return nil + } }() ) ), onTap: { [weak viewModel] in - switch state.loadingState { + guard state.proState.status != .neverBeenPro else { return } + + switch state.proState.loadingState { + case .success: break case .loading: viewModel?.showLoadingModal( from: .logoWithPro, title: { - switch state.currentProPlanState { - case .active, .refunding: + switch state.proState.status { + case .active: "proStatusLoading" .put(key: "pro", value: Constants.pro) .localized() - case .expired, .none: + + case .expired, .neverBeenPro: "checkingProStatus" .put(key: "pro", value: Constants.pro) .localized() } }(), description: { - switch state.currentProPlanState { - case .active, .refunding: + switch state.proState.status { + case .active: "proStatusLoadingDescription" .put(key: "pro", value: Constants.pro) .localized() + case .expired: "checkingProStatusDescription" .put(key: "pro", value: Constants.pro) .localized() - case .none: + + case .neverBeenPro: "checkingProStatusContinue" .put(key: "pro", value: Constants.pro) .localized() } }() ) + case .error: viewModel?.showErrorModal( from: .logoWithPro, @@ -345,8 +394,8 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .put(key: "pro", value: Constants.pro) .localized(), description: { - switch state.currentProPlanState { - case .none: + switch state.proState.status { + case .neverBeenPro: "proStatusNetworkErrorContinue" .put(key: "pro", value: Constants.pro) .localizedFormatted() @@ -357,47 +406,52 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } }() ) - case .success: - break } } - ), - ( - (state.currentProPlanState != .none && !viewModel.isInBottomSheet) ? nil : - SessionListScreenContent.ListItemInfo( - id: .continueButton, - variant: .button(title: "theContinue".localized(), enabled: (state.loadingState == .success)), - onTap: { [weak viewModel] in - switch state.loadingState { - case .loading: - viewModel?.showLoadingModal( - from: .logoWithPro, - title: "checkingProStatus" - .put(key: "pro", value: Constants.pro) - .localized(), - description: "checkingProStatusContinue" - .put(key: "pro", value: Constants.pro) - .localized() - ) - case .error: - viewModel?.showErrorModal( - from: .logoWithPro, - title: "proStatusError" - .put(key: "pro", value: Constants.pro) - .localized(), - description: "proStatusRefreshNetworkError" - .put(key: "pro", value: Constants.pro) - .localizedFormatted() - ) - case .success: - viewModel?.updateProPlan() - } - } - ) ) - ].compactMap { $0 } + ] ) + switch (state.proState.status, state.isInBottomSheet) { + case (.active, _), (.expired, _), (.neverBeenPro, false): break + case (.neverBeenPro, true): + logo.elements.append( + SessionListScreenContent.ListItemInfo( + id: .continueButton, + variant: .button( + title: "theContinue".localized(), + enabled: (state.proState.loadingState == .success) + ), + onTap: { [weak viewModel] in + switch state.proState.loadingState { + case .success: viewModel?.updateProPlan(state: state) + case .loading: + viewModel?.showLoadingModal( + from: .logoWithPro, + title: "checkingProStatus" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "checkingProStatusContinue" + .put(key: "pro", value: Constants.pro) + .localized() + ) + + case .error: + viewModel?.showErrorModal( + from: .logoWithPro, + title: "proStatusError" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "proStatusRefreshNetworkError" + .put(key: "pro", value: Constants.pro) + .localizedFormatted() + ) + } + } + ) + ) + } + let proFeatures: SectionModel = SectionModel( model: .proFeatures, elements: getProFeaturesElements(state: state, viewModel: viewModel) @@ -405,7 +459,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType // We can return the logo and proFeatures here since they are the only 2 sections that // the bottom sheet needs - guard !viewModel.isInBottomSheet else { + guard !state.isInBottomSheet else { return [ logo, proFeatures ] } @@ -447,7 +501,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .squareArrowUpRight, size: .large, customTint: { - switch state.currentProPlanState { + switch state.proState.status { case .expired: return .textPrimary default: return .sessionButton_text } @@ -455,17 +509,17 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in viewModel?.openUrl(Constants.session_pro_faq_url) } + onTap: { [weak viewModel] in viewModel?.openUrl(Constants.urls.proFaq) } ), SessionListScreenContent.ListItemInfo( id: .support, variant: .cell( - info: .init( - title: .init( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( "helpSupport".localized(), font: .Headings.H8 ), - description: .init( + description: SessionListScreenContent.TextInfo( "proSupportDescription" .put(key: "pro", value: Constants.pro) .localized(), @@ -475,7 +529,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .squareArrowUpRight, size: .large, customTint: { - switch state.currentProPlanState { + switch state.proState.status { case .expired: return .textPrimary default: return .sessionButton_text } @@ -483,27 +537,23 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in viewModel?.openUrl(Constants.session_pro_support_url) } + onTap: { [weak viewModel] in viewModel?.openUrl(Constants.urls.support) } ) ] ) - return switch state.currentProPlanState { - case .none: - [ logo, proFeatures, proManagement, help ] - case .active: - [ logo, proStats, proSettings, proFeatures, proManagement, help ] - case .expired: - [ logo, proManagement, proFeatures, help ] - case .refunding: - [ logo, proStats, proSettings, proFeatures, help ] + return switch (state.proState.status, state.proState.refundingStatus) { + case (.neverBeenPro, _): [ logo, proFeatures, proManagement, help ] + case (.active, .notRefunding): [ logo, proStats, proSettings, proFeatures, proManagement, help ] + case (.expired, _): [ logo, proManagement, proFeatures, help ] + case (.active, .refunding): [ logo, proStats, proSettings, proFeatures, help ] } } // MARK: - Pro Stats Elements private static func getProStatsElements( - state: ViewModelState, + state: State, viewModel: SessionProSettingsViewModel ) -> [SessionListScreenContent.ListItemInfo] { return [ @@ -512,82 +562,83 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType variant: .dataMatrix( info: [ [ - .init( + ListItemDataMatrix.Info( leadingAccessory: .icon( .messageSquare, size: .large, customTint: .primary ), - title: .init( + title: SessionListScreenContent.TextInfo( "proLongerMessagesSent" .putNumber(state.numberOfLongerMessagesSent) - .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfLongerMessagesSent) + .put(key: "total", value: (state.proState.loadingState == .loading ? "" : state.numberOfLongerMessagesSent)) .localized(), font: .Headings.H9 ), - isLoading: state.loadingState == .loading + isLoading: (state.proState.loadingState == .loading) ), - .init( + ListItemDataMatrix.Info( leadingAccessory: .icon( .pin, size: .large, customTint: .primary ), - title: .init( + title: SessionListScreenContent.TextInfo( "proPinnedConversations" .putNumber(state.numberOfPinnedConversations) - .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfPinnedConversations) + .put(key: "total", value: (state.proState.loadingState == .loading ? "" : state.numberOfPinnedConversations)) .localized(), font: .Headings.H9 ), - isLoading: state.loadingState == .loading + isLoading: (state.proState.loadingState == .loading) ) ], [ - .init( + ListItemDataMatrix.Info( leadingAccessory: .icon( .rectangleEllipsis, size: .large, customTint: .primary ), - title: .init( + title: SessionListScreenContent.TextInfo( "proBadgesSent" .putNumber(state.numberOfProBadgesSent) - .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfProBadgesSent) + .put(key: "total", value: (state.proState.loadingState == .loading ? "" : state.numberOfProBadgesSent)) .put(key: "pro", value: Constants.pro) .localized(), font: .Headings.H9 ), - isLoading: state.loadingState == .loading + isLoading: (state.proState.loadingState == .loading) ), - .init( + ListItemDataMatrix.Info( leadingAccessory: .icon( UIImage(named: "ic_user_group"), size: .large, customTint: .disabled ), - title: .init( + title: SessionListScreenContent.TextInfo( "proGroupsUpgraded" .putNumber(state.numberOfGroupsUpgraded) - .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfGroupsUpgraded) + .put(key: "total", value: (state.proState.loadingState == .loading ? "" : state.numberOfGroupsUpgraded)) .localized(), font: .Headings.H9, - color: state.loadingState == .loading ? .textPrimary : .disabled + color: (state.proState.loadingState == .loading ? .textPrimary : .disabled) ), - tooltipInfo: .init( + tooltipInfo: SessionListScreenContent.TooltipInfo( id: "SessionListScreen.DataMatrix.UpgradedGroups.ToolTip", // stringlint:ignore content: "proLargerGroupsTooltip" .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)), tintColor: .disabled, position: .topLeft ), - isLoading: state.loadingState == .loading + isLoading: (state.proState.loadingState == .loading) ) ] ] ), onTap: { [weak viewModel] in - guard state.loadingState == .loading else { return } + guard state.proState.loadingState == .loading else { return } + viewModel?.showLoadingModal( from: .proStats, title: "proStatsLoading" @@ -605,14 +656,15 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType // MARK: - Pro Features Elements private static func getProFeaturesElements( - state: ViewModelState, + state: State, viewModel: SessionProSettingsViewModel ) -> [SessionListScreenContent.ListItemInfo] { let proFeaturesIds: [ListItem] = [ .longerMessages, .unlimitedPins, .animatedDisplayPictures, .badges ] let proState: ProFeaturesInfo.ProState = { - guard !viewModel.isInBottomSheet else { return .none } - switch state.currentProPlanState { - case .none: return .none + guard !state.isInBottomSheet else { return .neverBeenPro } + + switch state.proState.status { + case .neverBeenPro: return .neverBeenPro case .expired: return .expired default: return .active } @@ -624,7 +676,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType SessionListScreenContent.ListItemInfo( id: id, variant: .cell( - info: .init( + info: ListItemCell.Info( leadingAccessory: .icon( info.icon, iconSize: .medium, @@ -633,8 +685,16 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType backgroundSize: .veryLarge, backgroundCornerRadius: 8 ), - title: .init(info.title, font: .Headings.H9, accessory: info.accessory), - description: .init(font: .Body.smallRegular, attributedString: info.description, color: .textSecondary) + title: SessionListScreenContent.TextInfo( + info.title, + font: .Headings.H9, + accessory: info.accessory + ), + description: SessionListScreenContent.TextInfo( + font: .Body.smallRegular, + attributedString: info.description, + color: .textSecondary + ) ) ) ) @@ -643,7 +703,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType SessionListScreenContent.ListItemInfo( id: .plusLoadsMore, variant: .cell( - info: .init( + info: ListItemCell.Info( leadingAccessory: .icon( plusMoreFeatureInfo.icon, iconSize: .medium, @@ -652,8 +712,11 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType backgroundSize: .veryLarge, backgroundCornerRadius: 8 ), - title: .init(plusMoreFeatureInfo.title, font: .Headings.H9), - description: .init( + title: SessionListScreenContent.TextInfo( + plusMoreFeatureInfo.title, + font: .Headings.H9 + ), + description: SessionListScreenContent.TextInfo( font: .Body.smallRegular, attributedString: plusMoreFeatureInfo.description, color: .textSecondary @@ -661,7 +724,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ), onTap: { [weak viewModel] in - viewModel?.openUrl(Constants.session_pro_roadmap) + viewModel?.openUrl(Constants.urls.proRoadmap) } ) ) @@ -672,170 +735,210 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType // MARK: - Pro Settings Elements private static func getProSettingsElements( - state: ViewModelState, - previousState: ViewModelState, + state: State, + previousState: State, viewModel: SessionProSettingsViewModel ) -> [SessionListScreenContent.ListItemInfo] { - return [ - { - switch state.currentProPlanState { - case .none: nil - case .active(_, let expiredOn, let isAutoRenewing, _): - SessionListScreenContent.ListItemInfo( - id: .updatePlan, - variant: .cell( - info: .init( - title: .init( - "updateAccess" - .put(key: "pro", value: Constants.pro) - .localized(), - font: .Headings.H8 - ), - description: { - switch state.loadingState { - case .loading: - .init( - font: .Body.smallRegular, - attributedString: "proAccessLoadingEllipsis" - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.smallRegular) - ) - case .error: - .init( - font: .Body.smallRegular, - attributedString: "errorLoadingProAccess" - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.smallRegular), - color: .warning - ) - case .success: - .init( - font: .Body.smallRegular, - attributedString: ( - isAutoRenewing ? - "proAutoRenewTime" - .put(key: "pro", value: Constants.pro) - .put(key: "time", value: expiredOn.timeIntervalSinceNow.ceilingFormatted(format: .long, allowedUnits: [.day, .hour, .minute])) - .localizedFormatted(Fonts.Body.smallRegular) : - "proExpiringTime" - .put(key: "pro", value: Constants.pro) - .put(key: "time", value: expiredOn.timeIntervalSinceNow.ceilingFormatted(format: .long, allowedUnits: [.day, .hour, .minute])) - .localizedFormatted(Fonts.Body.smallRegular) - ) + let initialProSettingsElements: [SessionListScreenContent.ListItemInfo] + + switch (state.proState.status, state.proState.refundingStatus) { + case (.neverBeenPro, _), (.expired, _): initialProSettingsElements = [] + case (.active, .notRefunding): + initialProSettingsElements = [ + SessionListScreenContent.ListItemInfo( + id: .updatePlan, + variant: .cell( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( + "updateAccess" + .put(key: "pro", value: Constants.pro) + .localized(), + font: .Headings.H8 + ), + description: { + switch state.proState.loadingState { + case .loading: + return SessionListScreenContent.TextInfo( + font: .Body.smallRegular, + attributedString: "proAccessLoadingEllipsis" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.smallRegular) + ) + + case .error: + return SessionListScreenContent.TextInfo( + font: .Body.smallRegular, + attributedString: "errorLoadingProAccess" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.smallRegular), + color: .warning + ) + + case .success: + let expirationDate: Date = Date( + timeIntervalSince1970: floor(Double(state.proState.accessExpiryTimestampMs ?? 0) / 1000) + ) + let expirationString: String = expirationDate + .timeIntervalSince(viewModel.dependencies.dateNow) + .ceilingFormatted( + format: .long, + allowedUnits: [.day, .hour, .minute] ) - } - }(), - trailingAccessory: state.loadingState == .loading ? .loadingIndicator(size: .large) : .icon(.chevronRight, size: .large) + + return SessionListScreenContent.TextInfo( + font: .Body.smallRegular, + attributedString: (state.proState.autoRenewing ? + "proAutoRenewTime" + .put(key: "pro", value: Constants.pro) + .put(key: "time", value: expirationString) + .localizedFormatted(Fonts.Body.smallRegular) : + "proExpiringTime" + .put(key: "pro", value: Constants.pro) + .put(key: "time", value: expirationString) + .localizedFormatted(Fonts.Body.smallRegular) + ) + ) + } + }(), + trailingAccessory: (state.proState.loadingState == .loading ? + .loadingIndicator(size: .large) : + .icon(.chevronRight, size: .large) ) - ), - onTap: { [weak viewModel] in - switch state.loadingState { - case .loading: - viewModel?.showLoadingModal( - from: .updatePlan, - title: "proAccessLoading" - .put(key: "pro", value: Constants.pro) - .localized(), - description: "proAccessLoadingDescription" - .put(key: "pro", value: Constants.pro) - .localized() - ) - case .error: - viewModel?.showErrorModal( - from: .updatePlan, - title: "proAccessError" - .put(key: "pro", value: Constants.pro) - .localized(), - description: "proAccessNetworkLoadError" - .put(key: "pro", value: Constants.pro) - .put(key: "app_name", value: Constants.app_name) - .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) - ) - case .success: - viewModel?.updateProPlan() - } + ) + ), + onTap: { [weak viewModel] in + switch state.proState.loadingState { + case .success: viewModel?.updateProPlan(state: state) + case .loading: + viewModel?.showLoadingModal( + from: .updatePlan, + title: "proAccessLoading" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "proAccessLoadingDescription" + .put(key: "pro", value: Constants.pro) + .localized() + ) + + case .error: + viewModel?.showErrorModal( + from: .updatePlan, + title: "proAccessError" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "proAccessNetworkLoadError" + .put(key: "pro", value: Constants.pro) + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) + ) } - ) - case .expired: - nil - case .refunding(let originatingPlatform, _): - SessionListScreenContent.ListItemInfo( - id: .refundRequested, - variant: .cell( - info: .init( - title: .init("proRequestedRefund".localized(), font: .Headings.H8), - description: .init( - font: .Body.smallRegular, - attributedString: "processingRefundRequest" - .put(key: "platform", value: originatingPlatform.name) - .localizedFormatted(Fonts.Body.smallRegular) - ), - trailingAccessory: .icon(.circleAlert, size: .large) - ) - ), - onTap: { [weak viewModel] in - switch state.loadingState { - case .loading: - viewModel?.showLoadingModal( - from: .updatePlan, - title: "proAccessLoading" - .put(key: "pro", value: Constants.pro) - .localized(), - description: "proAccessLoadingDescription" - .put(key: "pro", value: Constants.pro) - .localized() - ) - case .error: - viewModel?.showErrorModal( - from: .updatePlan, - title: "proAccessError" - .put(key: "pro", value: Constants.pro) - .localized(), - description: "proAccessNetworkLoadError" - .put(key: "pro", value: Constants.pro) - .put(key: "app_name", value: Constants.app_name) - .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) - ) - case .success: - viewModel?.updateProPlan() - } + } + ) + ] + + case (.active, .refunding): + initialProSettingsElements = [ + SessionListScreenContent.ListItemInfo( + id: .refundRequested, + variant: .cell( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( + "proRequestedRefund".localized(), + font: .Headings.H8 + ), + description: SessionListScreenContent.TextInfo( + font: .Body.smallRegular, + attributedString: "processingRefundRequest" + .put(key: "platform", value: state.proState.originatingPlatform.platform) + .localizedFormatted(Fonts.Body.smallRegular) + ), + trailingAccessory: .icon(.circleAlert, size: .large) + ) + ), + onTap: { [weak viewModel] in + switch state.proState.loadingState { + case .success: viewModel?.updateProPlan(state: state) + case .loading: + viewModel?.showLoadingModal( + from: .updatePlan, + title: "proAccessLoading" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "proAccessLoadingDescription" + .put(key: "pro", value: Constants.pro) + .localized() + ) + + case .error: + viewModel?.showErrorModal( + from: .updatePlan, + title: "proAccessError" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "proAccessNetworkLoadError" + .put(key: "pro", value: Constants.pro) + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) + ) } - ) - } - }(), + } + ) + ] + } + + return initialProSettingsElements + [ SessionListScreenContent.ListItemInfo( id: .proBadge, variant: .cell( - info: .init( - title: .init("proBadge".put(key: "pro", value: Constants.pro).localized(), font: .Headings.H8), - description: .init("proBadgeVisible".put(key: "app_pro", value: Constants.app_pro).localized(), font: .Body.smallRegular), + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( + "proBadge" + .put(key: "pro", value: Constants.pro) + .localized(), + font: .Headings.H8 + ), + description: SessionListScreenContent.TextInfo( + "proBadgeVisible" + .put(key: "app_pro", value: Constants.app_pro) + .localized(), + font: .Body.smallRegular + ), trailingAccessory: .toggle( - state.isProBadgeEnabled, - oldValue: previousState.isProBadgeEnabled + state.profile.proFeatures.contains(.proBadge), + oldValue: previousState.profile.proFeatures.contains(.proBadge) ) ) ), onTap: { [dependencies = viewModel.dependencies] in - dependencies.setAsync(.isProBadgeEnabled, !state.isProBadgeEnabled) + Task.detached(priority: .userInitiated) { + try? await Profile.updateLocal( + proFeatures: (state.profile.proFeatures.contains(.proBadge) ? + state.profile.proFeatures.removing(.proBadge) : + state.profile.proFeatures.inserting(.proBadge) + ), + using: dependencies + ) + } } ) - ].compactMap { $0 } + ] } // MARK: - Pro Management Elements private static func getProManagementElements( - state: ViewModelState, + state: State, viewModel: SessionProSettingsViewModel ) -> [SessionListScreenContent.ListItemInfo] { - return switch state.currentProPlanState { - case .none: - [ + switch (state.proState.status, state.proState.refundingStatus) { + case (.active, .refunding): return [] + case (.neverBeenPro, _): + return [ SessionListScreenContent.ListItemInfo( id: .recoverPlan, variant: .cell( - info: .init( - title: .init( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( "proAccessRecover" .put(key: "pro", value: Constants.pro) .localized(), @@ -849,27 +952,20 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in - Task { - await viewModel? - .dependencies[singleton: .sessionProState] - .recoverPro { [weak viewModel] result in - DispatchQueue.main.async { - viewModel?.recoverProPlanCompletionHandler(result) - } - } - } - } + onTap: { [weak viewModel] in viewModel?.recoverProPlan() } ) ] - case .active(_, _, let isAutoRenewing, _): - [ - !isAutoRenewing ? nil : + + case (.active, .notRefunding): + var renewingItems: [SessionListScreenContent.ListItemInfo] = [] + + if state.proState.autoRenewing { + renewingItems.append( SessionListScreenContent.ListItemInfo( id: .cancelPlan, variant: .cell( - info: .init( - title: .init( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( "cancelAccess" .put(key: "pro", value: Constants.pro) .localized(), @@ -879,67 +975,77 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType trailingAccessory: .icon(.circleX, size: .large, customTint: .danger) ) ), - onTap: { [weak viewModel] in viewModel?.cancelPlan() } - ), + onTap: { [weak viewModel] in viewModel?.cancelPlan(state: state) } + ) + ) + } + + return renewingItems + [ SessionListScreenContent.ListItemInfo( id: .requestRefund, variant: .cell( - info: .init( - title: .init("requestRefund".localized(), font: .Headings.H8, color: .danger), + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( + "requestRefund".localized(), + font: .Headings.H8, + color: .danger + ), trailingAccessory: .icon(.circleAlert, size: .large, customTint: .danger) ) ), - onTap: { [weak viewModel] in viewModel?.requestRefund() } + onTap: { [weak viewModel] in viewModel?.requestRefund(state: state) } ) - ].compactMap { $0 } - case .expired: - [ + ] + + case (.expired, _): + return [ SessionListScreenContent.ListItemInfo( id: .renewPlan, variant: .cell( - info: .init( - title: .init( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( "proAccessRenew" .put(key: "pro", value: Constants.pro) .localized(), font: .Headings.H8, - color: state.loadingState == .success ? .sessionButton_text : .textPrimary + color: state.proState.loadingState == .success ? .primary : .textPrimary ), description: { - switch state.loadingState { + switch state.proState.loadingState { + case .success: return nil case .error: - return .init( + return SessionListScreenContent.TextInfo( font: .Body.smallRegular, attributedString: "errorCheckingProStatus" .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.smallRegular), color: .warning ) + case .loading: - return .init( + return SessionListScreenContent.TextInfo( font: .Body.smallRegular, attributedString: "checkingProStatusEllipsis" .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.smallRegular), color: .textPrimary ) - case .success: - return nil } }(), trailingAccessory: ( - state.loadingState == .loading ? + state.proState.loadingState == .loading ? .loadingIndicator(size: .large) : .icon( .circlePlus, size: .large, - customTint: state.loadingState == .success ? .sessionButton_text : .textPrimary + customTint: state.proState.loadingState == .success ? .primary : .textPrimary ) ) ) ), onTap: { [weak viewModel] in - switch state.loadingState { + switch state.proState.loadingState { + case .success: viewModel?.updateProPlan(state: state) case .loading: viewModel?.showLoadingModal( from: .renewPlan, @@ -950,6 +1056,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .put(key: "pro", value: Constants.pro) .localized() ) + case .error: viewModel?.showErrorModal( from: .updatePlan, @@ -961,16 +1068,14 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .put(key: "app_name", value: Constants.app_name) .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) ) - case .success: - viewModel?.updateProPlan() } } ), SessionListScreenContent.ListItemInfo( id: .recoverPlan, variant: .cell( - info: .init( - title: .init( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( "proAccessRecover" .put(key: "pro", value: Constants.pro) .localized(), @@ -984,20 +1089,9 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in - Task { - await viewModel? - .dependencies[singleton: .sessionProState] - .recoverPro { [weak viewModel] result in - DispatchQueue.main.async { - viewModel?.recoverProPlanCompletionHandler(result) - } - } - } - } + onTap: { [weak viewModel] in viewModel?.recoverProPlan() } ) ] - case .refunding: [] } } } @@ -1068,34 +1162,31 @@ extension SessionProSettingsViewModel { confirmStyle: .alert_text, cancelTitle: "helpSupport".localized(), cancelStyle: .alert_text, - onConfirm: { [dependencies = self.dependencies] _ in - dependencies.set( - feature: .mockCurrentUserSessionProLoadingState, - to: .loading - ) + onConfirm: { [dependencies] _ in + Task.detached(priority: .userInitiated) { + try? await dependencies[singleton: .sessionProManager].refreshProState() + } }, - onCancel: { [weak self] _ in - self?.openUrl(Constants.session_pro_support_url) - } + onCancel: { [weak self] _ in self?.openUrl(Constants.urls.support) } ) ) self.transitionToScreen(modal, transitionType: .present) } - func updateProPlan() { - let paymentScreen = SessionProPaymentScreen( + @MainActor func updateProPlan(state: State) { + let paymentScreen: SessionProPaymentScreen = SessionProPaymentScreen( viewModel: SessionProPaymentScreenContent.ViewModel( - dependencies: dependencies, - dataModel: .init( - flow: dependencies[singleton: .sessionProState].sessionProStateSubject.value.toPaymentFlow(using: dependencies), - plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } + dataModel: SessionProPaymentScreenContent.DataModel( + flow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow(state: state.proState), + plans: state.proState.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ), - isFromBottomSheet: isInBottomSheet + isFromBottomSheet: state.isInBottomSheet, + using: dependencies ) ) - guard !isInBottomSheet else { + guard !state.isInBottomSheet else { self.transitionToScreen(paymentScreen, transitionType: .push) return } @@ -1103,94 +1194,112 @@ extension SessionProSettingsViewModel { self.transitionToScreen(SessionHostingViewController(rootView: paymentScreen)) } - @MainActor func recoverProPlanCompletionHandler(_ result: Bool) { - let modal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: ( - result ? - "proAccessRestored" - .put(key: "pro", value: Constants.pro) - .localized() : - "proAccessNotFound" - .put(key: "pro", value: Constants.pro) - .localized() - ), - body: .text( - ( - result ? - "proAccessRestoredDescription" - .put(key: "app_name", value: Constants.app_name) - .put(key: "pro", value: Constants.pro) - .localized() : - "proAccessNotFoundDescription" - .put(key: "app_name", value: Constants.app_name) - .put(key: "pro", value: Constants.pro) - .localized() - ), - scrollMode: .never - ), - confirmTitle: (result ? nil : "helpSupport".localized()), - cancelTitle: (result ? "okay".localized() : "close".localized()), - cancelStyle: (result ? .textPrimary : .danger), - dismissOnConfirm: false, - onConfirm: { [weak self] modal in - guard result == false else { - return modal.dismiss(animated: true) - } + @MainActor func recoverProPlan() { + Task.detached(priority: .userInitiated) { [weak self, manager = dependencies[singleton: .sessionProManager]] in + try? await manager.refreshProState() + + let state: SessionPro.State = manager.currentUserCurrentProState + + await MainActor.run { [weak self] in + let modal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: { + switch state.status { + case .active: + return "proAccessRestored" + .put(key: "pro", value: Constants.pro) + .localized() + + case .neverBeenPro, .expired: + return "proAccessNotFound" + .put(key: "pro", value: Constants.pro) + .localized() + } + }(), + body: { + switch state.status { + case .active: + return .text( + "proAccessRestoredDescription" + .put(key: "app_name", value: Constants.app_name) + .put(key: "pro", value: Constants.pro) + .localized(), + scrollMode: .never + ) + + case .neverBeenPro, .expired: + return .text( + "proAccessNotFoundDescription" + .put(key: "app_name", value: Constants.app_name) + .put(key: "pro", value: Constants.pro) + .localized(), + scrollMode: .never + ) + } + }(), + confirmTitle: (state.status == .active ? nil : "helpSupport".localized()), + cancelTitle: (state.status == .active ? "okay".localized() : "close".localized()), + cancelStyle: (state.status == .active ? .textPrimary : .danger), + dismissOnConfirm: false, + onConfirm: { [weak self] modal in + guard state.status != .active else { + return modal.dismiss(animated: true) + } + + self?.openUrl(Constants.urls.proAccessNotFound) + } + ) + ) - self?.openUrl(Constants.session_pro_recovery_support_url) - } - ) - ) - - self.transitionToScreen(modal, transitionType: .present) + self?.transitionToScreen(modal, transitionType: .present) + } + } } - func cancelPlan() { + func cancelPlan(state: State) { let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionProPaymentScreen( viewModel: SessionProPaymentScreenContent.ViewModel( - dependencies: dependencies, - dataModel: .init( - flow: .cancel( - originatingPlatform: { - switch dependencies[singleton: .sessionProState].sessionProStateSubject.value.originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android - } - }() - ), - plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } + dataModel: SessionProPaymentScreenContent.DataModel( + flow: .cancel(originatingPlatform: state.proState.originatingPlatform), + plans: state.proState.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ), - isFromBottomSheet: false + isFromBottomSheet: false, + using: dependencies ) ) ) self.transitionToScreen(viewController) } - func requestRefund() { + func requestRefund(state: State) { let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionProPaymentScreen( viewModel: SessionProPaymentScreenContent.ViewModel( - dependencies: dependencies, - dataModel: .init( + dataModel: SessionProPaymentScreenContent.DataModel( flow: .refund( - originatingPlatform: { - switch dependencies[singleton: .sessionProState].sessionProStateSubject.value.originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android - } - }(), - isNonOriginatingAccount: dependencies[feature: .mockNonOriginatingAccount], // TODO: [PRO] Get the real state if not originator + originatingPlatform: state.proState.originatingPlatform, + isNonOriginatingAccount: (state.proState.originatingAccount == .nonOriginatingAccount), requestedAt: nil ), - plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } + plans: state.proState.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ), - isFromBottomSheet: false + isFromBottomSheet: false, + using: dependencies ) ) ) self.transitionToScreen(viewController) } } + +// MARK: - Convenience + +private extension ObservedEvent { + var handlingStrategy: EventHandlingStrategy { + switch (key, key.generic) { + case (.anyConversationPinnedPriorityChanged, _): return .databaseQuery + default: return .directCacheUpdate + } + } +} diff --git a/SessionMessagingKit/SessionPro/SessionProState+Models.swift b/SessionMessagingKit/SessionPro/SessionProState+Models.swift deleted file mode 100644 index 97c03f4206..0000000000 --- a/SessionMessagingKit/SessionPro/SessionProState+Models.swift +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import SessionUIKit -import SessionUtilitiesKit -import Combine - -public extension SessionProPlanState { - func toPaymentFlow(using dependencies: Dependencies) -> SessionProPaymentScreenContent.SessionProPlanPaymentFlow { - switch self { - case .none: - return .purchase( - billingAccess: !dependencies[feature: .mockInstalledFromIPA] - ) - case .active(let currentPlan, let expiredOn, let isAutoRenewing, let originatingPlatform): - return .update( - currentPlan: currentPlan.info(), - expiredOn: expiredOn, - isAutoRenewing: isAutoRenewing, - originatingPlatform: { - switch originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android - } - }(), - isNonOriginatingAccount: dependencies[feature: .mockNonOriginatingAccount], - billingAccess: !dependencies[feature: .mockInstalledFromIPA] - ) - case .expired(_, let originatingPlatform): - return .renew( - originatingPlatform: { - switch originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android - } - }(), - billingAccess: !dependencies[feature: .mockInstalledFromIPA] - ) - case .refunding(let originatingPlatform, let requestedAt): - return .refund( - originatingPlatform: { - switch originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android - } - }(), - isNonOriginatingAccount: dependencies[feature: .mockNonOriginatingAccount], - requestedAt: requestedAt - ) - } - } -} - -public extension SessionProPlan { - func info() -> SessionProPaymentScreenContent.SessionProPlanInfo { - let price: Double = self.variant.price - let pricePerMonth: Double = (self.variant.price / Double(self.variant.duration)) - return .init( - duration: self.variant.duration, - totalPrice: price, - pricePerMonth: pricePerMonth, - discountPercent: self.variant.discountPercent, - titleWithPrice: { - switch self.variant { - case .oneMonth: - return "proPriceOneMonth" - .put(key: "monthly_price", value: pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true))) - .localized() - case .threeMonths: - return "proPriceThreeMonths" - .put(key: "monthly_price", value: pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true))) - .localized() - case .twelveMonths: - return "proPriceTwelveMonths" - .put(key: "monthly_price", value: pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true))) - .localized() - } - }(), - subtitleWithPrice: { - switch self.variant { - case .oneMonth: - return "proBilledMonthly" - .put(key: "price", value: price.formatted(format: .currency(decimal: true, withLocalSymbol: true))) - .localized() - case .threeMonths: - return "proBilledQuarterly" - .put(key: "price", value: price.formatted(format: .currency(decimal: true, withLocalSymbol: true))) - .localized() - case .twelveMonths: - return "proBilledAnnually" - .put(key: "price", value: price.formatted(format: .currency(decimal: true, withLocalSymbol: true))) - .localized() - } - }() - ) - } - - static func from(_ info: SessionProPaymentScreenContent.SessionProPlanInfo) -> SessionProPlan { - let variant: SessionProPlan.Variant = { - switch info.duration { - case 1: return .oneMonth - case 3: return .threeMonths - case 12: return .twelveMonths - default: fatalError("Unhandled SessionProPlan.Variant.Duration case") - } - }() - - return SessionProPlan(variant: variant) - } -} diff --git a/SessionMessagingKit/SessionPro/SessionProState.swift b/SessionMessagingKit/SessionPro/SessionProState.swift deleted file mode 100644 index dfe63eb671..0000000000 --- a/SessionMessagingKit/SessionPro/SessionProState.swift +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import SessionUIKit -import SessionUtilitiesKit -import Combine - -// MARK: - Singleton - -public extension Singleton { - static let sessionProState: SingletonConfig = Dependencies.create( - identifier: "sessionProState", - createInstance: { dependencies in SessionProState(using: dependencies) } - ) -} - -// MARK: - SessionProState - -public class SessionProState: SessionProManagerType, ProfilePictureAnimationManagerType { - public let dependencies: Dependencies - public var sessionProStateSubject: CurrentValueSubject - public var sessionProStatePublisher: AnyPublisher { - sessionProStateSubject - .compactMap { $0 } - .eraseToAnyPublisher() - } - public var isSessionProActivePublisher: AnyPublisher { - sessionProStateSubject - .compactMap { - switch $0 { - case .active, .refunding: return true - case .none, .expired: return false - } - } - .eraseToAnyPublisher() - } - public var isSessionProExpired: Bool { - switch sessionProStateSubject.value { - case .expired: return true - default: return false - } - } - public var sessionProPlans: [SessionProPlan] { - dependencies[feature: .mockInstalledFromIPA] ? [] : SessionProPlan.Variant.allCases.map { SessionProPlan(variant: $0) } - } - - public var shouldAnimateImageSubject: CurrentValueSubject - public var shouldAnimateImagePublisher: AnyPublisher { - shouldAnimateImageSubject - .compactMap { $0 } - .eraseToAnyPublisher() - } - - public init(using dependencies: Dependencies) { - self.dependencies = dependencies - // TODO: [PRO] Get the pro state of current user - let originatingPlatform: ClientPlatform = dependencies[feature: .proPlanOriginatingPlatform] - let expiryInSeconds = dependencies[feature: .mockCurrentUserSessionProExpiry].durationInSeconds ?? 3 * 30 * 24 * 60 * 60 - switch dependencies[feature: .mockCurrentUserSessionProState] { - case .none: - self.sessionProStateSubject = CurrentValueSubject(SessionProPlanState.none) - case .active: - self.sessionProStateSubject = CurrentValueSubject( - SessionProPlanState.active( - currentPlan: SessionProPlan(variant: .threeMonths), - expiredOn: Calendar.current.date(byAdding: .second, value: Int(expiryInSeconds), to: Date())!, - isAutoRenewing: true, - originatingPlatform: originatingPlatform - ) - ) - case .expiring: - self.sessionProStateSubject = CurrentValueSubject( - SessionProPlanState.active( - currentPlan: SessionProPlan(variant: .threeMonths), - expiredOn: Calendar.current.date(byAdding: .second, value: Int(expiryInSeconds), to: Date())!, - isAutoRenewing: false, - originatingPlatform: originatingPlatform - ) - ) - case .expired: - self.sessionProStateSubject = CurrentValueSubject( - SessionProPlanState.expired( - expiredOn: Date(), - originatingPlatform: originatingPlatform - ) - ) - case .refunding: - self.sessionProStateSubject = CurrentValueSubject( - SessionProPlanState.refunding( - originatingPlatform: originatingPlatform, - requestedAt: Calendar.current.date(byAdding: .hour, value: -1, to: Date())! - ) - ) - } - - self.shouldAnimateImageSubject = CurrentValueSubject( - dependencies[cache: .libSession].isSessionPro - ) - } - - public func upgradeToPro(plan: SessionProPlan, originatingPlatform: ClientPlatform, completion: ((_ result: Bool) -> Void)?) async { - // TODO: [PRO] Upgrade to Pro - Task { - try await Task.sleep(for: .seconds(5)) - dependencies[defaults: .standard, key: .hasShownProExpiringCTA] = false - dependencies[defaults: .standard, key: .hasShownProExpiredCTA] = false - dependencies.set(feature: .mockCurrentUserSessionProState, to: .active) - let expiryInSeconds = dependencies[feature: .mockCurrentUserSessionProExpiry].durationInSeconds ?? TimeInterval(plan.variant.duration) * 30 * 24 * 60 * 60 - self.sessionProStateSubject.send( - SessionProPlanState.active( - currentPlan: plan, - expiredOn: Calendar.current.date(byAdding: .second, value: Int(expiryInSeconds), to: Date())!, - isAutoRenewing: true, - originatingPlatform: originatingPlatform - ) - ) - self.shouldAnimateImageSubject.send(true) - dependencies.setAsync(.isProBadgeEnabled, true) - completion?(true) - } - } - - public func cancelPro(completion: ((_ result: Bool) -> Void)?) async { - // TODO: [PRO] Cancel Pro: This is more like just cancel subscription - guard case .active(let currentPlan, let expiredOn, _, let originatingPlatform) = self.sessionProStateSubject.value else { - return - } - dependencies.set(feature: .mockCurrentUserSessionProState, to: .expiring) - self.sessionProStateSubject.send( - SessionProPlanState.active( - currentPlan: currentPlan, - expiredOn: expiredOn, - isAutoRenewing: false, - originatingPlatform: originatingPlatform - ) - ) - self.shouldAnimateImageSubject.send(true) - completion?(true) - } - - public func requestRefund(completion: ((_ result: Bool) -> Void)?) async { - // TODO: [PRO] Request refund - dependencies.set(feature: .mockCurrentUserSessionProState, to: .refunding) - self.sessionProStateSubject.send( - SessionProPlanState.refunding( - originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], - requestedAt: Date() - ) - ) - self.shouldAnimateImageSubject.send(true) - completion?(true) - } - - public func expirePro(completion: ((_ result: Bool) -> Void)?) async { - // TODO: [PRO] Mannualy expire pro state, maybe just for QA as we have backend to determine if pro is expired - dependencies.set(feature: .mockCurrentUserSessionProState, to: .expired) - self.sessionProStateSubject.send( - SessionProPlanState.expired( - expiredOn: Date(), - originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform] - ) - ) - self.shouldAnimateImageSubject.send(false) - completion?(true) - } - - public func recoverPro(completion: ((_ result: Bool) -> Void)?) async { - // TODO: [PRO] Recover from an existing pro plan - guard dependencies[feature: .proPlanToRecover] == true && dependencies[feature: .mockCurrentUserSessionProLoadingState] == .success else { - completion?(false) - return - } - await upgradeToPro( - plan: SessionProPlan(variant: .threeMonths), - originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], - completion: completion - ) - } - - // These functions are only for QA purpose - public func updateOriginatingPlatform(_ newValue: ClientPlatform) { - self.sessionProStateSubject.send( - self.sessionProStateSubject.value - .with(originatingPlatform: newValue) - ) - } - - public func updateProExpiry(_ expiryInSeconds: TimeInterval?) { - guard case .active(let currentPlan, _, let isAutoRenewing, let originatingPlatform) = self.sessionProStateSubject.value else { - return - } - let expiryInSeconds = expiryInSeconds ?? TimeInterval(currentPlan.variant.duration * 30 * 24 * 60 * 60) - - self.sessionProStateSubject.send( - SessionProPlanState.active( - currentPlan: currentPlan, - expiredOn: Calendar.current.date(byAdding: .second, value: Int(expiryInSeconds), to: Date())!, - isAutoRenewing: isAutoRenewing, - originatingPlatform: originatingPlatform - ) - ) - } -} - -// MARK: - SessionProCTAManagerType - -extension SessionProState: SessionProCTAManagerType { - @discardableResult @MainActor public func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - dismissType: Modal.DismissType, - onConfirm: (() -> Void)?, - onCancel: (() -> Void)?, - afterClosed: (() -> Void)?, - presenting: ((UIViewController) -> Void)? - ) -> Bool { - let shouldShowProCTA: Bool = { - guard dependencies[feature: .sessionProEnabled] else { return false } - switch variant { - case .expiring, .groupLimit: - return true - default: - switch sessionProStateSubject.value { - case .active, .refunding: return false - case .none, .expired: return true - } - } - }() - - guard shouldShowProCTA else { - return false - } - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - variant: variant, - dataManager: dependencies[singleton: .imageDataManager], - dismissType: dismissType, - afterClosed: afterClosed, - onConfirm: onConfirm, - onCancel: onCancel - ) - ) - presenting?(sessionProModal) - - return true - } - - @MainActor public func showSessionProBottomSheetIfNeeded( - afterClosed: (() -> Void)?, - presenting: ((UIViewController) -> Void)? - ) { - let viewModel = SessionProSettingsViewModel(isInBottomSheet: true, using: dependencies) - let sessionProBottomSheet: BottomSheetHostingViewController = BottomSheetHostingViewController( - bottomSheet: BottomSheet( - hasCloseButton: true, - afterClosed: afterClosed - ) { - SessionListScreen(viewModel: viewModel) - } - ) - presenting?(sessionProBottomSheet) - } -} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProConfig.swift b/SessionMessagingKit/SessionPro/Types/SessionProConfig.swift new file mode 100644 index 0000000000..5011307775 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProConfig.swift @@ -0,0 +1,38 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionNetworkingKit +import SessionUtilitiesKit + +public extension SessionPro { + struct ProConfig { + let rotatingPrivateKey: [UInt8] + let proProof: Network.SessionPro.ProProof + + var libSessionValue: pro_pro_config { + var config: pro_pro_config = pro_pro_config() + config.set(\.rotating_privkey, to: rotatingPrivateKey) + config.proof = proProof.libSessionValue + + return config + } + + // MARK: - Initialization + + init( + rotatingPrivateKey: [UInt8], + proProof: Network.SessionPro.ProProof + ) { + self.rotatingPrivateKey = rotatingPrivateKey + self.proProof = proProof + } + + init(_ libSessionValue: pro_pro_config) { + rotatingPrivateKey = libSessionValue.get(\.rotating_privkey) + proProof = Network.SessionPro.ProProof(libSessionValue.proof) + } + } +} + +extension pro_pro_config: @retroactive CAccessible & CMutable {} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift new file mode 100644 index 0000000000..88af8ee627 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift @@ -0,0 +1,35 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionNetworkingKit + +public extension SessionPro { + struct DecodedProForMessage: Sendable, Codable, Equatable { + let status: SessionPro.DecodedStatus? + let proProof: Network.SessionPro.ProProof + let messageFeatures: MessageFeatures + let profileFeatures: ProfileFeatures + + // MARK: - Initialization + + init( + status: SessionPro.DecodedStatus?, + proProof: Network.SessionPro.ProProof, + messageFeatures: MessageFeatures, + profileFeatures: ProfileFeatures + ) { + self.status = status + self.proProof = proProof + self.messageFeatures = messageFeatures + self.profileFeatures = profileFeatures + } + + init(_ libSessionValue: session_protocol_decoded_pro) { + status = SessionPro.DecodedStatus(libSessionValue.status) + proProof = Network.SessionPro.ProProof(libSessionValue.proof) + messageFeatures = MessageFeatures(libSessionValue.msg_bitset) + profileFeatures = ProfileFeatures(libSessionValue.profile_bitset) + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProDecodedStatus.swift b/SessionMessagingKit/SessionPro/Types/SessionProDecodedStatus.swift new file mode 100644 index 0000000000..0bd9bc99ff --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProDecodedStatus.swift @@ -0,0 +1,27 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension SessionPro { + enum DecodedStatus: Sendable, Codable, CaseIterable { + case invalidProBackendSig + case invalidUserSig + case valid + case expired + + public init?(_ libSessionValue: SESSION_PROTOCOL_PRO_STATUS) { + switch libSessionValue { + case SESSION_PROTOCOL_PRO_STATUS_NIL: return nil + case SESSION_PROTOCOL_PRO_STATUS_INVALID_PRO_BACKEND_SIG: self = .invalidProBackendSig + case SESSION_PROTOCOL_PRO_STATUS_INVALID_USER_SIG: self = .invalidUserSig + case SESSION_PROTOCOL_PRO_STATUS_VALID: self = .valid + case SESSION_PROTOCOL_PRO_STATUS_EXPIRED: self = .expired + default: return nil + } + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProError.swift b/SessionMessagingKit/SessionPro/Types/SessionProError.swift new file mode 100644 index 0000000000..66ca9da736 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProError.swift @@ -0,0 +1,47 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +public enum SessionProError: Error, CustomStringConvertible { + case productNotFound + case transactionNotFound + case purchaseCancelled + case refundCancelled + case windowSceneRequired + case failedToShowStoreKitUI(String) + + case purchaseFailed(String) + case refundFailed(String) + case generateProProofFailed(String) + case getProDetailsFailed(String) + case getProRevocationsFailed(String) + + case noLatestPaymentItem + case refundAlreadyRequestedForLatestPayment + case nonOriginatedLatestPayment + + case unhandledBehaviour + + public var description: String { + switch self { + case .productNotFound: return "The request product was not found." + case .transactionNotFound: return "The transaction was not found." + case .purchaseCancelled: return "The purchase was cancelled." + case .refundCancelled: return "The refund was cancelled." + case .windowSceneRequired: return "A window scene is required to present the UI." + case .failedToShowStoreKitUI(let screen): return "Failed to show StoreKit UI: \(screen)." + + case .purchaseFailed(let error): return "The purchase failed due to error: \(error)." + case .refundFailed(let error): return "The refund failed due to error: \(error)." + case .generateProProofFailed(let error): return "Failed to generate the pro proof due to error: \(error)." + case .getProDetailsFailed(let error): return "Failed to get pro details due to error: \(error)." + case .getProRevocationsFailed(let error): return "Failed to retrieve the latest pro revocations due to error: \(error)." + + case .noLatestPaymentItem: return "No latest payment item." + case .refundAlreadyRequestedForLatestPayment: return "Refund already requested for latest payment" + case .nonOriginatedLatestPayment: return "Latest payment wasn't originated from an Apple device" + + case .unhandledBehaviour: return "Unhandled behaviour." + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeatureStatus.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeatureStatus.swift new file mode 100644 index 0000000000..55867939fe --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProFeatureStatus.swift @@ -0,0 +1,29 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension SessionPro { + enum FeatureStatus: Equatable { + case success + case utfDecodingError + case exceedsCharacterLimit + + var libSessionValue: SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS { + switch self { + case .success: return SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS + case .utfDecodingError: return SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_UTF_DECODING_ERROR + case .exceedsCharacterLimit: return SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_EXCEEDS_CHARACTER_LIMIT + } + } + + init(_ libSessionValue: SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS) { + switch libSessionValue { + case SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS: self = .success + case SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_UTF_DECODING_ERROR: self = .utfDecodingError + case SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_EXCEEDS_CHARACTER_LIMIT: self = .exceedsCharacterLimit + default: self = .utfDecodingError + } + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift new file mode 100644 index 0000000000..e7449ff5d6 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift @@ -0,0 +1,34 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension SessionPro { + struct FeaturesForMessage: Equatable { + public let status: FeatureStatus + public let error: String? + public let features: MessageFeatures + public let codePointCount: Int + + static let invalidString: FeaturesForMessage = FeaturesForMessage(status: .utfDecodingError) + + // MARK: - Initialization + + init(status: FeatureStatus, error: String? = nil, features: MessageFeatures = [], codePointCount: Int = 0) { + self.status = status + self.error = error + self.features = features + self.codePointCount = codePointCount + } + + init(_ libSessionValue: session_protocol_pro_features_for_msg) { + status = FeatureStatus(libSessionValue.status) + error = libSessionValue.get(\.error, nullIfEmpty: true) + features = MessageFeatures(libSessionValue.bitset) + codePointCount = libSessionValue.codepoint_count + } + } +} + +extension session_protocol_pro_features_for_msg: @retroactive CAccessible {} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProLoadingState.swift b/SessionMessagingKit/SessionPro/Types/SessionProLoadingState.swift new file mode 100644 index 0000000000..64add28d11 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProLoadingState.swift @@ -0,0 +1,22 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUtilitiesKit + +public extension SessionPro { + enum LoadingState: Sendable, CaseIterable, Equatable, CustomStringConvertible { + case loading + case error + case success + + public var description: String { + switch self { + case .loading: return "Loading" + case .error: return "Error" + case .success: return "Success" + } + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProMessageFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProMessageFeatures.swift new file mode 100644 index 0000000000..cf03fd152a --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProMessageFeatures.swift @@ -0,0 +1,48 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension SessionPro { + struct MessageFeatures: OptionSet, Sendable, Codable, Equatable, Hashable, CustomStringConvertible { + public let rawValue: UInt64 + + public static let none: MessageFeatures = MessageFeatures(rawValue: 0) + public static let largerCharacterLimit: MessageFeatures = MessageFeatures(rawValue: 1 << 0) + public static let all: MessageFeatures = [ largerCharacterLimit ] + + var libSessionValue: session_protocol_pro_message_bitset { + var result: session_protocol_pro_message_bitset = session_protocol_pro_message_bitset() + result.data = rawValue + + return result + } + + var profileOnlyFeatures: MessageFeatures { + self.subtracting(.largerCharacterLimit) + } + + // MARK: - Initialization + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public init(_ libSessionValue: session_protocol_pro_message_bitset) { + self = MessageFeatures(rawValue: libSessionValue.data) + } + + // MARK: - CustomStringConvertible + + // stringlint:ignore_contents + public var description: String { + var results: [String] = [] + + if self.contains(.largerCharacterLimit) { + results.append("largerCharacterLimit") + } + + return "[\(results.joined(separator: ", "))]" + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProOriginatingAccount.swift b/SessionMessagingKit/SessionPro/Types/SessionProOriginatingAccount.swift new file mode 100644 index 0000000000..4de126d0a1 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProOriginatingAccount.swift @@ -0,0 +1,26 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension SessionPro { + enum OriginatingAccount: Sendable, Equatable, Hashable, CaseIterable, CustomStringConvertible, ExpressibleByBooleanLiteral { + case originatingAccount + case nonOriginatingAccount + + public init(booleanLiteral value: Bool) { + self = (value ? .originatingAccount : .nonOriginatingAccount) + } + + public init(_ value: Bool) { + self = OriginatingAccount(booleanLiteral: value) + } + + // stringlint:ignore_contents + public var description: String { + switch self { + case .originatingAccount: return "Originating Account" + case .nonOriginatingAccount: return "Non-originating Account" + } + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift b/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift new file mode 100644 index 0000000000..a8872b7512 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift @@ -0,0 +1,155 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import StoreKit +import SessionUIKit +import SessionNetworkingKit +import SessionUtilitiesKit + +public extension SessionPro { + struct Plan: Sendable, Equatable, Hashable { + // stringlint:ignore_contents + private static let productIds: [String] = [ + "com.getsession.org.pro_sub_1_month", + "com.getsession.org.pro_sub_3_months", + "com.getsession.org.pro_sub_12_months" + ] + + public let id: String + public let variant: Network.SessionPro.Plan + public let durationMonths: Int + public let price: Decimal + public let pricePerMonth: Decimal + public let discountPercent: Int? + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.variant == rhs.variant + } + + // MARK: - Functions + + public static func retrieveProductsAndPlans() async throws -> (products: [Product], plans: [Plan]) { +#if targetEnvironment(simulator) + return ( + [], + [ + Plan( + id: "SimId3", // stringlint:ignore + variant: .twelveMonths, + durationMonths: 12, + price: 111, + pricePerMonth: 9.25, + discountPercent: 75 + ), + Plan( + id: "SimId2", // stringlint:ignore + variant: .threeMonths, + durationMonths: 3, + price: 222, + pricePerMonth: 74, + discountPercent: 50 + ), + Plan( + id: "SimId1", // stringlint:ignore + variant: .oneMonth, + durationMonths: 1, + price: 444, + pricePerMonth: 444, + discountPercent: nil + ) + ] + ) +#else + let products: [Product] = try await Product + .products(for: productIds) + .sorted() + .reversed() + + guard let shortestMonthlyPrice: Decimal = products.last.map({ $0.price / Decimal($0.durationMonths) }) else { + return ([], []) + } + + let plans: [Plan] = products.map { product in + let durationMonths: Int = product.durationMonths + let thisMonthlyPrice: Decimal = (product.price / Decimal(durationMonths)) + let monthlySavings: Decimal = (shortestMonthlyPrice - thisMonthlyPrice) + let discountDecimal: Decimal = ((monthlySavings / shortestMonthlyPrice) * 100) + let discount: Int = NSDecimalNumber(decimal: discountDecimal) + .rounding(accordingToBehavior: NSDecimalNumberHandler( + roundingMode: .down, + scale: 0, + raiseOnExactness: false, + raiseOnOverflow: false, + raiseOnUnderflow: false, + raiseOnDivideByZero: false + )) + .intValue + let variant: Network.SessionPro.Plan = { + switch durationMonths { + case 1: return .oneMonth + case 3: return .threeMonths + case 12: return .twelveMonths + default: + Log.error("Received a subscription product with an invalid duration: \(durationMonths), product id: \(product.id)") + return .none + } + }() + + return Plan( + id: product.id, + variant: variant, + durationMonths: durationMonths, + price: product.price, + pricePerMonth: (product.price / Decimal(durationMonths)), + discountPercent: (variant != .oneMonth ? discount : nil) + ) + } + + return (products, plans) +#endif + } + } +} + +// MARK: - Convenience + +extension Product: @retroactive Comparable { + var durationMonths: Int { + guard let subscription: SubscriptionInfo = subscription else { return -1 } + + switch subscription.subscriptionPeriod.unit { + case .day: return (subscription.subscriptionPeriod.value / 30) + case .week: return (subscription.subscriptionPeriod.value / 4) + case .month: return subscription.subscriptionPeriod.value + case .year: return (subscription.subscriptionPeriod.value * 12) + @unknown default: return subscription.subscriptionPeriod.value + } + } + + public static func < (lhs: Product, rhs: Product) -> Bool { + guard + let lhsSubscription: SubscriptionInfo = lhs.subscription, + let rhsSubscription: SubscriptionInfo = rhs.subscription, ( + lhsSubscription.subscriptionPeriod.unit != rhsSubscription.subscriptionPeriod.unit || + lhsSubscription.subscriptionPeriod.value != rhsSubscription.subscriptionPeriod.value + ) + else { return lhs.id < rhs.id } + + func approximateDurationDays(_ subscription: SubscriptionInfo) -> Int { + switch subscription.subscriptionPeriod.unit { + case .day: return subscription.subscriptionPeriod.value + case .week: return subscription.subscriptionPeriod.value * 7 + case .month: return subscription.subscriptionPeriod.value * 30 + case .year: return subscription.subscriptionPeriod.value * 365 + @unknown default: return subscription.subscriptionPeriod.value + } + } + + let lhsApproxDays: Int = approximateDurationDays(lhsSubscription) + let rhsApproxDays: Int = approximateDurationDays(rhsSubscription) + + guard lhsApproxDays != rhsApproxDays else { return lhs.id < rhs.id } + + return (lhsApproxDays < rhsApproxDays) + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProProfileFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProProfileFeatures.swift new file mode 100644 index 0000000000..b20cc1c377 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProProfileFeatures.swift @@ -0,0 +1,48 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension SessionPro { + struct ProfileFeatures: OptionSet, Sendable, Codable, Equatable, Hashable, CustomStringConvertible { + public let rawValue: UInt64 + + public static let none: ProfileFeatures = ProfileFeatures(rawValue: 0) + public static let proBadge: ProfileFeatures = ProfileFeatures(rawValue: 1 << 0) + public static let animatedAvatar: ProfileFeatures = ProfileFeatures(rawValue: 1 << 1) + public static let all: ProfileFeatures = [ proBadge, animatedAvatar ] + + var libSessionValue: session_protocol_pro_profile_bitset { + var result: session_protocol_pro_profile_bitset = session_protocol_pro_profile_bitset() + result.data = rawValue + + return result + } + + // MARK: - Initialization + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public init(_ libSessionValue: session_protocol_pro_profile_bitset) { + self = ProfileFeatures(rawValue: libSessionValue.data) + } + + // MARK: - CustomStringConvertible + + // stringlint:ignore_contents + public var description: String { + var results: [String] = [] + + if self.contains(.proBadge) { + results.append("proBadge") + } + if self.contains(.animatedAvatar) { + results.append("animatedAvatar") + } + + return "[\(results.joined(separator: ", "))]" + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProRefundingStatus.swift b/SessionMessagingKit/SessionPro/Types/SessionProRefundingStatus.swift new file mode 100644 index 0000000000..27f5fad5e0 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProRefundingStatus.swift @@ -0,0 +1,26 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension SessionPro { + enum RefundingStatus: Sendable, Equatable, Hashable, CaseIterable, CustomStringConvertible, ExpressibleByBooleanLiteral { + case notRefunding + case refunding + + public init(booleanLiteral value: Bool) { + self = (value ? .refunding : .notRefunding) + } + + public init(_ value: Bool) { + self = RefundingStatus(booleanLiteral: value) + } + + // stringlint:ignore_contents + public var description: String { + switch self { + case .notRefunding: return "Not Refunding" + case .refunding: return "Refunding" + } + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProState.swift b/SessionMessagingKit/SessionPro/Types/SessionProState.swift new file mode 100644 index 0000000000..706921630a --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProState.swift @@ -0,0 +1,371 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import StoreKit +import SessionUIKit +import SessionNetworkingKit +import SessionUtilitiesKit + +public extension SessionPro { + struct State: Sendable, Equatable, Hashable { + public let sessionProEnabled: Bool + + public let buildVariant: BuildVariant + public let products: [Product] + public let plans: [SessionPro.Plan] + public let entitledTransactions: [Transaction] + + public let loadingState: SessionPro.LoadingState + public let status: Network.SessionPro.BackendUserProStatus + public let proof: Network.SessionPro.ProProof? + public let profileFeatures: SessionPro.ProfileFeatures + + public let autoRenewing: Bool + public let accessExpiryTimestampMs: UInt64? + public let latestPaymentItem: Network.SessionPro.PaymentItem? + public let originatingPlatform: SessionProUI.ClientPlatform + public let originatingAccount: SessionPro.OriginatingAccount + public let refundingStatus: SessionPro.RefundingStatus + } +} + +public extension SessionPro.State { + static let invalid: SessionPro.State = SessionPro.State( + sessionProEnabled: false, + buildVariant: .appStore, + products: [], + plans: [], + entitledTransactions: [], + loadingState: .loading, + status: .neverBeenPro, + proof: nil, + profileFeatures: .none, + autoRenewing: false, + accessExpiryTimestampMs: 0, + latestPaymentItem: nil, + originatingPlatform: .iOS, + originatingAccount: .originatingAccount, + refundingStatus: .notRefunding + ) +} + +internal extension SessionPro.State { + func with( + products: Update<[Product]> = .useExisting, + plans: Update<[SessionPro.Plan]> = .useExisting, + entitledTransactions: Update<[Transaction]> = .useExisting, + loadingState: Update = .useExisting, + status: Update = .useExisting, + proof: Update = .useExisting, + profileFeatures: Update = .useExisting, + autoRenewing: Update = .useExisting, + accessExpiryTimestampMs: Update = .useExisting, + latestPaymentItem: Update = .useExisting, + using dependencies: Dependencies + ) -> SessionPro.State { + let finalBuildVariant: BuildVariant = { + switch dependencies[feature: .mockCurrentUserSessionProBuildVariant] { + case .simulate(let mockedValue): return mockedValue + case .useActual: return BuildVariant.current + } + }() + let finalLoadingState: SessionPro.LoadingState = { + switch dependencies[feature: .mockCurrentUserSessionProLoadingState] { + case .simulate(let mockedValue): return mockedValue + case .useActual: return loadingState.or(self.loadingState) + } + }() + let finalStatus: Network.SessionPro.BackendUserProStatus = { + switch dependencies[feature: .mockCurrentUserSessionProBackendStatus] { + case .simulate(let mockedValue): return mockedValue + case .useActual: return (status.or(self.status)) + } + }() + let finalAccessExpiryTimestampMs: UInt64? = { + let mockedValue: TimeInterval = dependencies[feature: .mockCurrentUserAccessExpiryTimestamp] + + guard mockedValue > 0 else { return accessExpiryTimestampMs.or(self.accessExpiryTimestampMs) } + + return UInt64(mockedValue) + }() + let finalLatestPaymentItem: Network.SessionPro.PaymentItem? = latestPaymentItem.or(self.latestPaymentItem) + let finalOriginatingPlatform: SessionProUI.ClientPlatform = { + switch dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform] { + case .simulate(let mockedValue): return mockedValue + case .useActual: return SessionProUI.ClientPlatform(finalLatestPaymentItem?.paymentProvider) + } + }() + let finalEntitledTransactions: [Transaction] = entitledTransactions.or(self.entitledTransactions) + let finalOriginatingAccount: SessionPro.OriginatingAccount = { + switch dependencies[feature: .mockCurrentUserOriginatingAccount] { + case .simulate(let mockedValue): return mockedValue + case .useActual: + guard let lastPaymentItemAppleTransactionId: String = finalLatestPaymentItem?.appleTransactionId else { + return .nonOriginatingAccount + } + + let transactionIds: Set = Set(finalEntitledTransactions.map { "\($0.id)" }) + + return (transactionIds.contains(lastPaymentItemAppleTransactionId) ? + .originatingAccount : + .nonOriginatingAccount + ) + } + }() + + let finalRefundingStatus: SessionPro.RefundingStatus = { + switch dependencies[feature: .mockCurrentUserSessionProRefundingStatus] { + case .simulate(let mockedValue): return mockedValue + case .useActual: + return SessionPro.RefundingStatus( + finalStatus == .active && + (finalLatestPaymentItem?.refundRequestedTimestampMs ?? 0) > 0 + ) + } + }() + + return SessionPro.State( + sessionProEnabled: dependencies[feature: .sessionProEnabled], + buildVariant: finalBuildVariant, + products: products.or(self.products), + plans: plans.or(self.plans), + entitledTransactions: finalEntitledTransactions, + loadingState: finalLoadingState, + status: finalStatus, + proof: proof.or(self.proof), + profileFeatures: profileFeatures.or(self.profileFeatures), + autoRenewing: autoRenewing.or(self.autoRenewing), + accessExpiryTimestampMs: finalAccessExpiryTimestampMs, + latestPaymentItem: finalLatestPaymentItem, + originatingPlatform: finalOriginatingPlatform, + originatingAccount: finalOriginatingAccount, + refundingStatus: finalRefundingStatus + ) + } +} + +// MARK: - Convenience + +extension SessionProUI.ClientPlatform { + /// The originating platform the latest payment came from + /// + /// **Note:** There may not be a latest payment, in which case we default to `iOS` because we are on an `iOS` device + init(_ provider: Network.SessionPro.PaymentProvider?) { + switch provider { + case .none: self = .iOS + case .appStore: self = .iOS + case .playStore: self = .android + } + } +} + +// MARK: - SessionPro.MockState + +internal extension SessionPro { + struct MockState: ObservableKeyProvider { + struct Info: Sendable, Equatable { + let sessionProEnabled: Bool + let mockBuildVariant: MockableFeature + let mockProLoadingState: MockableFeature + let mockProBackendStatus: MockableFeature + let mockAccessExpiryTimestamp: TimeInterval + let mockOriginatingPlatform: MockableFeature + let mockOriginatingAccount: MockableFeature + let mockRefundingStatus: MockableFeature + } + + let previousInfo: Info? + let info: Info + + var needsRefresh: Bool { + guard let previousInfo else { return false } + + func changedToUseActual( + _ keyPath: KeyPath> + ) -> Bool { + switch (previousInfo[keyPath: keyPath], self.info[keyPath: keyPath]) { + case (.simulate, .useActual): return true + default: return false + } + } + + return ( + (info.sessionProEnabled && !previousInfo.sessionProEnabled) || + changedToUseActual(\.mockBuildVariant) || + changedToUseActual(\.mockProLoadingState) || + changedToUseActual(\.mockProBackendStatus) || + changedToUseActual(\.mockOriginatingPlatform) || + changedToUseActual(\.mockOriginatingAccount) || + changedToUseActual(\.mockRefundingStatus) || + (previousInfo.mockAccessExpiryTimestamp > 0 && info.mockAccessExpiryTimestamp == 0) + ) + } + + let observedKeys: Set = [ + .feature(.sessionProEnabled), + .feature(.mockCurrentUserSessionProBuildVariant), + .feature(.mockCurrentUserSessionProLoadingState), + .feature(.mockCurrentUserSessionProBackendStatus), + .feature(.mockCurrentUserAccessExpiryTimestamp), + .feature(.mockCurrentUserSessionProOriginatingPlatform), + .feature(.mockCurrentUserOriginatingAccount), + .feature(.mockCurrentUserSessionProRefundingStatus) + ] + + init(previousInfo: Info? = nil, using dependencies: Dependencies) { + self.previousInfo = previousInfo + self.info = Info( + sessionProEnabled: dependencies[feature: .sessionProEnabled], + mockBuildVariant: dependencies[feature: .mockCurrentUserSessionProBuildVariant], + mockProLoadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], + mockProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], + mockAccessExpiryTimestamp: dependencies[feature: .mockCurrentUserAccessExpiryTimestamp], + mockOriginatingPlatform: dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform], + mockOriginatingAccount: dependencies[feature: .mockCurrentUserOriginatingAccount], + mockRefundingStatus: dependencies[feature: .mockCurrentUserSessionProRefundingStatus] + ) + } + } +} + + +// MARK: - SessionPro.LoadingState + +public extension FeatureStorage { + static let mockCurrentUserSessionProLoadingState: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProLoadingState" + ) +} + +extension SessionPro.LoadingState: MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .loading: return "The UI state while we are waiting on the network response." + case .error: return "The UI state when there was an error retrieving the users Pro status." + case .success: return "The UI state once we have successfully retrieved the users Pro status." + } + } +} + +// MARK: - Network.SessionPro.BackendUserProStatus + +public extension FeatureStorage { + static let mockCurrentUserSessionProBackendStatus: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProBackendStatus" + ) +} + +extension Network.SessionPro.BackendUserProStatus: @retroactive MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .neverBeenPro: return "The user has never had Session Pro before." + case .active: return "The user has an active Session Pro subscription." + case .expired: return "The user's Session Pro subscription has expired." + } + } +} + +// MARK: - Access Expiry Timestamp + +public extension FeatureStorage { + static let mockCurrentUserAccessExpiryTimestamp: FeatureConfig = Dependencies.create( + identifier: "mockCurrentUserAccessExpiryTimestamp" + ) +} + +// MARK: - SessionProUI.ClientPlatform + +public extension FeatureStorage { + static let mockCurrentUserSessionProOriginatingPlatform: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProOriginatingPlatform" + ) +} + +extension SessionProUI.ClientPlatform: @retroactive CustomStringConvertible { + public var description: String { + switch self { + case .iOS: return Constants.PaymentProvider.appStore.device + case .android: return Constants.PaymentProvider.playStore.device + } + } +} + +extension SessionProUI.ClientPlatform: @retroactive MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .iOS: return "The Session Pro subscription was originally purchased on an iOS device." + case .android: return "The Session Pro subscription was originally purchased on an Android device." + } + } +} + +// MARK: - OriginatingAccount.OriginatingAccount + +public extension FeatureStorage { + static let mockCurrentUserOriginatingAccount: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserOriginatingAccount" + ) +} + +extension SessionPro.OriginatingAccount: MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .originatingAccount: return "The Session Pro subscription was originally purchased on the account currently logged in." + case .nonOriginatingAccount: return "The Session Pro subscription was originally purchased on a different account." + } + } +} + +// MARK: - BuildVariant + +public extension FeatureStorage { + static let mockCurrentUserSessionProBuildVariant: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProBuildVariant" + ) +} + +extension BuildVariant: @retroactive MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .appStore: return "The app was installed via the App Store." + case .development: return "The app is a development build." + case .testFlight: return "The app was installed via TestFlight." + case .ipa: return "The app was installed direcrtly as an IPA." + + case .apk: return "The app was installed directly as an APK." + case .fDroid: return "The app was installed via fDroid." + case .huawei: return "The app is a Huawei build." + } + } +} + +// MARK: - SessionPro.RefundingStatus + +public extension FeatureStorage { + static let mockCurrentUserSessionProRefundingStatus: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProRefundingStatus" + ) +} + +extension SessionPro.RefundingStatus: MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .notRefunding: return "The Session Pro subscription does not currently have a pending refund." + case .refunding: return "The Session Pro subscription currently has a pending refund." + } + } +} diff --git a/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift b/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift new file mode 100644 index 0000000000..175c9bf783 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift @@ -0,0 +1,95 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUIKit +import SessionNetworkingKit + +public extension SessionProPaymentScreenContent.SessionProPlanPaymentFlow { + init(state: SessionPro.State) { + let latestPlan: SessionPro.Plan? = state.plans.first { $0.variant == state.latestPaymentItem?.plan } + let expiryDate: Date? = state.accessExpiryTimestampMs.map { Date(timeIntervalSince1970: floor(Double($0) / 1000)) } + + switch (state.status, latestPlan, state.refundingStatus) { + case (.neverBeenPro, _, _), (.active, .none, _): + self = .purchase(billingAccess: state.buildVariant == .appStore) + + case (.active, .some(let plan), .notRefunding): + self = .update( + currentPlan: SessionProPaymentScreenContent.SessionProPlanInfo(plan: plan), + expiredOn: (expiryDate ?? Date.distantPast), + originatingPlatform: state.originatingPlatform, + isAutoRenewing: (state.autoRenewing == true), + isNonOriginatingAccount: (state.originatingAccount == .nonOriginatingAccount), + billingAccess: (state.buildVariant == .appStore) + ) + + case (.expired, _, _): + self = .renew( + originatingPlatform: state.originatingPlatform, + billingAccess: (state.buildVariant == .appStore) + ) + + case (.active, .some, .refunding): + self = .refund( + originatingPlatform: state.originatingPlatform, + isNonOriginatingAccount: (state.originatingAccount == .nonOriginatingAccount), + requestedAt: (state.latestPaymentItem?.refundRequestedTimestampMs).map { + Date(timeIntervalSince1970: (Double($0) / 1000)) + } + ) + } + } +} + +public extension SessionProPaymentScreenContent.SessionProPlanInfo { + init(plan: SessionPro.Plan) { + let price: Double = Double(truncating: plan.price as NSNumber) + let pricePerMonth: Double = Double(truncating: plan.pricePerMonth as NSNumber) + let formattedPrice: String = price.formatted(format: .currency(decimal: true, withLocalSymbol: true, roundingMode: .floor)) + let formattedPricePerMonth: String = pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true, roundingMode: .floor)) + + self = SessionProPaymentScreenContent.SessionProPlanInfo( + id: plan.id, + duration: plan.durationMonths, + totalPrice: price, + pricePerMonth: pricePerMonth, + discountPercent: plan.discountPercent, + titleWithPrice: { + switch plan.variant { + case .none, .oneMonth: + return "proPriceOneMonth" + .put(key: "monthly_price", value: formattedPricePerMonth) + .localized() + + case .threeMonths: + return "proPriceThreeMonths" + .put(key: "monthly_price", value: formattedPricePerMonth) + .localized() + + case .twelveMonths: + return "proPriceTwelveMonths" + .put(key: "monthly_price", value: formattedPricePerMonth) + .localized() + } + }(), + subtitleWithPrice: { + switch plan.variant { + case .none, .oneMonth: + return "proBilledMonthly" + .put(key: "price", value: formattedPrice) + .localized() + + case .threeMonths: + return "proBilledQuarterly" + .put(key: "price", value: formattedPrice) + .localized() + + case .twelveMonths: + return "proBilledAnnually" + .put(key: "price", value: formattedPrice) + .localized() + } + }() + ) + } +} diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift deleted file mode 100644 index 7eb40f498e..0000000000 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ /dev/null @@ -1,1521 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation -import UniformTypeIdentifiers -import GRDB -import DifferenceKit -import SessionUIKit -import SessionUtilitiesKit - -fileprivate typealias ViewModel = MessageViewModel -fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInteractionInfo -fileprivate typealias ReactionInfo = MessageViewModel.ReactionInfo -fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo - -// TODO: [Database Relocation] Refactor this to split database data from no-database data (to avoid unneeded nullables) -public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case threadId - case threadVariant - case threadIsTrusted - case threadExpirationType - case threadExpirationTimer - case threadOpenGroupServer - case threadOpenGroupPublicKey - case threadContactNameInternal - - // Interaction Info - - case rowId - case id - case serverHash - case openGroupServerMessageId - case variant - case timestampMs - case receivedAtTimestampMs - case authorId - case authorNameInternal - case body - case rawBody - case expiresStartedAtMs - case expiresInSeconds - case isProMessage - - case state - case hasBeenReadByRecipient - case mostRecentFailureText - case isSenderModeratorOrAdmin - case isTypingIndicator - case profile - case quoteViewModel - case linkPreview - case linkPreviewAttachment - - case currentUserSessionId - - // Post-Query Processing Data - - case attachments - case reactionInfo - case cellType - case authorName - case authorNameSuppressedId - case senderName - case canHaveProfile - case shouldShowProfile - case shouldShowDateHeader - case containsOnlyEmoji - case glyphCount - case previousVariant - case positionInCluster - case isOnlyMessageInCluster - case isLast - case isLastOutgoing - case currentUserSessionIds - case optimisticMessageId - } - - public enum Gesture { - case tap - case doubleTap - case longPress - } - - // TODO: [PRO] Shouldn't need a bunch of these conformances - public enum CellType: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { - case textOnlyMessage - case mediaMessage - case audio - case voiceMessage - case genericAttachment - case infoMessage - case call - case typingIndicator - case dateHeader - case unreadMarker - - /// A number of the `CellType` entries are dynamically added to the dataset after processing, this flag indicates - /// whether the given type is one of them - public var isPostProcessed: Bool { - switch self { - case .typingIndicator, .dateHeader, .unreadMarker: return true - default: return false - } - } - - public var supportedGestures: Set { - switch self { - case .typingIndicator, .dateHeader, .unreadMarker: return [] - case .voiceMessage: return [.tap, .doubleTap, .longPress] - case .textOnlyMessage, .mediaMessage, .audio, .genericAttachment, - .infoMessage, .call: - return [.tap, .longPress] - } - } - } - - public var differenceIdentifier: Int64 { id } - - // Thread Info - - public let threadId: String - public let threadVariant: SessionThread.Variant - public let threadIsTrusted: Bool - public let threadExpirationType: DisappearingMessagesConfiguration.DisappearingMessageType? - public let threadExpirationTimer: TimeInterval? - public let threadOpenGroupServer: String? - public let threadOpenGroupPublicKey: String? - private let threadContactNameInternal: String? - - // Interaction Info - - public let rowId: Int64 - public let id: Int64 - public let serverHash: String? - public let openGroupServerMessageId: Int64? - public let variant: Interaction.Variant - public let timestampMs: Int64 - public let receivedAtTimestampMs: Int64 - public let authorId: String - private let authorNameInternal: String? - public let body: String? - public let rawBody: String? - public let expiresStartedAtMs: Double? - public let expiresInSeconds: TimeInterval? - public let isProMessage: Bool - - public let state: Interaction.State - public let hasBeenReadByRecipient: Bool - public let mostRecentFailureText: String? - public let isSenderModeratorOrAdmin: Bool - public let isTypingIndicator: Bool? - public let profile: Profile? - public let quoteViewModel: QuoteViewModel? - public let linkPreview: LinkPreview? - public let linkPreviewAttachment: Attachment? - - public let currentUserSessionId: String - - // Post-Query Processing Data - - /// This value includes the associated attachments - public let attachments: [Attachment]? - - /// This value includes the associated reactions - public let reactionInfo: [ReactionInfo]? - - /// This value defines what type of cell should appear and is generated based on the interaction variant - /// and associated attachment data - public let cellType: CellType - - /// This value includes the author name information - public let authorName: String - - /// This value includes the author name information with the `id` suppressed (if it was present) - public let authorNameSuppressedId: String - - /// This value will be used to populate the author label, if it's null then the label will be hidden - /// - /// **Note:** This will only be populated for incoming messages - public let senderName: String? - - /// A flag indicating whether the profile view can be displayed - public let canHaveProfile: Bool - - /// A flag indicating whether the profile view should be displayed - public let shouldShowProfile: Bool - - /// A flag which controls whether the date header should be displayed - public let shouldShowDateHeader: Bool - - /// This value will be used to populate the Context Menu and date header (if present) - public var dateForUI: Date { Date(timeIntervalSince1970: TimeInterval(Double(self.timestampMs) / 1000)) } - - /// This value will be used to populate the Message Info (if present) - public var receivedDateForUI: Date { - Date(timeIntervalSince1970: TimeInterval(Double(self.receivedAtTimestampMs) / 1000)) - } - - /// This value specifies whether the body contains only emoji characters - public let containsOnlyEmoji: Bool? - - /// This value specifies the number of emoji characters the body contains - public let glyphCount: Int? - - /// This value indicates the variant of the previous ViewModel item, if it's null then there is no previous item - public let previousVariant: Interaction.Variant? - - /// This value indicates the position of this message within a cluser of messages - public let positionInCluster: Position - - /// This value indicates whether this is the only message in a cluser of messages - public let isOnlyMessageInCluster: Bool - - /// This value indicates whether this is the last message in the thread - public let isLast: Bool - - public let isLastOutgoing: Bool - - /// This contains all sessionId values for the current user (standard and any blinded variants) - public let currentUserSessionIds: Set? - - /// This is a temporary id used before an outgoing message is persisted into the database - public let optimisticMessageId: UUID? - - // MARK: - Mutation - - public func with( - state: Update = .useExisting, // Optimistic outgoing messages - mostRecentFailureText: Update = .useExisting, // Optimistic outgoing messages - profile: Update = .useExisting, - quoteViewModel: Update = .useExisting, // Workaround for blinded current user - attachments: Update<[Attachment]?> = .useExisting, - reactionInfo: Update<[ReactionInfo]?> = .useExisting - ) -> MessageViewModel { - return MessageViewModel( - threadId: self.threadId, - threadVariant: self.threadVariant, - threadIsTrusted: self.threadIsTrusted, - threadExpirationType: self.threadExpirationType, - threadExpirationTimer: self.threadExpirationTimer, - threadOpenGroupServer: self.threadOpenGroupServer, - threadOpenGroupPublicKey: self.threadOpenGroupPublicKey, - threadContactNameInternal: self.threadContactNameInternal, - rowId: self.rowId, - id: self.id, - serverHash: self.serverHash, - openGroupServerMessageId: self.openGroupServerMessageId, - variant: self.variant, - timestampMs: self.timestampMs, - receivedAtTimestampMs: self.receivedAtTimestampMs, - authorId: self.authorId, - authorNameInternal: self.authorNameInternal, - body: self.body, - rawBody: self.rawBody, - expiresStartedAtMs: self.expiresStartedAtMs, - expiresInSeconds: self.expiresInSeconds, - isProMessage: self.isProMessage, - state: state.or(self.state), - hasBeenReadByRecipient: self.hasBeenReadByRecipient, - mostRecentFailureText: mostRecentFailureText.or(self.mostRecentFailureText), - isSenderModeratorOrAdmin: self.isSenderModeratorOrAdmin, - isTypingIndicator: self.isTypingIndicator, - profile: profile.or(self.profile), - quoteViewModel: quoteViewModel.or(self.quoteViewModel), - linkPreview: self.linkPreview, - linkPreviewAttachment: self.linkPreviewAttachment, - currentUserSessionId: self.currentUserSessionId, - attachments: attachments.or(self.attachments), - reactionInfo: reactionInfo.or(self.reactionInfo), - cellType: self.cellType, - authorName: self.authorName, - authorNameSuppressedId: self.authorNameSuppressedId, - senderName: self.senderName, - canHaveProfile: self.canHaveProfile, - shouldShowProfile: self.shouldShowProfile, - shouldShowDateHeader: self.shouldShowDateHeader, - containsOnlyEmoji: self.containsOnlyEmoji, - glyphCount: self.glyphCount, - previousVariant: self.previousVariant, - positionInCluster: self.positionInCluster, - isOnlyMessageInCluster: self.isOnlyMessageInCluster, - isLast: self.isLast, - isLastOutgoing: self.isLastOutgoing, - currentUserSessionIds: self.currentUserSessionIds, - optimisticMessageId: self.optimisticMessageId - ) - } - - public func withClusteringChanges( - prevModel: MessageViewModel?, - nextModel: MessageViewModel?, - isLast: Bool, - isLastOutgoing: Bool, - currentUserSessionIds: Set, - currentUserProfile: Profile, - threadIsTrusted: Bool, - using dependencies: Dependencies - ) -> MessageViewModel { - let cellType: CellType = { - guard self.isTypingIndicator != true else { return .typingIndicator } - guard !self.variant.isDeletedMessage else { return .textOnlyMessage } - guard let attachment: Attachment = self.attachments?.first else { - switch variant { - case .infoCall: return .call - case .infoLegacyGroupCreated, .infoLegacyGroupUpdated, .infoLegacyGroupCurrentUserLeft, - .infoGroupCurrentUserLeaving, .infoGroupCurrentUserErrorLeaving, - .infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification, - .infoMessageRequestAccepted, .infoGroupInfoInvited, .infoGroupInfoUpdated, .infoGroupMembersUpdated: - return .infoMessage - - case ._legacyStandardIncomingDeleted, .standardIncomingDeleted, .standardOutgoingDeleted, .standardIncomingDeletedLocally, .standardOutgoingDeletedLocally: - return .textOnlyMessage /// Should be handled above - - case .standardOutgoing, .standardIncoming: return .textOnlyMessage - } - } - - // The only case which currently supports multiple attachments is a 'mediaMessage' - // (the album view) - guard self.attachments?.count == 1 else { return .mediaMessage } - - // Pending audio attachments won't have a duration - if - attachment.isAudio && ( - ((attachment.duration ?? 0) > 0) || - ( - attachment.state != .downloaded && - attachment.state != .uploaded - ) - ) - { - return (attachment.variant == .voiceMessage ? .voiceMessage : .audio) - } - - if attachment.isVisualMedia { - return .mediaMessage - } - - return .genericAttachment - }() - // TODO: [Database Relocation] Clean up `currentUserProfile` logic (profile data should be sourced from a separate query for efficiency) - let authorDisplayName: String = { - guard authorId != currentUserProfile.id else { - return currentUserProfile.displayName( - for: self.threadVariant, - ignoringNickname: true, // Current user has no nickname - suppressId: false // Show the id next to the author name if desired - ) - } - - return Profile.displayName( - for: self.threadVariant, - id: self.authorId, - name: self.authorNameInternal, - nickname: nil, // Folded into 'authorName' within the Query - suppressId: false // Show the id next to the author name if desired - ) - }() - let authorDisplayNameSuppressedId: String = { - guard authorId != currentUserProfile.id else { - return currentUserProfile.displayName( - for: self.threadVariant, - ignoringNickname: true, // Current user has no nickname - suppressId: true // Exclude the id next to the author name - ) - } - - return Profile.displayName( - for: self.threadVariant, - id: self.authorId, - name: self.authorNameInternal, - nickname: nil, // Folded into 'authorName' within the Query - suppressId: true // Exclude the id next to the author name - ) - }() - let shouldShowDateBeforeThisModel: Bool = { - guard self.isTypingIndicator != true else { return false } - guard self.variant != .infoCall else { return true } // Always show on calls - guard !self.variant.isInfoMessage else { return false } // Never show on info messages - guard let prevModel: ViewModel = prevModel else { return true } - - return MessageViewModel.shouldShowDateBreak( - between: prevModel.timestampMs, - and: self.timestampMs - ) - }() - let shouldShowDateBeforeNextModel: Bool = { - // Should be nothing after a typing indicator - guard self.isTypingIndicator != true else { return false } - guard let nextModel: ViewModel = nextModel else { return false } - - return MessageViewModel.shouldShowDateBreak( - between: self.timestampMs, - and: nextModel.timestampMs - ) - }() - let (positionInCluster, isOnlyMessageInCluster): (Position, Bool) = { - let isFirstInCluster: Bool = ( - self.variant.isInfoMessage || - prevModel == nil || - shouldShowDateBeforeThisModel || ( - self.variant.isOutgoing && - prevModel?.variant.isOutgoing != true - ) || ( - self.variant.isIncoming && - prevModel?.variant.isIncoming != true - ) || - self.authorId != prevModel?.authorId - ) - let isLastInCluster: Bool = ( - self.variant.isInfoMessage || - nextModel == nil || - shouldShowDateBeforeNextModel || ( - self.variant.isOutgoing && - prevModel?.variant.isOutgoing != true - ) || ( - self.variant.isIncoming && - prevModel?.variant.isIncoming != true - ) || - self.authorId != nextModel?.authorId - ) - - let isOnlyMessageInCluster: Bool = (isFirstInCluster && isLastInCluster) - - switch (isFirstInCluster, isLastInCluster) { - case (true, true), (false, false): return (.middle, isOnlyMessageInCluster) - case (true, false): return (.top, isOnlyMessageInCluster) - case (false, true): return (.bottom, isOnlyMessageInCluster) - } - }() - let isGroupThread: Bool = ( - self.threadVariant == .community || - self.threadVariant == .legacyGroup || - self.threadVariant == .group - ) - - return ViewModel( - threadId: self.threadId, - threadVariant: self.threadVariant, - threadIsTrusted: (threadIsTrusted || self.threadIsTrusted), - threadExpirationType: self.threadExpirationType, - threadExpirationTimer: self.threadExpirationTimer, - threadOpenGroupServer: self.threadOpenGroupServer, - threadOpenGroupPublicKey: self.threadOpenGroupPublicKey, - threadContactNameInternal: self.threadContactNameInternal, - rowId: self.rowId, - id: self.id, - serverHash: self.serverHash, - openGroupServerMessageId: self.openGroupServerMessageId, - variant: self.variant, - timestampMs: self.timestampMs, - receivedAtTimestampMs: self.receivedAtTimestampMs, - authorId: self.authorId, - authorNameInternal: (self.threadId == currentUserProfile.id ? - "you".localized() : - self.authorNameInternal - ), - body: (!self.variant.isInfoMessage ? - self.body : - // Info messages might not have a body so we should use the 'previewText' value instead - Interaction.previewText( - variant: self.variant, - body: self.body, - threadContactDisplayName: Profile.displayName( - for: self.threadVariant, - id: self.threadId, - name: self.threadContactNameInternal, - nickname: nil, // Folded into 'threadContactNameInternal' within the Query - suppressId: false // Show the id next to the author name if desired - ), - authorDisplayName: authorDisplayName, - attachmentDescriptionInfo: self.attachments?.first.map { firstAttachment in - Attachment.DescriptionInfo( - id: firstAttachment.id, - variant: firstAttachment.variant, - contentType: firstAttachment.contentType, - sourceFilename: firstAttachment.sourceFilename - ) - }, - attachmentCount: self.attachments?.count, - isOpenGroupInvitation: (self.linkPreview?.variant == .openGroupInvitation), - using: dependencies - ) - ), - rawBody: self.body, - expiresStartedAtMs: self.expiresStartedAtMs, - expiresInSeconds: self.expiresInSeconds, - isProMessage: self.isProMessage, - state: self.state, - hasBeenReadByRecipient: self.hasBeenReadByRecipient, - mostRecentFailureText: self.mostRecentFailureText, - isSenderModeratorOrAdmin: self.isSenderModeratorOrAdmin, - isTypingIndicator: self.isTypingIndicator, - profile: (self.profile?.id == currentUserProfile.id ? currentUserProfile : self.profile), - quoteViewModel: self.quoteViewModel, - linkPreview: self.linkPreview, - linkPreviewAttachment: self.linkPreviewAttachment, - currentUserSessionId: self.currentUserSessionId, - attachments: self.attachments, - reactionInfo: self.reactionInfo, - cellType: cellType, - authorName: authorDisplayName, - authorNameSuppressedId: authorDisplayNameSuppressedId, - senderName: { - // Only show for group threads - guard isGroupThread else { return nil } - - // Only show for incoming messages - guard self.variant.isIncoming else { return nil } - - // Only if there is a date header or the senders are different - guard - shouldShowDateBeforeThisModel || - self.authorId != prevModel?.authorId || - prevModel?.variant.isInfoMessage == true - else { return nil } - - return authorDisplayName - }(), - canHaveProfile: ( - // Only group threads and incoming messages - isGroupThread && - self.variant.isIncoming - ), - shouldShowProfile: ( - // Only group threads - isGroupThread && - - // Only incoming messages - self.variant.isIncoming && - - // Show if the next message has a different sender, isn't a standard message or has a "date break" - ( - self.authorId != nextModel?.authorId || - nextModel?.variant.isIncoming != true || - shouldShowDateBeforeNextModel - ) && - - // Need a profile to be able to show it - self.profile != nil - ), - shouldShowDateHeader: shouldShowDateBeforeThisModel, - containsOnlyEmoji: self.body?.containsOnlyEmoji, - glyphCount: self.body?.glyphCount, - previousVariant: prevModel?.variant, - positionInCluster: positionInCluster, - isOnlyMessageInCluster: isOnlyMessageInCluster, - isLast: isLast, - isLastOutgoing: isLastOutgoing, - currentUserSessionIds: currentUserSessionIds, - optimisticMessageId: self.optimisticMessageId - ) - } -} - -// MARK: - DisappeaingMessagesUpdateControlMessage - -public extension MessageViewModel { - func messageDisappearingConfiguration() -> DisappearingMessagesConfiguration { - return DisappearingMessagesConfiguration - .defaultWith(self.threadId) - .with( - isEnabled: (self.expiresInSeconds ?? 0) > 0, - durationSeconds: self.expiresInSeconds, - type: (Int64(self.expiresStartedAtMs ?? 0) == self.timestampMs ? .disappearAfterSend : .disappearAfterRead ) - ) - } - - func threadDisappearingConfiguration() -> DisappearingMessagesConfiguration { - return DisappearingMessagesConfiguration - .defaultWith(self.threadId) - .with( - isEnabled: (self.threadExpirationTimer ?? 0) > 0, - durationSeconds: self.threadExpirationTimer, - type: self.threadExpirationType - ) - } - - func canDoFollowingSetting() -> Bool { - guard self.variant == .infoDisappearingMessagesUpdate else { return false } - guard self.authorId != self.currentUserSessionId else { return false } - guard self.threadVariant == .contact else { return false } - return self.messageDisappearingConfiguration() != self.threadDisappearingConfiguration() - } -} - -// MARK: - AttachmentInteractionInfo - -public extension MessageViewModel { - struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case rowId - case attachment - case interactionAttachment - } - - public let rowId: Int64 - public let attachment: Attachment - public let interactionAttachment: InteractionAttachment - - // MARK: - Identifiable - - public var id: String { - "\(interactionAttachment.interactionId)-\(interactionAttachment.albumIndex)" - } - - // MARK: - Comparable - - public static func < (lhs: AttachmentInteractionInfo, rhs: AttachmentInteractionInfo) -> Bool { - return (lhs.interactionAttachment.albumIndex < rhs.interactionAttachment.albumIndex) - } - } -} - -// MARK: - ReactionInfo - -public extension MessageViewModel { - struct ReactionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable, Hashable, Differentiable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case rowId - case reaction - case profile - } - - public let rowId: Int64 - public let reaction: Reaction - public let profile: Profile? - - // MARK: - Identifiable - - public var differenceIdentifier: String { return id } - - public var id: String { - "\(reaction.emoji)-\(reaction.interactionId)-\(reaction.authorId)" - } - - // MARK: - Comparable - - public static func < (lhs: ReactionInfo, rhs: ReactionInfo) -> Bool { - return (lhs.reaction.sortId < rhs.reaction.sortId) - } - } -} - -// MARK: - TypingIndicatorInfo - -public extension MessageViewModel { - struct TypingIndicatorInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case rowId - case threadId - } - - public let rowId: Int64 - public let threadId: String - - // MARK: - Identifiable - - public var id: String { threadId } - } -} - -// MARK: - Convenience Initialization - -public extension MessageViewModel { - static let genericId: Int64 = -1 - static let typingIndicatorId: Int64 = -2 - static let optimisticUpdateId: Int64 = -3 - - /// This init method is only used for system-created cells or empty states - init( - variant: Interaction.Variant = .standardOutgoing, - timestampMs: Int64 = Int64.max, - receivedAtTimestampMs: Int64 = Int64.max, - body: String? = nil, - quoteViewModel: QuoteViewModel? = nil, - cellType: CellType = .typingIndicator, - isTypingIndicator: Bool? = nil, - isLast: Bool = true, - isLastOutgoing: Bool = false - ) { - self.threadId = "INVALID_THREAD_ID" - self.threadVariant = .contact - self.threadIsTrusted = false - self.threadExpirationType = nil - self.threadExpirationTimer = nil - self.threadOpenGroupServer = nil - self.threadOpenGroupPublicKey = nil - self.threadContactNameInternal = nil - - // Interaction Info - - let targetId: Int64 = { - guard isTypingIndicator != true else { return MessageViewModel.typingIndicatorId } - guard cellType != .dateHeader else { return -timestampMs } - - return MessageViewModel.genericId - }() - self.rowId = targetId - self.id = targetId - self.serverHash = nil - self.openGroupServerMessageId = nil - self.variant = variant - self.timestampMs = timestampMs - self.receivedAtTimestampMs = receivedAtTimestampMs - self.authorId = "" - self.authorNameInternal = nil - self.body = body - self.rawBody = nil - self.expiresStartedAtMs = nil - self.expiresInSeconds = nil - self.isProMessage = false - - self.state = .sent - self.hasBeenReadByRecipient = false - self.mostRecentFailureText = nil - self.isSenderModeratorOrAdmin = false - self.isTypingIndicator = isTypingIndicator - self.profile = nil - self.quoteViewModel = quoteViewModel - self.linkPreview = nil - self.linkPreviewAttachment = nil - self.currentUserSessionId = "" - self.attachments = nil - self.reactionInfo = nil - - // Post-Query Processing Data - - self.cellType = cellType - self.authorName = "" - self.authorNameSuppressedId = "" - self.senderName = nil - self.canHaveProfile = false - self.shouldShowProfile = false - self.shouldShowDateHeader = false - self.containsOnlyEmoji = nil - self.glyphCount = nil - self.previousVariant = nil - self.positionInCluster = .middle - self.isOnlyMessageInCluster = true - self.isLast = isLast - self.isLastOutgoing = isLastOutgoing - self.currentUserSessionIds = [currentUserSessionId] - self.optimisticMessageId = nil - } - - /// This init method is only used for optimistic outgoing messages - init( - optimisticMessageId: UUID, - threadId: String, - threadVariant: SessionThread.Variant, - threadExpirationType: DisappearingMessagesConfiguration.DisappearingMessageType?, - threadExpirationTimer: TimeInterval?, - threadOpenGroupServer: String?, - threadOpenGroupPublicKey: String?, - threadContactNameInternal: String, - timestampMs: Int64, - receivedAtTimestampMs: Int64, - authorId: String, - authorNameInternal: String, - body: String?, - expiresStartedAtMs: Double?, - expiresInSeconds: TimeInterval?, - isProMessage: Bool, - state: Interaction.State = .sending, - isSenderModeratorOrAdmin: Bool, - currentUserProfile: Profile, - quoteViewModel: QuoteViewModel?, - linkPreview: LinkPreview?, - linkPreviewAttachment: Attachment?, - attachments: [Attachment]? - ) { - self.threadId = threadId - self.threadVariant = threadVariant - self.threadIsTrusted = false - self.threadExpirationType = threadExpirationType - self.threadExpirationTimer = threadExpirationTimer - self.threadOpenGroupServer = threadOpenGroupServer - self.threadOpenGroupPublicKey = threadOpenGroupPublicKey - self.threadContactNameInternal = threadContactNameInternal - - // Interaction Info - - self.rowId = MessageViewModel.optimisticUpdateId - self.id = MessageViewModel.optimisticUpdateId - self.serverHash = nil - self.openGroupServerMessageId = nil - self.variant = .standardOutgoing - self.timestampMs = timestampMs - self.receivedAtTimestampMs = receivedAtTimestampMs - self.authorId = authorId - self.authorNameInternal = authorNameInternal - self.body = body - self.rawBody = body - self.expiresStartedAtMs = expiresStartedAtMs - self.expiresInSeconds = expiresInSeconds - self.isProMessage = isProMessage - - self.state = state - self.hasBeenReadByRecipient = false - self.mostRecentFailureText = nil - self.isSenderModeratorOrAdmin = isSenderModeratorOrAdmin - self.isTypingIndicator = false - self.profile = currentUserProfile - self.quoteViewModel = quoteViewModel - self.linkPreview = linkPreview - self.linkPreviewAttachment = linkPreviewAttachment - self.currentUserSessionId = currentUserProfile.id - self.attachments = attachments - self.reactionInfo = nil - - // Post-Query Processing Data - - self.cellType = .textOnlyMessage - self.authorName = "" - self.authorNameSuppressedId = "" - self.senderName = nil - self.canHaveProfile = false - self.shouldShowProfile = false - self.shouldShowDateHeader = false - self.containsOnlyEmoji = nil - self.glyphCount = nil - self.previousVariant = nil - self.positionInCluster = .middle - self.isOnlyMessageInCluster = true - self.isLast = false - self.isLastOutgoing = false - self.currentUserSessionIds = [currentUserProfile.id] - self.optimisticMessageId = optimisticMessageId - } -} - -// MARK: - Convenience - -extension MessageViewModel { - private static let maxMinutesBetweenTwoDateBreaks: Int = 5 - - /// Returns the difference in minutes, ignoring seconds - /// - /// If both dates are the same date, returns 0 - /// If firstDate is one minute before secondDate, returns 1 - /// - /// **Note:** Assumes both dates use the "current" calendar - private static func minutesFrom(_ firstDate: Date, to secondDate: Date) -> Int? { - let calendar: Calendar = Calendar.current - let components1: DateComponents = calendar.dateComponents( - [.era, .year, .month, .day, .hour, .minute], - from: firstDate - ) - let components2: DateComponents = calendar.dateComponents( - [.era, .year, .month, .day, .hour, .minute], - from: secondDate - ) - - guard - let date1: Date = calendar.date(from: components1), - let date2: Date = calendar.date(from: components2) - else { return nil } - - return calendar.dateComponents([.minute], from: date1, to: date2).minute - } - - fileprivate static func shouldShowDateBreak(between timestamp1: Int64, and timestamp2: Int64) -> Bool { - let date1: Date = Date(timeIntervalSince1970: TimeInterval(Double(timestamp1) / 1000)) - let date2: Date = Date(timeIntervalSince1970: TimeInterval(Double(timestamp2) / 1000)) - - return ((minutesFrom(date1, to: date2) ?? 0) > maxMinutesBetweenTwoDateBreaks) - } -} - -// MARK: - ConversationVC - -// MARK: --MessageViewModel - -public extension MessageViewModel { - static func filterSQL(threadId: String) -> SQL { - let interaction: TypedTableAlias = TypedTableAlias() - - return SQL("\(interaction[.threadId]) = \(threadId)") - } - - static let groupSQL: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - - return SQL("GROUP BY \(interaction[.id])") - }() - - static let orderSQL: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - - return SQL("\(interaction[.timestampMs].desc)") - }() - - static func baseQuery( - userSessionId: SessionId, - currentUserSessionIds: Set, - orderSQL: SQL, - groupSQL: SQL? - ) -> (([Int64]) -> AdaptedFetchRequest>) { - return { rowIds -> AdaptedFetchRequest> in - let interaction: TypedTableAlias = TypedTableAlias() - let thread: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let groupMember: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let threadProfile: TypedTableAlias = TypedTableAlias(name: "threadProfile") - let linkPreview: TypedTableAlias = TypedTableAlias() - let linkPreviewAttachment: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .linkPreviewAttachment) - - let numColumnsBeforeLinkedRecords: Int = 25 - let finalGroupSQL: SQL = (groupSQL ?? "") - let request: SQLRequest = """ - SELECT - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - -- Default to 'true' for non-contact threads - IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.Columns.threadIsTrusted), - \(disappearingMessagesConfig[.type]) AS \(ViewModel.Columns.threadExpirationType), - \(disappearingMessagesConfig[.durationSeconds]) AS \(ViewModel.Columns.threadExpirationTimer), - \(openGroup[.server]) AS \(ViewModel.Columns.threadOpenGroupServer), - \(openGroup[.publicKey]) AS \(ViewModel.Columns.threadOpenGroupPublicKey), - IFNULL(\(threadProfile[.nickname]), \(threadProfile[.name])) AS \(ViewModel.Columns.threadContactNameInternal), - - \(interaction[.rowId]) AS \(ViewModel.Columns.rowId), - \(interaction[.id]), - \(interaction[.serverHash]), - \(interaction[.openGroupServerMessageId]), - \(interaction[.variant]), - \(interaction[.timestampMs]), - \(interaction[.receivedAtTimestampMs]), - \(interaction[.authorId]), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), - \(interaction[.body]), - \(interaction[.expiresStartedAtMs]), - \(interaction[.expiresInSeconds]), - \(interaction[.isProMessage]), - \(interaction[.state]), - (\(interaction[.recipientReadTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.hasBeenReadByRecipient), - \(interaction[.mostRecentFailureText]), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(interaction[.threadId]) AND - \(groupMember[.profileId]) = \(interaction[.authorId]) AND - \(SQL("\(groupMember[.role]) IN \([GroupMember.Role.moderator, GroupMember.Role.admin])")) - ) - ) AS \(ViewModel.Columns.isSenderModeratorOrAdmin), - - \(profile.allColumns), - \(linkPreview.allColumns), - \(linkPreviewAttachment.allColumns), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId), - - -- All of the below properties are set in post-query processing but to prevent the - -- query from crashing when decoding we need to provide default values - \(CellType.textOnlyMessage) AS \(ViewModel.Columns.cellType), - '' AS \(ViewModel.Columns.authorName), - '' AS \(ViewModel.Columns.authorNameSuppressedId), - false AS \(ViewModel.Columns.canHaveProfile), - false AS \(ViewModel.Columns.shouldShowProfile), - false AS \(ViewModel.Columns.shouldShowDateHeader), - \(Position.middle) AS \(ViewModel.Columns.positionInCluster), - false AS \(ViewModel.Columns.isOnlyMessageInCluster), - false AS \(ViewModel.Columns.isLast), - false AS \(ViewModel.Columns.isLastOutgoing) - - FROM \(Interaction.self) - JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) - LEFT JOIN \(threadProfile) ON \(threadProfile[.id]) = \(interaction[.threadId]) - LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) - LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - - LEFT JOIN \(LinkPreview.self) ON ( - \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral()) - ) - LEFT JOIN \(linkPreviewAttachment) ON \(linkPreviewAttachment[.id]) = \(linkPreview[.attachmentId]) - - WHERE \(interaction[.rowId]) IN \(rowIds) - \(finalGroupSQL) - ORDER BY \(orderSQL) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeLinkedRecords, - Profile.numberOfSelectedColumns(db), - LinkPreview.numberOfSelectedColumns(db), - Attachment.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .profile: adapters[1], - .linkPreview: adapters[2], - .linkPreviewAttachment: adapters[3] - ]) - } - } - } -} - -// MARK: --AttachmentInteractionInfo - -public extension MessageViewModel.AttachmentInteractionInfo { - static let baseQuery: ((SQL?) -> AdaptedFetchRequest>) = { - return { additionalFilters -> AdaptedFetchRequest> in - let attachment: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { - return SQL(stringLiteral: "") - } - - return """ - WHERE \(additionalFilters) - """ - }() - let numColumnsBeforeLinkedRecords: Int = 1 - let request: SQLRequest = """ - SELECT - \(attachment[.rowId]) AS \(AttachmentInteractionInfo.Columns.rowId), - \(attachment.allColumns), - \(interactionAttachment.allColumns) - FROM \(Attachment.self) - JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) - \(finalFilterSQL) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeLinkedRecords, - Attachment.numberOfSelectedColumns(db), - InteractionAttachment.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(AttachmentInteractionInfo.self, [ - .attachment: adapters[1], - .interactionAttachment: adapters[2] - ]) - } - } - }() - - static var joinToViewModelQuerySQL: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - let attachment: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - return """ - JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) - JOIN \(Attachment.self) ON \(attachment[.id]) = \(interactionAttachment[.attachmentId]) - """ - }() - - static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { - return { dataCache, pagedDataCache -> DataCache in - var updatedPagedDataCache: DataCache = pagedDataCache - - dataCache - .values - .grouped(by: \.interactionAttachment.interactionId) - .forEach { (interactionId: Int64, attachments: [MessageViewModel.AttachmentInteractionInfo]) in - guard - let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId], - let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] - else { return } - - updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with( - attachments: .set(to: attachments - .sorted() - .map { $0.attachment }) - ) - ) - } - - return updatedPagedDataCache - } - } -} - -// MARK: --ReactionInfo - -public extension MessageViewModel.ReactionInfo { - static let baseQuery: ((SQL?) -> AdaptedFetchRequest>) = { - return { additionalFilters -> AdaptedFetchRequest> in - let reaction: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - - let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { - return SQL(stringLiteral: "") - } - - return """ - WHERE \(additionalFilters) - """ - }() - let numColumnsBeforeLinkedRecords: Int = 1 - let request: SQLRequest = """ - SELECT - \(reaction[.rowId]) AS \(ReactionInfo.Columns.rowId), - \(reaction.allColumns), - \(profile.allColumns) - FROM \(Reaction.self) - LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(reaction[.authorId]) - \(finalFilterSQL) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeLinkedRecords, - Reaction.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ReactionInfo.self, [ - .reaction: adapters[1], - .profile: adapters[2] - ]) - } - } - }() - - static var joinToViewModelQuerySQL: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - let reaction: TypedTableAlias = TypedTableAlias() - - return """ - JOIN \(Reaction.self) ON \(reaction[.interactionId]) = \(interaction[.id]) - """ - }() - - static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { - return { dataCache, pagedDataCache -> DataCache in - var updatedPagedDataCache: DataCache = pagedDataCache - var pagedRowIdsWithNoReactions: Set = Set(pagedDataCache.data.keys) - - // Add any new reactions - dataCache - .values - .grouped(by: \.reaction.interactionId) - .forEach { (interactionId: Int64, reactionInfo: [MessageViewModel.ReactionInfo]) in - guard - let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId], - let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] - else { return } - - updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with(reactionInfo: .set(to: reactionInfo.sorted())) - ) - pagedRowIdsWithNoReactions.remove(interactionRowId) - } - - // Remove any removed reactions - updatedPagedDataCache = updatedPagedDataCache.upserting( - items: pagedRowIdsWithNoReactions - .compactMap { rowId -> ViewModel? in updatedPagedDataCache.data[rowId] } - .filter { viewModel -> Bool in (viewModel.reactionInfo?.isEmpty == false) } - .map { viewModel -> ViewModel in viewModel.with(reactionInfo: .set(to: nil)) } - ) - - return updatedPagedDataCache - } - } -} - -// MARK: --TypingIndicatorInfo - -public extension MessageViewModel.TypingIndicatorInfo { - static let baseQuery: ((SQL?) -> SQLRequest) = { - return { additionalFilters -> SQLRequest in - let threadTypingIndicator: TypedTableAlias = TypedTableAlias() - let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { - return SQL(stringLiteral: "") - } - - return """ - WHERE \(additionalFilters) - """ - }() - let request: SQLRequest = """ - SELECT - \(threadTypingIndicator[.rowId]), - \(threadTypingIndicator[.threadId]) - FROM \(ThreadTypingIndicator.self) - \(finalFilterSQL) - """ - - return request - } - }() - - static var joinToViewModelQuerySQL: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - let threadTypingIndicator: TypedTableAlias = TypedTableAlias() - - return """ - JOIN \(ThreadTypingIndicator.self) ON \(threadTypingIndicator[.threadId]) = \(interaction[.threadId]) - """ - }() - - static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { - return { dataCache, pagedDataCache -> DataCache in - guard !dataCache.data.isEmpty else { - return pagedDataCache.deleting(rowIds: [MessageViewModel.typingIndicatorId]) - } - - return pagedDataCache - .upserting(MessageViewModel(isTypingIndicator: true)) - } - } -} - -// MARK: - QuoteViewModel - -extension QuoteViewModel: @retroactive FetchableRecordWithRowId, @retroactive Decodable, @retroactive Identifiable, Differentiable, @retroactive ColumnExpressible { - fileprivate static let numberOfColumns: Int = 4 - - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case rowId - case interactionId - case authorId - case timestampMs - case quotedInteractionId - case quotedInteractionVariant - case quotedText - case quotedAttachment - } - - // MARK: - Identifiable - - public var id: String { - "quote-\(interactionId.map { "\($0)" } ?? "nil")-attachment_\(quotedAttachmentInfo?.id ?? "None")" - } - - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - var quotedAttachmentInfo: AttachmentInfo? - var interactionIsDeleted: Bool = false - - if - let attachment: Attachment = try container.decodeIfPresent(Attachment.self, forKey: .quotedAttachment), - let utType: UTType = UTType(sessionMimeType: attachment.contentType) - { - quotedAttachmentInfo = AttachmentInfo( - id: attachment.id, - utType: utType, - isVoiceMessage: (attachment.variant == .voiceMessage), - downloadUrl: attachment.downloadUrl, - sourceFilename: attachment.sourceFilename, - thumbnailSource: nil /// Intentionally `nil`, should be set via the `with` function below in the UI - ) - } - - if let variant: Interaction.Variant = try container.decodeIfPresent(Interaction.Variant.self, forKey: .quotedInteractionVariant) { - interactionIsDeleted = variant.isDeletedMessage - } - - self = QuoteViewModel( - mode: .regular, - direction: .outgoing, - currentUserSessionIds: [], - rowId: try container.decode(Int64.self, forKey: .rowId), - interactionId: try container.decode(Int64.self, forKey: .interactionId), - authorId: try container.decode(String.self, forKey: .authorId), - showProBadge: false, - timestampMs: try container.decode(Int64.self, forKey: .timestampMs), - quotedInteractionId: try container.decode(Int64.self, forKey: .quotedInteractionId), - quotedInteractionIsDeleted: interactionIsDeleted, - quotedText: try container.decodeIfPresent(String.self, forKey: .quotedText), - quotedAttachmentInfo: quotedAttachmentInfo, - displayNameRetriever: { _, _ in nil } - ) - } - - public func with( - direction: QuoteViewModel.Direction, - currentUserSessionIds: Set, - showProBadge: Bool, - thumbnailSource: ImageDataManager.DataSource?, - displayNameRetriever: @escaping (String, Bool) -> String? - ) -> QuoteViewModel { - return QuoteViewModel( - mode: mode, - direction: direction, - currentUserSessionIds: currentUserSessionIds, - rowId: rowId, - interactionId: interactionId, - authorId: authorId, - showProBadge: showProBadge, - timestampMs: timestampMs, - quotedInteractionId: quotedInteractionId, - quotedInteractionIsDeleted: quotedInteractionIsDeleted, - quotedText: quotedText, - quotedAttachmentInfo: quotedAttachmentInfo.map { - AttachmentInfo( - id: $0.id, - utType: $0.utType, - isVoiceMessage: $0.isVoiceMessage, - downloadUrl: $0.downloadUrl, - sourceFilename: $0.sourceFilename, - thumbnailSource: thumbnailSource - ) - }, - displayNameRetriever: displayNameRetriever - ) - } -} -//TODO: Need to test that this actually works -//public extension MessageViewModel { -// struct QuotedInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Hashable, ColumnExpressible { -// public typealias Columns = CodingKeys -// public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { -// case rowId -// case interactionId -// case authorId -// case timestampMs -// case body -// case attachment -// case quotedInteractionId -// case quotedInteractionVariant -// } -// -// public let rowId: Int64 -// public let interactionId: Int64 -// public let authorId: String -// public let timestampMs: Int64 -// public let body: String? -// public let attachment: Attachment? -// public let quotedInteractionId: Int64 -// public let quotedInteractionVariant: Interaction.Variant -// -// // MARK: - Identifiable -// -// public var id: String { "quote-\(interactionId)-attachment_\(attachment?.id ?? "None")" } -// -// // MARK: - Initialization -// -// public init(previewBody: String) { -// self.body = previewBody -// -// /// This is an preview version so none of these values matter -// self.rowId = -1 -// self.interactionId = -1 -// self.authorId = "" -// self.timestampMs = 0 -// self.attachment = nil -// self.quotedInteractionId = -1 -// self.quotedInteractionVariant = .standardOutgoing -// } -// -// public init?(replyModel: QuotedReplyModel?) { -// guard let model: QuotedReplyModel = replyModel else { return nil } -// -// self.authorId = model.authorId -// self.timestampMs = model.timestampMs -// self.body = model.body -// self.attachment = model.attachment -// -// /// This is an optimistic version so none of these values exist yet -// self.rowId = -1 -// self.interactionId = -1 -// self.quotedInteractionId = -1 -// self.quotedInteractionVariant = .standardOutgoing -// } -// } -//} - -public extension QuoteViewModel { - static func baseQuery( - userSessionId: SessionId, - currentUserSessionIds: Set - ) -> ((SQL?) -> AdaptedFetchRequest>) { - return { additionalFilters -> AdaptedFetchRequest> in - let quote: TypedTableAlias = TypedTableAlias() - let quoteInteraction: TypedTableAlias = TypedTableAlias(name: "quoteInteraction") - let quoteInteractionAttachment: TypedTableAlias = TypedTableAlias( - name: "quoteInteractionAttachment" - ) - let quoteLinkPreview: TypedTableAlias = TypedTableAlias(name: "quoteLinkPreview") - let attachment: TypedTableAlias = TypedTableAlias() - - let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { - return SQL(stringLiteral: "") - } - - return """ - WHERE \(additionalFilters) - """ - }() - - let numColumnsBeforeLinkedRecords: Int = 7 - let request: SQLRequest = """ - SELECT - \(quote[.rowId]) AS \(QuoteViewModel.Columns.rowId), - \(quote[.interactionId]) AS \(QuoteViewModel.Columns.interactionId), - \(quote[.authorId]) AS \(QuoteViewModel.Columns.authorId), - \(quote[.timestampMs]) AS \(QuoteViewModel.Columns.timestampMs), - \(quoteInteraction[.id]) AS \(QuoteViewModel.Columns.quotedInteractionId), - \(quoteInteraction[.variant]) AS \(QuoteViewModel.Columns.quotedInteractionVariant), - \(quoteInteraction[.body]) AS \(QuoteViewModel.Columns.quotedText), - \(attachment.allColumns) - FROM \(Quote.self) - JOIN \(quoteInteraction) ON ( - \(quoteInteraction[.timestampMs]) = \(quote[.timestampMs]) AND ( - \(quoteInteraction[.authorId]) = \(quote[.authorId]) OR ( - -- A users outgoing message is stored in some cases using their standard id - -- but the quote will use their blinded id so handle that case - \(quoteInteraction[.authorId]) = \(userSessionId.hexString) AND - \(quote[.authorId]) IN \(currentUserSessionIds) - ) - ) - ) - LEFT JOIN \(quoteInteractionAttachment) ON ( - \(quoteInteractionAttachment[.interactionId]) = \(quoteInteraction[.id]) AND - \(quoteInteractionAttachment[.albumIndex]) = 0 - ) - LEFT JOIN \(quoteLinkPreview) ON ( - \(quoteLinkPreview[.url]) = \(quoteInteraction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral( - interaction: quoteInteraction, - linkPreview: quoteLinkPreview - )) - ) - LEFT JOIN \(Attachment.self) ON ( - \(attachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) OR - \(attachment[.id]) = \(quoteLinkPreview[.attachmentId]) - ) - \(finalFilterSQL) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeLinkedRecords, - Attachment.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(QuoteViewModel.self, [ - .quotedAttachment: adapters[1] - ]) - } - } - } - - static func joinToViewModelQuerySQL() -> SQL { - let quote: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - - return """ - JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) - """ - } - - static func createReferencedRowIdsRetriever() -> (([Int64], DataCache) -> [Int64]) { - return { pagedRowIds, dataCache -> [Int64] in - dataCache.values.compactMap { viewModel in - guard - let interactionId: Int64 = viewModel.interactionId, ( - pagedRowIds.contains(viewModel.quotedInteractionId) || - pagedRowIds.contains(interactionId) - ) - else { return nil } - - return viewModel.rowId - } - } - } - - static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { - return { dataCache, pagedDataCache -> DataCache in - var updatedPagedDataCache: DataCache = pagedDataCache - - // Update changed records - dataCache.values.forEach { quoteViewModel in - guard - let interactionId: Int64 = quoteViewModel.interactionId, - let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId], - let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] - else { return } - - switch quoteViewModel.quotedInteractionIsDeleted { - // If the original message wasn't deleted and the quote contains some of it's content - // then remove that content from the quote - case false: - updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with(quoteViewModel: .set(to: quoteViewModel)) - ) - - // If the original message was deleted and the quote contains some of it's content - // then remove that content from the quote - case true: - guard dataToUpdate.quoteViewModel != nil else { return } - - updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with(quoteViewModel: .set(to: nil)) - ) - } - } - - return updatedPagedDataCache - } - } -} diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift deleted file mode 100644 index 5508a2e9a2..0000000000 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ /dev/null @@ -1,2339 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation -import GRDB -import DifferenceKit -import SessionUIKit -import SessionUtilitiesKit - -fileprivate typealias ViewModel = SessionThreadViewModel - -/// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewModel` and the -/// `GlobalSearchViewController`, it has a number of query methods which can be used to retrieve the relevant data for each -/// screen in a single location in an attempt to avoid spreading out _almost_ duplicated code in multiple places -/// -/// **Note:** When updating the UI make sure to check the actual queries being run as some fields will have incorrect default values -/// in order to optimise their queries to only include the required data -// TODO: [Database Relocation] Refactor this to split database data from no-database data (to avoid unneeded nullables) -public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, Decodable, Sendable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible, ThreadSafeType { - public typealias PagedDataType = SessionThread - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case rowId - case threadId - case threadVariant - case threadCreationDateTimestamp - case threadMemberNames - - case threadIsNoteToSelf - case outdatedMemberId - case threadIsMessageRequest - case threadRequiresApproval - case threadShouldBeVisible - case threadPinnedPriority - case threadIsBlocked - case threadMutedUntilTimestamp - case threadOnlyNotifyForMentions - case threadMessageDraft - case threadIsDraft - - case threadContactIsTyping - case threadWasMarkedUnread - case threadUnreadCount - case threadUnreadMentionCount - case threadHasUnreadMessagesOfAnyKind - case threadCanWrite - case threadCanUpload - - // Thread display info - - case disappearingMessagesConfiguration - - case contactLastKnownClientVersion - case threadDisplayPictureUrl - case contactProfile - case closedGroupProfileFront - case closedGroupProfileBack - case closedGroupProfileBackFallback - case closedGroupAdminProfile - case closedGroupName - case closedGroupDescription - case closedGroupUserCount - case closedGroupExpired - case currentUserIsClosedGroupMember - case currentUserIsClosedGroupAdmin - case openGroupName - case openGroupDescription - case openGroupServer - case openGroupRoomToken - case openGroupPublicKey - case openGroupUserCount - case openGroupPermissions - case openGroupCapabilities - - // Interaction display info - - case interactionId - case interactionVariant - case interactionTimestampMs - case interactionBody - case interactionState - case interactionHasBeenReadByRecipient - case interactionIsOpenGroupInvitation - case interactionAttachmentDescriptionInfo - case interactionAttachmentCount - - case authorId - case threadContactNameInternal - case authorNameInternal - case currentUserSessionId - case currentUserSessionIds - case recentReactionEmoji - case wasKickedFromGroup - case groupIsDestroyed - case isContactApproved - } - - public var differenceIdentifier: String { threadId } - public var id: String { threadId } - - public let rowId: Int64 - public let threadId: String - public let threadVariant: SessionThread.Variant - private let threadCreationDateTimestamp: TimeInterval - public let threadMemberNames: String? - - public let threadIsNoteToSelf: Bool - public let outdatedMemberId: String? - - /// This flag indicates whether the thread is an outgoing message request - public let threadIsMessageRequest: Bool? - - /// This flag indicates whether the thread is an incoming message request - public let threadRequiresApproval: Bool? - public let threadShouldBeVisible: Bool? - public let threadPinnedPriority: Int32 - public let threadIsBlocked: Bool? - public let threadMutedUntilTimestamp: TimeInterval? - public let threadOnlyNotifyForMentions: Bool? - public let threadMessageDraft: String? - public let threadIsDraft: Bool? - - public let threadContactIsTyping: Bool? - public let threadWasMarkedUnread: Bool? - public let threadUnreadCount: UInt? - public let threadUnreadMentionCount: UInt? - public let threadHasUnreadMessagesOfAnyKind: Bool? - public let threadCanWrite: Bool? - public let threadCanUpload: Bool? - - // Thread display info - - public let disappearingMessagesConfiguration: DisappearingMessagesConfiguration? - - public let contactLastKnownClientVersion: FeatureVersion? - public let threadDisplayPictureUrl: String? - public let contactProfile: Profile? - internal let closedGroupProfileFront: Profile? - internal let closedGroupProfileBack: Profile? - internal let closedGroupProfileBackFallback: Profile? - public let closedGroupAdminProfile: Profile? - public let closedGroupName: String? - private let closedGroupDescription: String? - private let closedGroupUserCount: Int? - public let closedGroupExpired: Bool? - public let currentUserIsClosedGroupMember: Bool? - public let currentUserIsClosedGroupAdmin: Bool? - public let openGroupName: String? - private let openGroupDescription: String? - public let openGroupServer: String? - public let openGroupRoomToken: String? - public let openGroupPublicKey: String? - private let openGroupUserCount: Int? - private let openGroupPermissions: OpenGroup.Permissions? - public let openGroupCapabilities: Set? - - // Interaction display info - - public let interactionId: Int64? - public let interactionVariant: Interaction.Variant? - public let interactionTimestampMs: Int64? - public let interactionBody: String? - public let interactionState: Interaction.State? - public let interactionHasBeenReadByRecipient: Bool? - public let interactionIsOpenGroupInvitation: Bool? - public let interactionAttachmentDescriptionInfo: Attachment.DescriptionInfo? - public let interactionAttachmentCount: Int? - - public let authorId: String? - private let threadContactNameInternal: String? - private let authorNameInternal: String? - public let currentUserSessionId: String - public let currentUserSessionIds: Set? - public let recentReactionEmoji: [String]? - public let wasKickedFromGroup: Bool? - public let groupIsDestroyed: Bool? - - /// Flag indicates that the contact's message request has been approved - public let isContactApproved: Bool? - - // UI specific logic - - public var displayName: String { - return SessionThread.displayName( - threadId: threadId, - variant: threadVariant, - closedGroupName: closedGroupName, - openGroupName: openGroupName, - isNoteToSelf: threadIsNoteToSelf, - ignoringNickname: false, - profile: profile - ) - } - - public var contactDisplayName: String { - return SessionThread.displayName( - threadId: threadId, - variant: threadVariant, - closedGroupName: closedGroupName, - openGroupName: openGroupName, - isNoteToSelf: threadIsNoteToSelf, - ignoringNickname: true, - profile: profile - ) - } - - public var threadDescription: String? { - switch threadVariant { - case .contact, .legacyGroup: return nil - case .community: return openGroupDescription - case .group: return closedGroupDescription - } - } - - public var allProfileIds: Set { - Set([ - authorId, contactProfile?.id, closedGroupProfileFront?.id, - closedGroupProfileBackFallback?.id, closedGroupAdminProfile?.id - ].compactMap { $0 }) - } - - public var profile: Profile? { - switch threadVariant { - case .contact: return contactProfile - case .legacyGroup, .group: - return (closedGroupProfileBack ?? closedGroupProfileBackFallback) - case .community: return nil - } - } - - public var additionalProfile: Profile? { - switch threadVariant { - case .legacyGroup, .group: return closedGroupProfileFront - default: return nil - } - } - - public var lastInteractionDate: Date { - guard let interactionTimestampMs: Int64 = interactionTimestampMs else { - return Date(timeIntervalSince1970: threadCreationDateTimestamp) - } - - return Date(timeIntervalSince1970: TimeInterval(Double(interactionTimestampMs) / 1000)) - } - - public var messageInputState: InputView.InputState { - guard !threadIsNoteToSelf else { return InputView.InputState(inputs: .all) } - guard threadIsBlocked != true else { - return InputView.InputState( - inputs: .disabled, - message: "blockBlockedDescription".localized(), - messageAccessibility: Accessibility( - identifier: "Blocked banner" - ) - ) - } - - if threadVariant == .community && threadCanWrite == false { - return InputView.InputState( - inputs: .disabled, - message: "permissionsWriteCommunity".localized() - ) - } - - /// Attachments shouldn't be allowed for message requests or if uploads are disabled - let finalInputs: InputView.Input - - switch (threadRequiresApproval, threadIsMessageRequest, threadCanUpload) { - case (false, false, true): finalInputs = .all - default: finalInputs = [.text, .attachmentsDisabled, .voiceMessagesDisabled] - } - - return InputView.InputState( - inputs: finalInputs - ) - } - - public var userCount: Int? { - switch threadVariant { - case .contact: return nil - case .legacyGroup, .group: return closedGroupUserCount - case .community: return openGroupUserCount - } - } - - /// This function returns the thread contact profile name formatted for the specific type of thread provided - /// - /// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this - /// to always behave as the `contact` variant which is why this needs to be a function instead of just using the provided - /// parameter - public func threadContactName() -> String { - return Profile.displayName( - for: .contact, - id: threadId, - name: threadContactNameInternal, - nickname: nil, // Folded into 'threadContactNameInternal' within the Query - suppressId: true, // Don't include the account id in the name in the conversation list - customFallback: "Anonymous" - ) - } - - /// This function returns the profile name formatted for the specific type of thread provided - /// - /// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this - /// to always behave as the `contact` variant which is why this needs to be a function instead of just using the provided - /// parameter - public func authorName(for threadVariant: SessionThread.Variant) -> String { - return Profile.displayName( - for: threadVariant, - id: (authorId ?? threadId), - name: authorNameInternal, - nickname: nil, // Folded into 'authorName' within the Query - suppressId: true, // Don't include the account id in the name in the conversation list - customFallback: (threadVariant == .contact ? - "Anonymous" : - nil - ) - ) - } - - public func canAccessSettings(using dependencies: Dependencies) -> Bool { - return ( - threadRequiresApproval == false && - threadIsMessageRequest == false && - threadVariant != .legacyGroup - ) - } - - public func isSessionPro(using dependencies: Dependencies) -> Bool { - guard threadIsNoteToSelf == false && threadVariant != .community else { - return false - } - return dependencies.mutate(cache: .libSession) { [threadId] in $0.validateSessionProState(for: threadId)} - } - - public func getQRCodeString() -> String { - switch self.threadVariant { - case .contact, .legacyGroup, .group: - return self.threadId - - case .community: - guard - let urlString: String = LibSession.communityUrlFor( - server: self.openGroupServer, - roomToken: self.openGroupRoomToken, - publicKey: self.openGroupPublicKey - ) - else { return "" } - - return urlString - } - } - - // MARK: - Marking as Read - - public enum ReadTarget { - /// Only the thread should be marked as read - case thread - - /// Both the thread and interactions should be marked as read, if no interaction id is provided then all interactions for the - /// thread will be marked as read - case threadAndInteractions(interactionsBeforeInclusive: Int64?) - } - - /// This method marks a thread as read and depending on the target may also update the interactions within a thread as read - public func markAsRead(target: ReadTarget, using dependencies: Dependencies) { - // Store the logic to mark a thread as read (to paths need to run this) - let threadId: String = self.threadId - let threadWasMarkedUnread: Bool? = self.threadWasMarkedUnread - let markThreadAsReadIfNeeded: (Dependencies) -> () = { dependencies in - // Only make this change if needed (want to avoid triggering a thread update - // if not needed) - guard threadWasMarkedUnread == true else { return } - - dependencies[singleton: .storage].writeAsync { db in - try SessionThread - .filter(id: threadId) - .updateAllAndConfig( - db, - SessionThread.Columns.markedAsUnread.set(to: false), - using: dependencies - ) - db.addConversationEvent(id: threadId, type: .updated(.markedAsUnread(false))) - } - } - - // Determine what we want to mark as read - switch target { - // Only mark the thread as read - case .thread: markThreadAsReadIfNeeded(dependencies) - - // We want to mark both the thread and interactions as read - case .threadAndInteractions(let interactionId): - guard - self.threadHasUnreadMessagesOfAnyKind == true, - let targetInteractionId: Int64 = (interactionId ?? self.interactionId) - else { - // No unread interactions so just mark the thread as read if needed - markThreadAsReadIfNeeded(dependencies) - return - } - - let threadId: String = self.threadId - let threadVariant: SessionThread.Variant = self.threadVariant - let threadIsBlocked: Bool? = self.threadIsBlocked - let threadIsMessageRequest: Bool? = self.threadIsMessageRequest - - dependencies[singleton: .storage].writeAsync { db in - markThreadAsReadIfNeeded(dependencies) - - try Interaction.markAsRead( - db, - interactionId: targetInteractionId, - threadId: threadId, - threadVariant: threadVariant, - includingOlder: true, - trySendReadReceipt: SessionThread.canSendReadReceipt( - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ), - using: dependencies - ) - } - } - } - - /// This method will mark a thread as read - public func markAsUnread(using dependencies: Dependencies) { - guard self.threadWasMarkedUnread != true else { return } - - let threadId: String = self.threadId - - dependencies[singleton: .storage].writeAsync { db in - try SessionThread - .filter(id: threadId) - .updateAllAndConfig( - db, - SessionThread.Columns.markedAsUnread.set(to: true), - using: dependencies - ) - db.addConversationEvent(id: threadId, type: .updated(.markedAsUnread(true))) - } - } - - // MARK: - Functions - - /// This function should only be called when initially creating/populating the `SessionThreadViewModel`, instead use - /// `threadCanWrite == true` to determine whether the user should be able to write to a thread, this function uses - /// external data to determine if the user can write so the result might differ from the original value when the - /// `SessionThreadViewModel` was created - public func determineInitialCanWriteFlag(using dependencies: Dependencies) -> Bool { - switch threadVariant { - case .contact: - guard threadIsMessageRequest == true else { return true } - - // If the thread is an incoming message request then we should be able to reply - // regardless of the original senders `blocksCommunityMessageRequests` setting - guard threadRequiresApproval == true else { return true } - - return (profile?.blocksCommunityMessageRequests != true) - - case .legacyGroup: return false - case .group: - guard groupIsDestroyed != true else { return false } - guard wasKickedFromGroup != true else { return false } - guard threadIsMessageRequest == false else { return true } - - /// Double check `libSession` directly just in case we the view model hasn't been updated since they were changed - guard - dependencies.mutate(cache: .libSession, { cache in - !cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) && - !cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) - }) - else { return false } - - return interactionVariant?.isGroupLeavingStatus != true - - case .community: - return (openGroupPermissions?.contains(.write) ?? false) - } - } - - /// This function should only be called when initially creating/populating the `SessionThreadViewModel`, instead use - /// `threadCanUpload == true` to determine whether the user should be able to write to a thread, this function uses - /// external data to determine if the user can write so the result might differ from the original value when the - /// `SessionThreadViewModel` was created - public func determineInitialCanUploadFlag(using dependencies: Dependencies) -> Bool { - switch threadVariant { - case .contact: - // If the thread is an outgoing message request then we shouldn't be able to upload - return (threadRequiresApproval == false) - - case .legacyGroup: return false - case .group: - guard groupIsDestroyed != true else { return false } - guard wasKickedFromGroup != true else { return false } - guard threadIsMessageRequest == false else { return true } - - /// Double check `libSession` directly just in case we the view model hasn't been updated since they were changed - guard - dependencies.mutate(cache: .libSession, { cache in - !cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) && - !cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) - }) - else { return false } - - return interactionVariant?.isGroupLeavingStatus != true - - case .community: - return (openGroupPermissions?.contains(.upload) ?? false) - } - } -} - -// MARK: - Convenience Initialization - -public extension SessionThreadViewModel { - static let invalidId: String = "INVALID_THREAD_ID" - static let messageRequestsSectionId: String = "MESSAGE_REQUESTS_SECTION_INVALID_THREAD_ID" - - // Note: This init method is only used system-created cells or empty states - init( - threadId: String, - threadVariant: SessionThread.Variant? = nil, - threadIsNoteToSelf: Bool = false, - threadIsMessageRequest: Bool? = nil, - threadIsBlocked: Bool? = nil, - contactProfile: Profile? = nil, - closedGroupAdminProfile: Profile? = nil, - closedGroupExpired: Bool? = nil, - currentUserIsClosedGroupMember: Bool? = nil, - currentUserIsClosedGroupAdmin: Bool? = nil, - openGroupPermissions: OpenGroup.Permissions? = nil, - threadWasMarkedUnread: Bool? = nil, - unreadCount: UInt = 0, - hasUnreadMessagesOfAnyKind: Bool = false, - threadCanWrite: Bool = true, - threadCanUpload: Bool = true, - disappearingMessagesConfiguration: DisappearingMessagesConfiguration? = nil, - using dependencies: Dependencies - ) { - self.rowId = -1 - self.threadId = threadId - self.threadVariant = (threadVariant ?? .contact) - self.threadCreationDateTimestamp = 0 - self.threadMemberNames = nil - - self.threadIsNoteToSelf = threadIsNoteToSelf - self.outdatedMemberId = nil - self.threadIsMessageRequest = threadIsMessageRequest - self.threadRequiresApproval = false - self.threadShouldBeVisible = false - self.threadPinnedPriority = 0 - self.threadIsBlocked = threadIsBlocked - self.threadMutedUntilTimestamp = nil - self.threadOnlyNotifyForMentions = nil - self.threadMessageDraft = nil - self.threadIsDraft = nil - - self.threadContactIsTyping = nil - self.threadWasMarkedUnread = threadWasMarkedUnread - self.threadUnreadCount = unreadCount - self.threadUnreadMentionCount = nil - self.threadHasUnreadMessagesOfAnyKind = hasUnreadMessagesOfAnyKind - self.threadCanWrite = threadCanWrite - self.threadCanUpload = threadCanUpload - - // Thread display info - - self.disappearingMessagesConfiguration = disappearingMessagesConfiguration - - self.contactLastKnownClientVersion = nil - self.threadDisplayPictureUrl = nil - self.contactProfile = contactProfile - self.closedGroupProfileFront = nil - self.closedGroupProfileBack = nil - self.closedGroupProfileBackFallback = nil - self.closedGroupAdminProfile = closedGroupAdminProfile - self.closedGroupName = nil - self.closedGroupDescription = nil - self.closedGroupUserCount = nil - self.closedGroupExpired = closedGroupExpired - self.currentUserIsClosedGroupMember = currentUserIsClosedGroupMember - self.currentUserIsClosedGroupAdmin = currentUserIsClosedGroupAdmin - self.openGroupName = nil - self.openGroupDescription = nil - self.openGroupServer = nil - self.openGroupRoomToken = nil - self.openGroupPublicKey = nil - self.openGroupUserCount = nil - self.openGroupPermissions = openGroupPermissions - self.openGroupCapabilities = nil - - // Interaction display info - - self.interactionId = nil - self.interactionVariant = nil - self.interactionTimestampMs = nil - self.interactionBody = nil - self.interactionState = nil - self.interactionHasBeenReadByRecipient = nil - self.interactionIsOpenGroupInvitation = nil - self.interactionAttachmentDescriptionInfo = nil - self.interactionAttachmentCount = nil - - self.authorId = nil - self.threadContactNameInternal = nil - self.authorNameInternal = nil - self.currentUserSessionId = dependencies[cache: .general].sessionId.hexString - self.currentUserSessionIds = [dependencies[cache: .general].sessionId.hexString] - self.recentReactionEmoji = nil - self.wasKickedFromGroup = false - self.groupIsDestroyed = false - self.isContactApproved = false - } -} - -// MARK: - Mutation - -public extension SessionThreadViewModel { - func populatingPostQueryData( - recentReactionEmoji: [String]?, - openGroupCapabilities: Set?, - currentUserSessionIds: Set, - wasKickedFromGroup: Bool, - groupIsDestroyed: Bool, - threadCanWrite: Bool, - threadCanUpload: Bool - ) -> SessionThreadViewModel { - return SessionThreadViewModel( - rowId: self.rowId, - threadId: self.threadId, - threadVariant: self.threadVariant, - threadCreationDateTimestamp: self.threadCreationDateTimestamp, - threadMemberNames: self.threadMemberNames, - threadIsNoteToSelf: self.threadIsNoteToSelf, - outdatedMemberId: self.outdatedMemberId, - threadIsMessageRequest: self.threadIsMessageRequest, - threadRequiresApproval: self.threadRequiresApproval, - threadShouldBeVisible: self.threadShouldBeVisible, - threadPinnedPriority: self.threadPinnedPriority, - threadIsBlocked: self.threadIsBlocked, - threadMutedUntilTimestamp: self.threadMutedUntilTimestamp, - threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions, - threadMessageDraft: self.threadMessageDraft, - threadIsDraft: self.threadIsDraft, - threadContactIsTyping: self.threadContactIsTyping, - threadWasMarkedUnread: self.threadWasMarkedUnread, - threadUnreadCount: self.threadUnreadCount, - threadUnreadMentionCount: self.threadUnreadMentionCount, - threadHasUnreadMessagesOfAnyKind: self.threadHasUnreadMessagesOfAnyKind, - threadCanWrite: threadCanWrite, - threadCanUpload: threadCanUpload, - disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, - contactLastKnownClientVersion: self.contactLastKnownClientVersion, - threadDisplayPictureUrl: self.threadDisplayPictureUrl, - contactProfile: self.contactProfile, - closedGroupProfileFront: self.closedGroupProfileFront, - closedGroupProfileBack: self.closedGroupProfileBack, - closedGroupProfileBackFallback: self.closedGroupProfileBackFallback, - closedGroupAdminProfile: self.closedGroupAdminProfile, - closedGroupName: self.closedGroupName, - closedGroupDescription: self.closedGroupDescription, - closedGroupUserCount: self.closedGroupUserCount, - closedGroupExpired: self.closedGroupExpired, - currentUserIsClosedGroupMember: self.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: self.currentUserIsClosedGroupAdmin, - openGroupName: self.openGroupName, - openGroupDescription: self.openGroupDescription, - openGroupServer: self.openGroupServer, - openGroupRoomToken: self.openGroupRoomToken, - openGroupPublicKey: self.openGroupPublicKey, - openGroupUserCount: self.openGroupUserCount, - openGroupPermissions: self.openGroupPermissions, - openGroupCapabilities: openGroupCapabilities, - interactionId: self.interactionId, - interactionVariant: self.interactionVariant, - interactionTimestampMs: self.interactionTimestampMs, - interactionBody: self.interactionBody, - interactionState: self.interactionState, - interactionHasBeenReadByRecipient: self.interactionHasBeenReadByRecipient, - interactionIsOpenGroupInvitation: self.interactionIsOpenGroupInvitation, - interactionAttachmentDescriptionInfo: self.interactionAttachmentDescriptionInfo, - interactionAttachmentCount: self.interactionAttachmentCount, - authorId: self.authorId, - threadContactNameInternal: self.threadContactNameInternal, - authorNameInternal: self.authorNameInternal, - currentUserSessionId: self.currentUserSessionId, - currentUserSessionIds: currentUserSessionIds, - recentReactionEmoji: recentReactionEmoji, - wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed, - isContactApproved: isContactApproved - ) - } -} - -// MARK: - AggregateInteraction - -private struct AggregateInteraction: Decodable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case interactionId - case threadId - case interactionTimestampMs - case threadUnreadCount - case threadUnreadMentionCount - case threadHasUnreadMessagesOfAnyKind - } - - let interactionId: Int64 - let threadId: String - let interactionTimestampMs: Int64 - let threadUnreadCount: UInt? - let threadUnreadMentionCount: UInt? - let threadHasUnreadMessagesOfAnyKind: Bool? -} - -// MARK: - ClosedGroupUserCount - -private struct ClosedGroupUserCount: Decodable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case groupId - case closedGroupUserCount - } - - let groupId: String - let closedGroupUserCount: Int -} - -// MARK: - GroupMemberInfo - -private struct GroupMemberInfo: Decodable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case groupId - case threadMemberNames - } - - let groupId: String - let threadMemberNames: String -} - -// MARK: - HomeVC & MessageRequestsViewModel - -// MARK: --SessionThreadViewModel - -public extension SessionThreadViewModel { - static func query( - userSessionId: SessionId, - groupSQL: SQL, - orderSQL: SQL, - ids: [String] - ) -> AdaptedFetchRequest> { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let typingIndicator: TypedTableAlias = TypedTableAlias() - let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") - let interaction: TypedTableAlias = TypedTableAlias() - let linkPreview: TypedTableAlias = TypedTableAlias() - let firstInteractionAttachment: TypedTableAlias = TypedTableAlias(name: "firstInteractionAttachment") - let attachment: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - /// - /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 15 - let numColumnsBetweenProfilesAndAttachmentInfo: Int = 13 // The attachment info columns will be combined - let request: SQLRequest = """ - SELECT - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), - ( - COALESCE(\(closedGroup[.invited]), false) = true OR ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND - IFNULL(\(contact[.isApproved]), false) = false - ) - ) AS \(ViewModel.Columns.threadIsMessageRequest), - - (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.Columns.threadContactIsTyping), - \(thread[.markedAsUnread]) AS \(ViewModel.Columns.threadWasMarkedUnread), - \(aggregateInteraction[.threadUnreadCount]), - \(aggregateInteraction[.threadUnreadMentionCount]), - \(aggregateInteraction[.threadHasUnreadMessagesOfAnyKind]), - - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) AND ( - ( - -- Legacy groups don't have a 'roleStatus' so just let those through - -- based solely on the 'role' - \(groupMember[.groupId]) > \(SessionId.Prefix.standard.rawValue) AND - \(groupMember[.groupId]) < \(SessionId.Prefix.standard.endOfRangeString) - ) OR - \(SQL("\(groupMember[.roleStatus]) = \(GroupMember.RoleStatus.accepted)")) - ) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), - - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(interaction[.id]) AS \(ViewModel.Columns.interactionId), - \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), - \(interaction[.timestampMs]) AS \(ViewModel.Columns.interactionTimestampMs), - \(interaction[.body]) AS \(ViewModel.Columns.interactionBody), - \(interaction[.state]) AS \(ViewModel.Columns.interactionState), - (\(interaction[.recipientReadTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.interactionHasBeenReadByRecipient), - (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.Columns.interactionIsOpenGroupInvitation), - - -- These 4 properties will be combined into 'Attachment.DescriptionInfo' - \(attachment[.id]), - \(attachment[.variant]), - \(attachment[.contentType]), - \(attachment[.sourceFilename]), - COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.Columns.interactionAttachmentCount), - - \(interaction[.authorId]), - IFNULL(\(contactProfile[.nickname]), \(contactProfile[.name])) AS \(ViewModel.Columns.threadContactNameInternal), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(SessionThread.self) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) - - LEFT JOIN ( - SELECT - \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), - \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), - MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), - SUM(\(interaction[.wasRead]) = false) AS \(AggregateInteraction.Columns.threadUnreadCount), - SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(AggregateInteraction.Columns.threadUnreadMentionCount), - (SUM(\(interaction[.wasRead]) = false) > 0) AS \(AggregateInteraction.Columns.threadHasUnreadMessagesOfAnyKind) - - FROM \(Interaction.self) - WHERE \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToShowConversationSnippet)")) - GROUP BY \(interaction[.threadId]) - ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) - - LEFT JOIN \(Interaction.self) ON ( - \(interaction[.threadId]) = \(thread[.id]) AND - \(interaction[.id]) = \(aggregateInteraction[.interactionId]) - ) - - LEFT JOIN \(LinkPreview.self) ON ( - \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral()) AND - \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) - ) - LEFT JOIN \(firstInteractionAttachment) ON ( - \(firstInteractionAttachment[.interactionId]) = \(interaction[.id]) AND - \(firstInteractionAttachment[.albumIndex]) = 0 - ) - LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachment[.attachmentId]) - LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) - LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - - -- Thread naming & avatar content - - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroup[.threadId]) IS NOT NULL AND - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userSessionId.hexString)")) - ) - LEFT JOIN \(closedGroupAdminProfile) ON ( - \(closedGroupAdminProfile[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) - ) - ) - ) - - WHERE \(thread[.id]) IN \(ids) - \(groupSQL) - ORDER BY \(orderSQL) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - numColumnsBetweenProfilesAndAttachmentInfo, - Attachment.DescriptionInfo.numberOfSelectedColumns() - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1], - .closedGroupProfileFront: adapters[2], - .closedGroupProfileBack: adapters[3], - .closedGroupProfileBackFallback: adapters[4], - .closedGroupAdminProfile: adapters[5], - .interactionAttachmentDescriptionInfo: adapters[7] - ]) - } - } - - static var optimisedJoinSQL: SQL = { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - - let timestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) - - return """ - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN ( - SELECT - \(interaction[.threadId]), - MAX(\(interaction[.timestampMs])) AS \(timestampMsColumnLiteral) - FROM \(Interaction.self) - WHERE \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToShowConversationSnippet)")) - GROUP BY \(interaction[.threadId]) - ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) - """ - }() - - static func homeFilterSQL(userSessionId: SessionId) -> SQL { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - - return """ - \(thread[.shouldBeVisible]) = true AND - -- Is not a message request - COALESCE(\(closedGroup[.invited]), false) = false AND ( - \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR - \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) OR - \(contact[.isApproved]) = true - ) AND - -- Is not a blocked contact - ( - \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR - \(contact[.isBlocked]) != true - ) - """ - } - - static func messageRequestsFilterSQL(userSessionId: SessionId) -> SQL { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - - return """ - \(thread[.shouldBeVisible]) = true AND ( - -- Is a message request - COALESCE(\(closedGroup[.invited]), false) = true OR ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND - IFNULL(\(contact[.isApproved]), false) = false - ) - ) - """ - } - - static let groupSQL: SQL = { - let thread: TypedTableAlias = TypedTableAlias() - - return SQL("GROUP BY \(thread[.id])") - }() - - static let homeOrderSQL: SQL = { - let thread: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - - return SQL(""" - (IFNULL(\(thread[.pinnedPriority]), 0) > 0) DESC, - IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC, - \(thread[.id]) DESC - """) - }() - - static let messageRequestsOrderSQL: SQL = { - let thread: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - - return SQL(""" - IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC, - \(thread[.id]) DESC - """) - }() -} - -// MARK: - ConversationVC - -public extension SessionThreadViewModel { - /// **Note:** This query **will** include deleted incoming messages in it's unread count (they should never be marked as unread - /// but including this warning just in case there is a discrepancy) - static func conversationQuery(threadId: String, userSessionId: SessionId) -> AdaptedFetchRequest> { - let thread: TypedTableAlias = TypedTableAlias() - let disappearingMessagesConfiguration: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") - let interaction: TypedTableAlias = TypedTableAlias() - let closedGroupUserCount: TypedTableAlias = TypedTableAlias(name: "closedGroupUserCount") - let profile: TypedTableAlias = TypedTableAlias() - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `disappearingMessageSConfiguration` entry below otherwise the query will fail to parse and might throw - /// - /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 18 - let request: SQLRequest = """ - SELECT - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - ( - SELECT \(contactProfile[.id]) - FROM \(contactProfile.self) - LEFT JOIN \(contact.self) ON \(contactProfile[.id]) = \(contact[.id]) - LEFT JOIN \(groupMember.self) ON \(groupMember[.groupId]) = \(threadId) - WHERE ( - (\(groupMember[.profileId]) = \(contactProfile[.id]) OR - \(contact[.id]) = \(threadId)) AND - \(contact[.id]) <> \(userSessionId.hexString) AND - \(contact[.lastKnownClientVersion]) = \(FeatureVersion.legacyDisappearingMessages) - ) - ) AS \(ViewModel.Columns.outdatedMemberId), - ( - COALESCE(\(closedGroup[.invited]), false) = true OR ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND - IFNULL(\(contact[.isApproved]), false) = false - ) - ) AS \(ViewModel.Columns.threadIsMessageRequest), - ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - IFNULL(\(contact[.didApproveMe]), false) = false - ) AS \(ViewModel.Columns.threadRequiresApproval), - \(thread[.shouldBeVisible]) AS \(ViewModel.Columns.threadShouldBeVisible), - - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), - \(thread[.messageDraft]) AS \(ViewModel.Columns.threadMessageDraft), - \(thread[.isDraft]) AS \(ViewModel.Columns.threadIsDraft), - - \(thread[.markedAsUnread]) AS \(ViewModel.Columns.threadWasMarkedUnread), - \(aggregateInteraction[.threadUnreadCount]), - \(aggregateInteraction[.threadHasUnreadMessagesOfAnyKind]), - - \(disappearingMessagesConfiguration.allColumns), - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(contact[.lastKnownClientVersion]) AS \(ViewModel.Columns.contactLastKnownClientVersion), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(closedGroupUserCount[.closedGroupUserCount]), - \(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) AND ( - ( - -- Legacy groups don't have a 'roleStatus' so just let those through - -- based solely on the 'role' - \(groupMember[.groupId]) > \(SessionId.Prefix.standard.rawValue) AND - \(groupMember[.groupId]) < \(SessionId.Prefix.standard.endOfRangeString) - ) OR - \(SQL("\(groupMember[.roleStatus]) = \(GroupMember.RoleStatus.accepted)")) - ) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), - - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - \(openGroup[.server]) AS \(ViewModel.Columns.openGroupServer), - \(openGroup[.roomToken]) AS \(ViewModel.Columns.openGroupRoomToken), - \(openGroup[.publicKey]) AS \(ViewModel.Columns.openGroupPublicKey), - \(openGroup[.userCount]) AS \(ViewModel.Columns.openGroupUserCount), - \(openGroup[.permissions]) AS \(ViewModel.Columns.openGroupPermissions), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(aggregateInteraction[.interactionId]), - \(aggregateInteraction[.interactionTimestampMs]), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(SessionThread.self) - LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfiguration[.threadId]) = \(thread[.id]) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN ( - SELECT - \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), - \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), - MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), - SUM(\(interaction[.wasRead]) = false) AS \(AggregateInteraction.Columns.threadUnreadCount), - 0 AS \(AggregateInteraction.Columns.threadUnreadMentionCount), - (SUM(\(interaction[.wasRead]) = false) > 0) AS \(AggregateInteraction.Columns.threadHasUnreadMessagesOfAnyKind) - FROM \(Interaction.self) - WHERE ( - \(SQL("\(interaction[.threadId]) = \(threadId)")) AND - \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) - ) - ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) - - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroup[.threadId]) IS NOT NULL AND - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userSessionId.hexString)")) - ) - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - COUNT(DISTINCT \(groupMember[.profileId])) AS \(ClosedGroupUserCount.Columns.closedGroupUserCount) - FROM \(GroupMember.self) - WHERE ( - \(SQL("\(groupMember[.groupId]) = \(threadId)")) AND - \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) - ) - ) AS \(closedGroupUserCount) ON \(SQL("\(closedGroupUserCount[.groupId]) = \(threadId)")) - - WHERE \(SQL("\(thread[.id]) = \(threadId)")) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - DisappearingMessagesConfiguration.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .disappearingMessagesConfiguration: adapters[1], - .contactProfile: adapters[2], - .closedGroupProfileFront: adapters[3], - .closedGroupProfileBack: adapters[4], - .closedGroupProfileBackFallback: adapters[5] - ]) - } - } - - static func conversationSettingsQuery(threadId: String, userSessionId: SessionId) -> AdaptedFetchRequest> { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - /// - /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 9 - let request: SQLRequest = """ - SELECT - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), - - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(closedGroup[.groupDescription]) AS \(ViewModel.Columns.closedGroupDescription), - \(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) AND ( - ( - -- Legacy groups don't have a 'roleStatus' so just let those through - -- based solely on the 'role' - \(groupMember[.groupId]) > \(SessionId.Prefix.standard.rawValue) AND - \(groupMember[.groupId]) < \(SessionId.Prefix.standard.endOfRangeString) - ) OR - \(SQL("\(groupMember[.roleStatus]) = \(GroupMember.RoleStatus.accepted)")) - ) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), - - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - \(openGroup[.roomDescription]) AS \(ViewModel.Columns.openGroupDescription), - \(openGroup[.server]) AS \(ViewModel.Columns.openGroupServer), - \(openGroup[.roomToken]) AS \(ViewModel.Columns.openGroupRoomToken), - \(openGroup[.publicKey]) AS \(ViewModel.Columns.openGroupPublicKey), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(SessionThread.self) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroup[.threadId]) IS NOT NULL AND - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userSessionId.hexString)")) - ) - LEFT JOIN \(closedGroupAdminProfile.never) - - WHERE \(SQL("\(thread[.id]) = \(threadId)")) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1], - .closedGroupProfileFront: adapters[2], - .closedGroupProfileBack: adapters[3], - .closedGroupProfileBackFallback: adapters[4], - .closedGroupAdminProfile: adapters[5] - ]) - } - } -} - -// MARK: - Search Queries - -public extension SessionThreadViewModel { - static let searchResultsLimit: Int = 500 - - /// FTS will fail or try to process characters outside of `[A-Za-z0-9]` are included directly in a search - /// term, in order to resolve this the term needs to be wrapped in quotation marks so the eventual SQL - /// is `MATCH '"{term}"'` or `MATCH '"{term}"*'` - static func searchSafeTerm(_ term: String) -> String { - return "\"\(term)\"" - } - - static func searchTermParts(_ searchTerm: String) -> [String] { - /// Process the search term in order to extract the parts of the search pattern we want - /// - /// Step 1 - Keep any "quoted" sections as stand-alone search - /// Step 2 - Separate any words outside of quotes - /// Step 3 - Join the different search term parts with 'OR" (include results for each individual term) - /// Step 4 - Append a wild-card character to the final word (as long as the last word doesn't end in a quote) - let normalisedTerm: String = standardQuotes(searchTerm) - - guard let regex = try? NSRegularExpression(pattern: "[^\\s\"']+|\"([^\"]*)\"") else { - // Fallback to removing the quotes and just splitting on spaces - return normalisedTerm - .replacingOccurrences(of: "\"", with: "") - .split(separator: " ") - .map { "\"\($0)\"" } - .filter { !$0.isEmpty } - } - - return regex - .matches(in: normalisedTerm, range: NSRange(location: 0, length: normalisedTerm.count)) - .compactMap { Range($0.range, in: normalisedTerm) } - .map { normalisedTerm[$0].trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } - .map { "\"\($0)\"" } - } - - static func standardQuotes(_ term: String) -> String { - // Apple like to use the special '""' quote characters when typing so replace them with normal ones - return term - .replacingOccurrences(of: "”", with: "\"") - .replacingOccurrences(of: "“", with: "\"") - } - - static func pattern(_ db: ObservingDatabase, searchTerm: String) throws -> FTS5Pattern { - return try pattern(db, searchTerm: searchTerm, forTable: Interaction.self) - } - - static func pattern(_ db: ObservingDatabase, searchTerm: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { - // Note: FTS doesn't support both prefix/suffix wild cards so don't bother trying to - // add a prefix one - let rawPattern: String = { - let result: String = searchTermParts(searchTerm) - .joined(separator: " OR ") - - // If the last character is a quotation mark then assume the user doesn't want to append - // a wildcard character - guard !standardQuotes(searchTerm).hasSuffix("\"") else { return result } - - return "\(result)*" - }() - let fallbackTerm: String = "\(searchSafeTerm(searchTerm))*" - - /// There are cases where creating a pattern can fail, we want to try and recover from those cases - /// by failling back to simpler patterns if needed - return try { - if let pattern: FTS5Pattern = try? db.makeFTS5Pattern(rawPattern: rawPattern, forTable: table) { - return pattern - } - - if let pattern: FTS5Pattern = try? db.makeFTS5Pattern(rawPattern: fallbackTerm, forTable: table) { - return pattern - } - - return try FTS5Pattern(matchingAnyTokenIn: fallbackTerm) ?? { throw StorageError.invalidSearchPattern }() - }() - } - - static func messagesQuery(userSessionId: SessionId, pattern: FTS5Pattern) -> AdaptedFetchRequest> { - let interaction: TypedTableAlias = TypedTableAlias() - let thread: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let interactionFullTextSearch: TypedTableAlias = TypedTableAlias(name: Interaction.fullTextSearchTableName) - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw - /// - /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 6 - let request: SQLRequest = """ - SELECT - \(interaction[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(interaction[.id]) AS \(ViewModel.Columns.interactionId), - \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), - \(interaction[.timestampMs]) AS \(ViewModel.Columns.interactionTimestampMs), - snippet(\(interactionFullTextSearch), -1, '', '', '...', 6) AS \(ViewModel.Columns.interactionBody), - - \(interaction[.authorId]), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(Interaction.self) - JOIN \(interactionFullTextSearch) ON ( - \(interactionFullTextSearch[.rowId]) = \(interaction[.rowId]) AND - \(interactionFullTextSearch[.body]) MATCH \(pattern) - ) - JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) - JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(interaction[.threadId]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(interaction[.threadId]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) - - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userSessionId.hexString) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userSessionId.hexString) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroup[.threadId]) IS NOT NULL AND - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(userSessionId.hexString) - ) - LEFT JOIN \(closedGroupAdminProfile.never) - - ORDER BY \(Column.rank), \(interaction[.timestampMs].desc) - LIMIT \(SQL("\(SessionThreadViewModel.searchResultsLimit)")) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1], - .closedGroupProfileFront: adapters[2], - .closedGroupProfileBack: adapters[3], - .closedGroupProfileBackFallback: adapters[4], - .closedGroupAdminProfile: adapters[5] - ]) - } - } - - /// This method does an FTS search against threads and their contacts to find any which contain the pattern - /// - /// **Note:** Unfortunately the FTS search only allows for a single pattern match per query which means we - /// need to combine the results of **all** of the following potential matches as unioned queries: - /// - Contact thread contact nickname - /// - Contact thread contact name - /// - Closed group name - /// - Closed group member nickname - /// - Closed group member name - /// - Open group name - /// - "Note to self" text match - /// - Hidden contact nickname - /// - Hidden contact name - /// - /// **Note 2:** Since the "Hidden Contact" records don't have associated threads the `rowId` value in the - /// returned results will always be `-1` for those results - static func contactsAndGroupsQuery(userSessionId: SessionId, pattern: FTS5Pattern, searchTerm: String) -> AdaptedFetchRequest> { - let thread: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) - let groupMember: TypedTableAlias = TypedTableAlias() - let groupMemberProfile: TypedTableAlias = TypedTableAlias(name: "groupMemberProfile") - let openGroup: TypedTableAlias = TypedTableAlias() - let groupMemberInfo: TypedTableAlias = TypedTableAlias(name: "groupMemberInfo") - let profile: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let profileFullTextSearch: TypedTableAlias = TypedTableAlias(name: Profile.fullTextSearchTableName) - let closedGroupFullTextSearch: TypedTableAlias = TypedTableAlias(name: ClosedGroup.fullTextSearchTableName) - let openGroupFullTextSearch: TypedTableAlias = TypedTableAlias(name: OpenGroup.fullTextSearchTableName) - - let noteToSelfLiteral: SQL = SQL(stringLiteral: "noteToSelf".localized().lowercased()) - let searchTermLiteral: SQL = SQL(stringLiteral: searchTerm.lowercased()) - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - /// - /// We use `IFNULL(rank, 100)` because the custom `Note to Self` like comparison will get a null - /// `rank` value which ends up as the first result, by defaulting to `100` it will always be ranked last compared - /// to any relevance-based results - let numColumnsBeforeProfiles: Int = 8 - var sqlQuery: SQL = "" - let selectQuery: SQL = """ - SELECT - IFNULL(\(Column.rank), 100) AS \(Column.rank), - - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - \(groupMemberInfo[.threadMemberNames]), - - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(SessionThread.self) - - """ - - // MARK: --Contact Threads - let contactQueryCommonJoinFilterGroup: SQL = """ - JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(closedGroupProfileFront.never) - LEFT JOIN \(closedGroupProfileBack.never) - LEFT JOIN \(closedGroupProfileBackFallback.never) - LEFT JOIN \(closedGroupAdminProfile.never) - LEFT JOIN \(closedGroup.never) - LEFT JOIN \(openGroup.never) - LEFT JOIN \(groupMemberInfo.never) - - WHERE - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) - GROUP BY \(thread[.id]) - """ - - // Contact thread nickname searching (ignoring note to self - handled separately) - sqlQuery += selectQuery - sqlQuery += """ - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND - \(profileFullTextSearch[.nickname]) MATCH \(pattern) - ) - """ - sqlQuery += contactQueryCommonJoinFilterGroup - - // Contact thread name searching (ignoring note to self - handled separately) - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND - \(profileFullTextSearch[.name]) MATCH \(pattern) - ) - """ - sqlQuery += contactQueryCommonJoinFilterGroup - - // MARK: --Closed Group Threads - let closedGroupQueryCommonJoinFilterGroup: SQL = """ - JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - JOIN \(GroupMember.self) ON ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(thread[.id]) - ) - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(GroupMemberInfo.Columns.threadMemberNames) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - GROUP BY \(groupMember[.groupId]) - ) AS \(groupMemberInfo) ON \(groupMemberInfo[.groupId]) = \(closedGroup[.threadId]) - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userSessionId.hexString) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userSessionId.hexString) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(userSessionId.hexString) - ) - LEFT JOIN \(closedGroupAdminProfile.never) - - LEFT JOIN \(contactProfile.never) - LEFT JOIN \(openGroup.never) - - WHERE ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyGroup)")) OR - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.group)")) - ) - GROUP BY \(thread[.id]) - """ - - // Closed group thread name searching - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - JOIN \(closedGroupFullTextSearch) ON ( - \(closedGroupFullTextSearch[.rowId]) = \(closedGroup[.rowId]) AND - \(closedGroupFullTextSearch[.name]) MATCH \(pattern) - ) - """ - sqlQuery += closedGroupQueryCommonJoinFilterGroup - - // Closed group member nickname searching - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - JOIN \(groupMemberProfile) ON \(groupMemberProfile[.id]) = \(groupMember[.profileId]) - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(groupMemberProfile[.rowId]) AND - \(profileFullTextSearch[.nickname]) MATCH \(pattern) - ) - """ - sqlQuery += closedGroupQueryCommonJoinFilterGroup - - // Closed group member name searching - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - JOIN \(groupMemberProfile) ON \(groupMemberProfile[.id]) = \(groupMember[.profileId]) - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(groupMemberProfile[.rowId]) AND - \(profileFullTextSearch[.name]) MATCH \(pattern) - ) - """ - sqlQuery += closedGroupQueryCommonJoinFilterGroup - - // MARK: --Open Group Threads - // Open group thread name searching - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - JOIN \(openGroupFullTextSearch) ON ( - \(openGroupFullTextSearch[.rowId]) = \(openGroup[.rowId]) AND - \(openGroupFullTextSearch[.name]) MATCH \(pattern) - ) - LEFT JOIN \(contactProfile.never) - LEFT JOIN \(closedGroupProfileFront.never) - LEFT JOIN \(closedGroupProfileBack.never) - LEFT JOIN \(closedGroupProfileBackFallback.never) - LEFT JOIN \(closedGroupAdminProfile.never) - LEFT JOIN \(closedGroup.never) - LEFT JOIN \(groupMemberInfo.never) - - WHERE - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) - GROUP BY \(thread[.id]) - """ - - // MARK: --Note to Self Thread - let noteToSelfQueryCommonJoins: SQL = """ - JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(closedGroupProfileFront.never) - LEFT JOIN \(closedGroupProfileBack.never) - LEFT JOIN \(closedGroupProfileBackFallback.never) - LEFT JOIN \(closedGroupAdminProfile.never) - LEFT JOIN \(openGroup.never) - LEFT JOIN \(closedGroup.never) - LEFT JOIN \(groupMemberInfo.never) - """ - - // Note to self thread searching for 'Note to Self' (need to join an FTS table to - // ensure there is a 'rank' column) - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - - LEFT JOIN \(profileFullTextSearch) ON false - """ - sqlQuery += noteToSelfQueryCommonJoins - sqlQuery += """ - - WHERE - \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) AND - '\(noteToSelfLiteral)' LIKE '%\(searchTermLiteral)%' - """ - - // Note to self thread nickname searching - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND - \(profileFullTextSearch[.nickname]) MATCH \(pattern) - ) - """ - sqlQuery += noteToSelfQueryCommonJoins - sqlQuery += """ - - WHERE \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) - """ - - // Note to self thread name searching - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND - \(profileFullTextSearch[.name]) MATCH \(pattern) - ) - """ - sqlQuery += noteToSelfQueryCommonJoins - sqlQuery += """ - - WHERE \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) - """ - - // MARK: --Contacts without threads - let hiddenContactQuery: SQL = """ - SELECT - IFNULL(\(Column.rank), 100) AS \(Column.rank), - - -1 AS \(ViewModel.Columns.rowId), - \(contact[.id]) AS \(ViewModel.Columns.threadId), - \(SQL("\(SessionThread.Variant.contact)")) AS \(ViewModel.Columns.threadVariant), - 0 AS \(ViewModel.Columns.threadCreationDateTimestamp), - \(groupMemberInfo[.threadMemberNames]), - - false AS \(ViewModel.Columns.threadIsNoteToSelf), - -1 AS \(ViewModel.Columns.threadPinnedPriority), - - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(Contact.self) - """ - let hiddenContactQueryCommonJoins: SQL = """ - JOIN \(contactProfile) ON \(contactProfile[.id]) = \(contact[.id]) - LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(contact[.id]) - LEFT JOIN \(closedGroupProfileFront.never) - LEFT JOIN \(closedGroupProfileBack.never) - LEFT JOIN \(closedGroupProfileBackFallback.never) - LEFT JOIN \(closedGroupAdminProfile.never) - LEFT JOIN \(closedGroup.never) - LEFT JOIN \(openGroup.never) - LEFT JOIN \(groupMemberInfo.never) - - WHERE \(thread[.id]) IS NULL - GROUP BY \(contact[.id]) - """ - - // Hidden contact by nickname - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += hiddenContactQuery - sqlQuery += """ - - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND - \(profileFullTextSearch[.nickname]) MATCH \(pattern) - ) - """ - sqlQuery += hiddenContactQueryCommonJoins - - // Hidden contact by name - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += hiddenContactQuery - sqlQuery += """ - - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND - \(profileFullTextSearch[.name]) MATCH \(pattern) - ) - """ - sqlQuery += hiddenContactQueryCommonJoins - - // Group everything by 'threadId' (the same thread can be found in multiple queries due - // to seaerching both nickname and name), then order everything by 'rank' (relevance) - // first, 'Note to Self' second (want it to appear at the bottom of threads unless it - // has relevance) adn then try to group and sort based on thread type and names - let finalQuery: SQL = """ - SELECT * - FROM ( - \(sqlQuery) - ) - - GROUP BY \(ViewModel.Columns.threadId) - ORDER BY - \(Column.rank), - \(ViewModel.Columns.threadIsNoteToSelf), - \(ViewModel.Columns.closedGroupName), - \(ViewModel.Columns.openGroupName), - \(ViewModel.Columns.threadId) - LIMIT \(SQL("\(SessionThreadViewModel.searchResultsLimit)")) - """ - - // Construct the actual request - let request: SQLRequest = SQLRequest( - literal: finalQuery, - adapter: RenameColumnAdapter { column in - // Note: The query automatically adds a suffix to the various profile columns - // to make them easier to distinguish (ie. 'id' -> 'id:1') - this breaks the - // decoding so we need to strip the information after the colon - guard column.contains(":") else { return column } - - return String(column.split(separator: ":")[0]) - }, - cached: false - ) - - // Add adapters which will group the various 'Profile' columns so they can be decoded - // as instances of 'Profile' types - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1], - .closedGroupProfileFront: adapters[2], - .closedGroupProfileBack: adapters[3], - .closedGroupProfileBackFallback: adapters[4], - .closedGroupAdminProfile: adapters[5] - ]) - } - } - - static func defaultContactsQuery(using dependencies: Dependencies) -> AdaptedFetchRequest> { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let currentTimestamp: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - let numColumnsBeforeProfiles: Int = 9 - let request: SQLRequest = """ - SELECT - 100 AS \(Column.rank), - - \(contact[.rowId]) AS \(ViewModel.Columns.rowId), - \(contact[.id]) AS \(ViewModel.Columns.threadId), - \(contact[.isApproved]) AS \(ViewModel.Columns.isContactApproved), - \(SessionThread.Variant.contact) AS \(ViewModel.Columns.threadVariant), - IFNULL(\(thread[.creationDateTimestamp]), \(currentTimestamp)) AS \(ViewModel.Columns.threadCreationDateTimestamp), - '' AS \(ViewModel.Columns.threadMemberNames), - - (\(SQL("\(contact[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - - \(contactProfile.allColumns), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(Contact.self) - LEFT JOIN \(thread) ON \(thread[.id]) = \(contact[.id]) - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(contact[.id]) - WHERE \(contact[.isBlocked]) = false - """ - - // Add adapters which will group the various 'Profile' columns so they can be decoded - // as instances of 'Profile' types - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1] - ]) - } - } - - /// This method returns only the 'Note to Self' thread in the structure of a search result conversation - static func noteToSelfOnlyQuery(userSessionId: SessionId) -> AdaptedFetchRequest> { - let thread: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - let numColumnsBeforeProfiles: Int = 8 - let request: SQLRequest = """ - SELECT - 100 AS \(Column.rank), - - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - '' AS \(ViewModel.Columns.threadMemberNames), - - true AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - - \(contactProfile.allColumns), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(SessionThread.self) - JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - - WHERE \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) - """ - - // Add adapters which will group the various 'Profile' columns so they can be decoded - // as instances of 'Profile' types - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1] - ]) - } - } -} - -// MARK: - Share Extension - -public extension SessionThreadViewModel { - static func shareQuery(userSessionId: SessionId) -> AdaptedFetchRequest> { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") - let interaction: TypedTableAlias = TypedTableAlias() - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - /// - /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 9 - - let request: SQLRequest = """ - SELECT - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - ( - COALESCE(\(closedGroup[.invited]), false) = true OR ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND - IFNULL(\(contact[.isApproved]), false) = false - ) - ) AS \(ViewModel.Columns.threadIsMessageRequest), - ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - IFNULL(\(contact[.didApproveMe]), false) = false - ) AS \(ViewModel.Columns.threadRequiresApproval), - - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), - - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), - - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - \(openGroup[.permissions]) AS \(ViewModel.Columns.openGroupPermissions), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(interaction[.id]) AS \(ViewModel.Columns.interactionId), - \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(SessionThread.self) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - - LEFT JOIN ( - SELECT - \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), - \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), - MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), - 0 AS \(AggregateInteraction.Columns.threadUnreadCount), - 0 AS \(AggregateInteraction.Columns.threadUnreadMentionCount) - FROM \(Interaction.self) - WHERE \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToShowConversationSnippet)")) - GROUP BY \(interaction[.threadId]) - ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) - LEFT JOIN \(Interaction.self) ON ( - \(interaction[.threadId]) = \(thread[.id]) AND - \(interaction[.id]) = \(aggregateInteraction[.interactionId]) - ) - - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroup[.threadId]) IS NOT NULL AND - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userSessionId.hexString)")) - ) - LEFT JOIN \(closedGroupAdminProfile.never) - - WHERE ( - \(thread[.shouldBeVisible]) = true AND - COALESCE(\(closedGroup[.invited]), false) = false AND ( - -- Is not a message request - \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR - \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) OR - \(contact[.isApproved]) = true - ) - -- Always show the 'Note to Self' thread when sharing - OR \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) - ) - - GROUP BY \(thread[.id]) - -- 'Note to Self', then by most recent message - ORDER BY \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) DESC, IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1], - .closedGroupProfileFront: adapters[2], - .closedGroupProfileBack: adapters[3], - .closedGroupProfileBackFallback: adapters[4], - .closedGroupAdminProfile: adapters[5] - ]) - } - } -} diff --git a/SessionMessagingKit/Types/Constants+LibSession.swift b/SessionMessagingKit/Types/Constants+LibSession.swift new file mode 100644 index 0000000000..f210b0a8fb --- /dev/null +++ b/SessionMessagingKit/Types/Constants+LibSession.swift @@ -0,0 +1,125 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUIKit +import SessionUtil +import SessionUtilitiesKit + +public extension Constants { + static let urls: GeneralUrls = GeneralUrls(SESSION_PROTOCOL_STRINGS) + static let buildVariants: BuildVariants = BuildVariants(SESSION_PROTOCOL_STRINGS, PaymentProvider.appStore) + + enum PaymentProvider { + private static let metadata: [session_pro_backend_payment_provider_metadata] = [ + SESSION_PRO_BACKEND_PAYMENT_PROVIDER_METADATA.0, /// Empty + SESSION_PRO_BACKEND_PAYMENT_PROVIDER_METADATA.1, /// Google + SESSION_PRO_BACKEND_PAYMENT_PROVIDER_METADATA.2 /// Apple + ] + + public static let appStore: Info = Info(metadata[Int(SESSION_PRO_BACKEND_PAYMENT_PROVIDER_IOS_APP_STORE.rawValue)]) + public static let playStore: Info = Info(metadata[Int(SESSION_PRO_BACKEND_PAYMENT_PROVIDER_GOOGLE_PLAY_STORE.rawValue)]) + } +} + +public extension Constants { + struct GeneralUrls: StringProvider.Url { + public let donations: String + public let donationsApp: String + public let download: String + public let faq: String + public let feedback: String + public let network: String + public let privacyPolicy: String + public let proAccessNotFound: String + public let proFaq: String + public let proPrivacyPolicy: String + public let proRoadmap: String + public let proSupport: String + public let proTermsOfService: String + public let staking: String + public let support: String + public let survey: String + public let termsOfService: String + public let token: String + public let translate: String + + fileprivate init(_ libSessionValue: session_protocol_strings) { + self.donations = libSessionValue.get(\.url_donations) + self.donationsApp = libSessionValue.get(\.url_donations_app) + self.download = libSessionValue.get(\.url_download) + self.faq = libSessionValue.get(\.url_faq) + self.feedback = libSessionValue.get(\.url_feedback) + self.network = libSessionValue.get(\.url_network) + self.privacyPolicy = libSessionValue.get(\.url_privacy_policy) + self.proAccessNotFound = libSessionValue.get(\.url_pro_access_not_found) + self.proFaq = libSessionValue.get(\.url_pro_faq) + self.proPrivacyPolicy = libSessionValue.get(\.url_pro_privacy_policy) + self.proRoadmap = libSessionValue.get(\.url_pro_roadmap) + self.proSupport = libSessionValue.get(\.url_pro_support) + self.proTermsOfService = libSessionValue.get(\.url_pro_terms_of_service) + self.staking = libSessionValue.get(\.url_staking) + self.support = libSessionValue.get(\.url_support) + self.survey = libSessionValue.get(\.url_survey) + self.termsOfService = libSessionValue.get(\.url_terms_of_service) + self.token = libSessionValue.get(\.url_token) + self.translate = libSessionValue.get(\.url_translate) + } + } + + struct BuildVariants: StringProvider.BuildVariant { + public let apk: String + public var appStore: String + public var development: String + public let fDroid: String + public let huawei: String + public let ipa: String + public var testFlight: String + + fileprivate init(_ libSessionValue: session_protocol_strings, _ iOSPaymentProvider: PaymentProvider.Info) { + self.apk = libSessionValue.get(\.build_variant_apk) + self.appStore = iOSPaymentProvider.store + self.development = "Development" // stringlint:ignore + self.fDroid = libSessionValue.get(\.build_variant_fdroid) + self.huawei = libSessionValue.get(\.build_variant_huawei) + self.ipa = libSessionValue.get(\.build_variant_ipa) + self.testFlight = "TestFlight" // stringlint:ignore + } + } +} + +public extension Constants.PaymentProvider { + struct Info: StringProvider.ClientPlatform { + public let device: String + public let store: String + public let platform: String + public let platformAccount: String + public let refundPlatformUrl: String + + /// Some platforms disallow a refund via their native support channels after some time period + /// (e.g. 48 hours after a purchase on Google, refunds must be dealt by the developers + /// themselves). If a platform does not have this restriction, this URL is typically the same as + /// the `refund_platform_url`. + public let refundSupportUrl: String + + public let refundStatusUrl: String + public let updateSubscriptionUrl: String + public let cancelSubscriptionUrl: String + + fileprivate init(_ libSessionValue: session_pro_backend_payment_provider_metadata) { + self.device = libSessionValue.get(\.device) + self.store = libSessionValue.get(\.store) + self.platform = libSessionValue.get(\.platform) + self.platformAccount = libSessionValue.get(\.platform_account) + self.refundPlatformUrl = libSessionValue.get(\.refund_platform_url) + + self.refundSupportUrl = libSessionValue.get(\.refund_support_url) + + self.refundStatusUrl = libSessionValue.get(\.refund_status_url) + self.updateSubscriptionUrl = libSessionValue.get(\.update_subscription_url) + self.cancelSubscriptionUrl = libSessionValue.get(\.cancel_subscription_url) + } + } +} + +extension session_protocol_strings: @retroactive CAccessible {} +extension session_pro_backend_payment_provider_metadata: @retroactive CAccessible {} diff --git a/SessionMessagingKit/Types/ConversationDataCache.swift b/SessionMessagingKit/Types/ConversationDataCache.swift new file mode 100644 index 0000000000..b7a8cd5ae2 --- /dev/null +++ b/SessionMessagingKit/Types/ConversationDataCache.swift @@ -0,0 +1,400 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUIKit +import SessionUtilitiesKit + +public typealias ConversationDataCacheItemRequirements = (Sendable & Equatable & Hashable & Identifiable) + +public struct ConversationDataCache: Sendable, Equatable, Hashable { + public let userSessionId: SessionId + public fileprivate(set) var context: Context + + // MARK: - General + + /// Stores `profileId -> Profile` (`threadId` for contact threads) + public fileprivate(set) var profiles: [String: Profile] = [:] + + // MARK: - Thread Data + + /// Stores `threadId -> SessionThread` + public fileprivate(set) var threads: [String: SessionThread] = [:] + + /// Stores `contactId -> Contact` (`threadId` for contact threads) + public fileprivate(set) var contacts: [String: Contact] = [:] + + /// Stores `threadId -> ClosedGroup` + public fileprivate(set) var groups: [String: ClosedGroup] = [:] + + /// Stores `threadId -> GroupInfo` + public fileprivate(set) var groupInfo: [String: LibSession.GroupInfo] = [:] + + /// Stores `threadId -> members` + public fileprivate(set) var groupMembers: [String: [GroupMember]] = [:] + + /// Stores `threadId -> OpenGroup` + public fileprivate(set) var communities: [String: OpenGroup] = [:] + + /// Stores `openGroup.server -> capabilityVariants` + public fileprivate(set) var communityCapabilities: [String: Set] = [:] + + /// Stores `threadId -> modAdminIds` + public fileprivate(set) var communityModAdminIds: [String: Set] = [:] + + /// Stores `threadId -> isUserModeratorOrAdmin` + public fileprivate(set) var userModeratorOrAdmin: [String: Bool] = [:] + + /// Stores `threadId -> DisappearingMessagesConfig` + public fileprivate(set) var disappearingMessagesConfigurations: [String: DisappearingMessagesConfiguration] = [:] + + /// Stores `threadId -> interactionStats` + public fileprivate(set) var interactionStats: [String: ConversationInfoViewModel.InteractionStats] = [:] + + /// Stores `threadId -> currentUserSessionIds` + public fileprivate(set) var currentUserSessionIds: [String: Set] = [:] + + // MARK: - Message Data + + /// Stores `interactionId -> Interaction` + public fileprivate(set) var interactions: [Int64: Interaction] = [:] + + /// Stores `interactionId -> interactionAttachments` + public fileprivate(set) var attachmentMap: [Int64: Set] = [:] + + /// Stores `attachmentId -> Attachment` + public fileprivate(set) var attachments: [String: Attachment] = [:] + + /// Stores `interactionId -> MaybeUnresolvedQuotedInfo` + public fileprivate(set) var quoteMap: [Int64: MessageViewModel.MaybeUnresolvedQuotedInfo] = [:] + + /// Stores `url -> previews` + public fileprivate(set) var linkPreviews: [String: Set] = [:] + + /// Stores `interactionId -> reactions` + public fileprivate(set) var reactions: [Int64: [Reaction]] = [:] + + /// Stores `blindedId -> unblindedId` + public fileprivate(set) var unblindedIdMap: [String: String] = [:] + + // MARK: - UI State + + /// Stores `threadIds` for conversations with incoming typing + public fileprivate(set) var incomingTyping: Set = [] + + // MARK: - Initialization + + public init(userSessionId: SessionId, context: Context) { + self.userSessionId = userSessionId + self.context = context + } +} + +// MARK: - Read Operations + +public extension ConversationDataCache { + func profile(for id: String) -> Profile? { profiles[id] } + func thread(for id: String) -> SessionThread? { threads[id] } + func contact(for threadId: String) -> Contact? { contacts[threadId] } + func group(for threadId: String) -> ClosedGroup? { groups[threadId] } + func groupInfo(for threadId: String) -> LibSession.GroupInfo? { groupInfo[threadId] } + func groupMembers(for threadId: String) -> [GroupMember] { (groupMembers[threadId] ?? []) } + func community(for threadId: String) -> OpenGroup? { communities[threadId] } + func communityCapabilities(for server: String) -> Set { + (communityCapabilities[server] ?? []) + } + func communityModAdminIds(for threadId: String) -> Set { (communityModAdminIds[threadId] ?? []) } + func isUserModeratorOrAdmin(in threadId: String) -> Bool { (userModeratorOrAdmin[threadId] ?? false) } + func disappearingMessageConfiguration(for threadId: String) -> DisappearingMessagesConfiguration? { + disappearingMessagesConfigurations[threadId] + } + func interactionStats(for threadId: String) -> ConversationInfoViewModel.InteractionStats? { + interactionStats[threadId] + } + func currentUserSessionIds(for threadId: String) -> Set { + return (currentUserSessionIds[threadId] ?? [userSessionId.hexString]) + } + + func interaction(for id: Int64) -> Interaction? { interactions[id] } + func attachment(for id: String) -> Attachment? { attachments[id] } + func attachments(for interactionId: Int64) -> [Attachment] { + guard let interactionAttachments: Set = attachmentMap[interactionId] else { + return [] + } + + return interactionAttachments + .sorted { $0.albumIndex < $1.albumIndex } + .compactMap { attachments[$0.attachmentId] } + } + func interactionAttachments(for interactionId: Int64) -> Set { + (attachmentMap[interactionId] ?? []) + } + func quoteInfo(for interactionId: Int64) -> MessageViewModel.MaybeUnresolvedQuotedInfo? { + quoteMap[interactionId] + } + func linkPreviews(for url: String) -> Set { (linkPreviews[url] ?? []) } + func reactions(for interactionId: Int64) -> [Reaction] { (reactions[interactionId] ?? []) } + func unblindedId(for blindedId: String) -> String? { unblindedIdMap[blindedId] } + func isTyping(in threadId: String) -> Bool { incomingTyping.contains(threadId) } + + func displayNameRetriever(for threadId: String, includeSessionIdSuffixWhenInMessageBody: Bool) -> DisplayNameRetriever { + let currentUserSessionIds: Set = currentUserSessionIds(for: threadId) + + return { sessionId, inMessageBody in + guard !currentUserSessionIds.contains(sessionId) else { + return "you".localized() + } + + return profile(for: sessionId)?.displayName( + includeSessionIdSuffix: (includeSessionIdSuffixWhenInMessageBody && inMessageBody) + ) + } + } +} + +// MARK: - Write Operations + +public extension ConversationDataCache { + mutating func withContext( + source: Context.Source, + requireFullRefresh: Bool = false, + requireAuthMethodFetch: Bool = false, + requiresMessageRequestCountUpdate: Bool = false, + requiresInitialUnreadInteractionInfo: Bool = false, + requireRecentReactionEmojiUpdate: Bool = false + ) { + self.context = Context( + source: source, + requireFullRefresh: requireFullRefresh, + requireAuthMethodFetch: requireAuthMethodFetch, + requiresMessageRequestCountUpdate: requiresMessageRequestCountUpdate, + requiresInitialUnreadInteractionInfo: requiresInitialUnreadInteractionInfo, + requireRecentReactionEmojiUpdate: requireRecentReactionEmojiUpdate + ) + } + + mutating func insert(_ profile: Profile) { + self.profiles[profile.id] = profile + } + + mutating func insert(profiles: [Profile]) { + profiles.forEach { self.profiles[$0.id] = $0 } + } + + mutating func insert(_ thread: SessionThread) { + self.threads[thread.id] = thread + } + + mutating func insert(threads: [SessionThread]) { + threads.forEach { self.threads[$0.id] = $0 } + } + + mutating func insert(_ contact: Contact) { + self.contacts[contact.id] = contact + } + + mutating func insert(contacts: [Contact]) { + contacts.forEach { self.contacts[$0.id] = $0 } + } + + mutating func insert(_ group: ClosedGroup) { + self.groups[group.threadId] = group + } + + mutating func insert(groups: [ClosedGroup]) { + groups.forEach { self.groups[$0.threadId] = $0 } + } + + mutating func insert(_ groupInfo: LibSession.GroupInfo) { + self.groupInfo[groupInfo.groupSessionId] = groupInfo + } + + mutating func insert(groupInfo: [LibSession.GroupInfo]) { + groupInfo.forEach { self.groupInfo[$0.groupSessionId] = $0 } + } + + mutating func insert(groupMembers: [String: [GroupMember]]) { + self.groupMembers.merge(groupMembers) { _, new in new } + } + + mutating func insert(_ community: OpenGroup) { + self.communities[community.threadId] = community + } + + mutating func insert(communities: [OpenGroup]) { + communities.forEach { self.communities[$0.threadId] = $0 } + } + + mutating func insert(communityCapabilities: [String: Set]) { + self.communityCapabilities.merge(communityCapabilities) { _, new in new } + } + + mutating func insert(communityModAdminIds: [String: Set]) { + self.communityModAdminIds.merge(communityModAdminIds) { _, new in new } + } + + mutating func insert(isUserModeratorOrAdmin: Bool, in threadId: String) { + self.userModeratorOrAdmin[threadId] = isUserModeratorOrAdmin + } + + mutating func insert(_ config: DisappearingMessagesConfiguration) { + self.disappearingMessagesConfigurations[config.threadId] = config + } + + mutating func insert(disappearingMessagesConfigurations configs: [DisappearingMessagesConfiguration]) { + configs.forEach { self.disappearingMessagesConfigurations[$0.threadId] = $0 } + } + + mutating func insert(_ stats: ConversationInfoViewModel.InteractionStats) { + self.interactionStats[stats.threadId] = stats + } + + mutating func insert(interactionStats: [ConversationInfoViewModel.InteractionStats]) { + interactionStats.forEach { self.interactionStats[$0.threadId] = $0 } + } + + mutating func setCurrentUserSessionIds(_ currentUserSessionIds: [String: Set]) { + self.currentUserSessionIds = currentUserSessionIds + } + + mutating func insert(_ interaction: Interaction) { + guard let id: Int64 = interaction.id else { return } + + self.interactions[id] = interaction + } + + mutating func insert(interactions: [Interaction]) { + interactions.forEach { interaction in + guard let id: Int64 = interaction.id else { return } + + self.interactions[id] = interaction + } + } + + mutating func insert(_ attachment: Attachment) { + self.attachments[attachment.id] = attachment + } + + mutating func insert(attachments: [Attachment]) { + attachments.forEach { self.attachments[$0.id] = $0 } + } + + mutating func insert(attachmentMap: [Int64: Set]) { + self.attachmentMap.merge(attachmentMap) { _, new in new } + + /// Remove any empty lists + attachmentMap.forEach { key, value in + guard value.isEmpty else { return } + + self.attachmentMap.removeValue(forKey: key) + } + } + + mutating func insert(quoteMap: [Int64: MessageViewModel.MaybeUnresolvedQuotedInfo]) { + self.quoteMap.merge(quoteMap) { _, new in new } + } + + mutating func insert(linkPreviews: [LinkPreview]) { + linkPreviews.forEach { preview in + self.linkPreviews[preview.url, default: []].insert(preview) + } + } + + mutating func insert(reactions: [Int64: [Reaction]]) { + let sortedReactions: [Int64: [Reaction]] = reactions.mapValues { + $0.sorted { lhs, rhs in lhs.sortId < rhs.sortId } + } + self.reactions.merge(sortedReactions) { _, new in new } + + /// Remove any empty lists + reactions.forEach { key, value in + guard value.isEmpty else { return } + + self.reactions.removeValue(forKey: key) + } + } + + mutating func insert(unblindedIdMap: [String: String]) { + self.unblindedIdMap.merge(unblindedIdMap) { _, new in new } + } + + mutating func setTyping(_ isTyping: Bool, in threadId: String) { + if isTyping { + self.incomingTyping.insert(threadId) + } else { + self.incomingTyping.remove(threadId) + } + } + + mutating func remove(threadIds: Set) { + threadIds.forEach { threadId in + self.threads.removeValue(forKey: threadId) + self.contacts.removeValue(forKey: threadId) + self.groups.removeValue(forKey: threadId) + self.groupInfo.removeValue(forKey: threadId) + self.groupMembers.removeValue(forKey: threadId) + self.communities.removeValue(forKey: threadId) + self.communityModAdminIds.removeValue(forKey: threadId) + self.userModeratorOrAdmin.removeValue(forKey: threadId) + self.disappearingMessagesConfigurations.removeValue(forKey: threadId) + self.interactionStats.removeValue(forKey: threadId) + self.incomingTyping.remove(threadId) + + let interactions: [Interaction] = Array(self.interactions.values) + interactions.forEach { interaction in + guard + let interactionId: Int64 = interaction.id, + interaction.threadId == threadId + else { return } + + self.interactions.removeValue(forKey: interactionId) + self.attachmentMap[interactionId]?.forEach { attachments.removeValue(forKey: $0.attachmentId) } + self.attachmentMap.removeValue(forKey: interactionId) + } + } + } + + mutating func remove(interactionIds: Set) { + interactionIds.forEach { id in + self.interactions.removeValue(forKey: id) + self.reactions.removeValue(forKey: id) + self.attachmentMap[id]?.forEach { + self.attachments.removeValue(forKey: $0.attachmentId) + } + self.attachmentMap.removeValue(forKey: id) + } + } + + mutating func remove(interactionStatsForThreadIds: Set) { + interactionStatsForThreadIds.forEach { id in + guard let stats: ConversationInfoViewModel.InteractionStats = self.interactionStats[id] else { + return + } + + self.interactionStats.removeValue(forKey: id) + self.interactions.removeValue(forKey: stats.latestInteractionId) + self.attachmentMap[stats.latestInteractionId]?.forEach { attachments.removeValue(forKey: $0.attachmentId) } + self.attachmentMap.removeValue(forKey: stats.latestInteractionId) + } + } + + mutating func removeAttachmentMap(for interactionId: Int64) { + self.attachmentMap.removeValue(forKey: interactionId) + } +} + +// MARK: - Convenience + +public extension ConversationDataCache { + func contactDisplayName(for threadId: String) -> String { + /// We expect a non-nullable string so if it's invalid just return an empty string + guard + let thread: SessionThread = thread(for: threadId), + thread.variant == .contact + else { return "" } + + let profile: Profile = (profile(for: thread.id) ?? Profile.defaultFor(thread.id)) + + return profile.displayName() + } +} diff --git a/SessionMessagingKit/Types/ConversationDataHelper.swift b/SessionMessagingKit/Types/ConversationDataHelper.swift new file mode 100644 index 0000000000..f35a95dc72 --- /dev/null +++ b/SessionMessagingKit/Types/ConversationDataHelper.swift @@ -0,0 +1,1113 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUIKit +import SessionUtilitiesKit + +public enum ConversationDataHelper {} + +public extension ConversationDataCache { + struct Context: Sendable, Equatable, Hashable { + public enum Source: Sendable, Equatable, Hashable { + case conversationList + case messageList(threadId: String) + case conversationSettings(threadId: String) + case searchResults + } + + let source: Source + let requireFullRefresh: Bool + let requireAuthMethodFetch: Bool + let requiresMessageRequestCountUpdate: Bool + let requiresInitialUnreadInteractionInfo: Bool + let requireRecentReactionEmojiUpdate: Bool + + // MARK: - Initialization + + public init( + source: Source, + requireFullRefresh: Bool, + requireAuthMethodFetch: Bool, + requiresMessageRequestCountUpdate: Bool, + requiresInitialUnreadInteractionInfo: Bool, + requireRecentReactionEmojiUpdate: Bool + ) { + self.source = source + self.requireFullRefresh = requireFullRefresh + self.requireAuthMethodFetch = requireAuthMethodFetch + self.requiresMessageRequestCountUpdate = requiresMessageRequestCountUpdate + self.requiresInitialUnreadInteractionInfo = requiresInitialUnreadInteractionInfo + self.requireRecentReactionEmojiUpdate = requireRecentReactionEmojiUpdate + } + + // MARK: - Functions + + func insertedItemIds(_ requirements: ConversationDataHelper.FetchRequirements, as: ID.Type) -> Set { + switch source { + case .searchResults, .conversationSettings: return [] + case .conversationList: return (requirements.insertedThreadIds as? Set ?? []) + case .messageList: return (requirements.insertedInteractionIds as? Set ?? []) + } + } + + func deletedItemIds(_ requirements: ConversationDataHelper.FetchRequirements, as: ID.Type) -> Set { + switch source { + case .searchResults, .conversationSettings: return [] + case .conversationList: return (requirements.deletedThreadIds as? Set ?? []) + case .messageList: return (requirements.deletedInteractionIds as? Set ?? []) + } + } + } +} + +public extension ConversationDataHelper { + static func determineFetchRequirements( + for changes: EventChangeset, + currentCache: ConversationDataCache, + itemCache: [Item.ID: Item], + loadPageEvent: LoadPageEvent? + ) -> FetchRequirements { + var requirements: FetchRequirements = FetchRequirements( + requireAuthMethodFetch: currentCache.context.requireAuthMethodFetch, + requiresMessageRequestCountUpdate: currentCache.context.requiresMessageRequestCountUpdate, + requiresInitialUnreadInteractionInfo: currentCache.context.requiresInitialUnreadInteractionInfo, + requireRecentReactionEmojiUpdate: ( + currentCache.context.requireRecentReactionEmojiUpdate || + changes.contains(.recentReactionsUpdated) + ) + ) + + /// Validate we have the bear minimum data for the source + switch currentCache.context.source { + case .conversationList, .searchResults: break + case .messageList(let threadId), .conversationSettings(let threadId): + /// On the message list and conversation settings if we don't currently have the thread cached then we need to fetch it + guard currentCache.thread(for: threadId) == nil else { break } + + requirements.threadIdsNeedingFetch.insert(threadId) + } + + /// If we need a full fetch then we need to fill the "idsNeedingFetch" sets with info from the current cache + if currentCache.context.requireFullRefresh { + requirements.threadIdsNeedingFetch.insert(contentsOf: Set(currentCache.threads.keys)) + requirements.interactionIdsNeedingFetch.insert(contentsOf: Set(currentCache.interactions.keys)) + + switch currentCache.context.source { + case .searchResults: break + case .conversationList: + requirements.threadIdsNeedingFetch.insert(contentsOf: Set(itemCache.keys) as? Set) + + case .messageList(let threadId): + requirements.threadIdsNeedingFetch.insert(threadId) + requirements.interactionIdsNeedingFetch.insert(contentsOf: Set(itemCache.keys) as? Set) + + case .conversationSettings(let threadId): + requirements.threadIdsNeedingFetch.insert(threadId) + } + } + + /// Handle explicit events which may require additional data to be fetched + changes.databaseEvents.forEach { event in + switch (event.key.generic, event.value) { + case (GenericObservableKey(.messageRequestAccepted), let threadId as String): + requirements.threadIdsNeedingFetch.insert(threadId) + + case (_, is ConversationEvent): + handleConversationEvent( + event, + cache: currentCache, + itemCache: itemCache, + requirements: &requirements + ) + + case (_, is MessageEvent): + handleMessageEvent( + event, + cache: currentCache, + requirements: &requirements + ) + + /// Blocking and unblocking contacts should result in the conversation being removed/added to the conversation list + /// + /// **Note:** This is generally observed via `anyContactBlockedStatusChanged` + case (_, let contactEvent as ContactEvent): + if case .isBlocked(true) = contactEvent.change { + requirements.deletedThreadIds.insert(contactEvent.id) + } + else if case .isBlocked(false) = contactEvent.change { + requirements.insertedThreadIds.insert(contactEvent.id) + } + + case (_, let groupMemberEvent as GroupMemberEvent): + requirements.groupIdsNeedingMemberFetch.insert(groupMemberEvent.threadId) + + case (_, let profileEvent as ProfileEvent): + /// Only fetch if not already cached + if currentCache.profile(for: profileEvent.id) == nil { + requirements.profileIdsNeedingFetch.insert(profileEvent.id) + } + + case (_, let attachmentEvent as AttachmentEvent): + requirements.attachmentIdsNeedingFetch.insert(attachmentEvent.id) + + case (_, let reactionEvent as ReactionEvent): + requirements.interactionIdsNeedingReactionUpdates.insert(reactionEvent.messageId) + + case (GenericObservableKey(.messageRequestAccepted), let contactId as String): + switch currentCache.context.source { + case .searchResults: break + case .conversationList: requirements.contactIdsNeedingFetch.insert(contactId) + case .conversationSettings(let threadId), .messageList(let threadId): + guard threadId == contactId else { break } + + requirements.contactIdsNeedingFetch.insert(contactId) + } + + default: break + } + } + + /// Handle conversation update events which may require lib session fetching (eg. group changes) + changes.libSessionEvents.forEach { event in + switch (event.key.generic, event.value) { + case (.conversationUpdated, is ConversationEvent): + handleConversationEvent( + event, + cache: currentCache, + itemCache: itemCache, + requirements: &requirements + ) + + default: break + } + } + + /// Handle any events which require a change to the message request count + requirements.requiresMessageRequestCountUpdate = changes.databaseEvents.contains { event in + switch event.key { + case .messageRequestUnreadMessageReceived, .messageRequestAccepted, .messageRequestDeleted, + .messageRequestMessageRead: + return true + + default: return false + } + } + + /// Handle page loading events based on view context + requirements.needsPageLoad = { + switch currentCache.context.source { + case .conversationSettings, .searchResults: return false /// No paging + case .messageList, .conversationList: break + } + + /// If we need a full refresh then we also need to refetch the paged data in case the sorting changed + if currentCache.context.requireFullRefresh { + return true + } + + /// If we had an event that directly impacted the paged data then we need a page load + let hasDirectPagedDataChange: Bool = ( + loadPageEvent != nil || + !currentCache.context.insertedItemIds(requirements, as: Item.ID.self).isEmpty || + !currentCache.context.deletedItemIds(requirements, as: Item.ID.self).isEmpty + ) + + if hasDirectPagedDataChange { + return true + } + + switch currentCache.context.source { + case .messageList, .searchResults, .conversationSettings: return false + case .conversationList: + /// On the conversation list if a new message is created in any conversation then we need to reload the paged + /// data as it means the conversation order likely changed + if changes.contains(.anyMessageCreatedInAnyConversation) { + return true + } + + /// If a message request was accepted then we need to reload the paged data as it likely means a new + /// conversation should appear at the top of the list + if changes.contains(.messageRequestAccepted) { + return true + } + + /// On the conversation list if the last message was deleted then we need to reload the paged data as it means + /// the conversation order likely changed + for key in itemCache.keys { + guard + let threadId: String = key as? String, + let stats: ConversationInfoViewModel.InteractionStats = currentCache.interactionStats( + for: threadId + ), + changes.contains(.messageDeleted(id: stats.latestInteractionId, threadId: threadId)) + else { continue } + + return true + } + + break + } + + return false + }() + + return requirements + } + + static func applyNonDatabaseEvents( + _ changes: EventChangeset, + currentCache: ConversationDataCache, + using dependencies: Dependencies + ) async -> ConversationDataCache { + var updatedCache: ConversationDataCache = currentCache + + /// We sacrifice a little memory and performance here to simplify the logic greatly, always refresh the `currentUserSessionIds` + /// and `communityModAdminIds` to match the latest data stored in the `CommunityManager` + let communityServers: [String: CommunityManager.Server] = await dependencies[singleton: .communityManager] + .serversByThreadId() + updatedCache.setCurrentUserSessionIds(communityServers.mapValues { $0.currentUserSessionIds }) + updatedCache.insert( + communityModAdminIds: communityServers.values.reduce(into: [:]) { result, next in + for room in next.rooms.values { + result[OpenGroup.idFor(roomToken: room.token, server: next.server)] = CommunityManager.allModeratorsAndAdmins( + room: room, + includingHidden: true + ) + } + } + ) + + /// General Conversation Changes + changes.forEach(.conversationUpdated, as: ConversationEvent.self) { event in + switch (event.variant, event.change) { + case (.group, .displayName(let name)): + guard let group: ClosedGroup = updatedCache.group(for: event.id) else { return } + + updatedCache.insert(group.with(name: .set(to: name))) + + case (.community, .displayName(let name)): + guard let community: OpenGroup = updatedCache.community(for: event.id) else { return } + + updatedCache.insert(community.with(name: .set(to: name))) + + case (.group, .description(let description)): + guard let group: ClosedGroup = updatedCache.group(for: event.id) else { return } + + updatedCache.insert(group.with(groupDescription: .set(to: description))) + + case (.community, .description(let description)): + guard let community: OpenGroup = updatedCache.community(for: event.id) else { return } + + updatedCache.insert(community.with(roomDescription: .set(to: description))) + + case (.group, .displayPictureUrl(let url)): + guard let group: ClosedGroup = updatedCache.group(for: event.id) else { return } + + updatedCache.insert(group.with(displayPictureUrl: .set(to: url))) + + case (.community, .displayPictureUrl(let url)): + guard let community: OpenGroup = updatedCache.community(for: event.id) else { return } + + updatedCache.insert(community.with(displayPictureOriginalUrl: .set(to: url))) + + case (_, .pinnedPriority(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(pinnedPriority: .set(to: value))) + + case (_, .shouldBeVisible(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(shouldBeVisible: .set(to: value))) + + case (_, .mutedUntilTimestamp(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(mutedUntilTimestamp: .set(to: value))) + + case (_, .onlyNotifyForMentions(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(onlyNotifyForMentions: .set(to: value))) + + case (_, .markedAsUnread(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(markedAsUnread: .set(to: value))) + + case (_, .isDraft(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(isDraft: .set(to: value))) + + case (_, .messageDraft(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(messageDraft: .set(to: value))) + + case (_, .disappearingMessageConfiguration(let value)): + guard let value: DisappearingMessagesConfiguration = value else { return } + + updatedCache.insert(disappearingMessagesConfigurations: [value]) + + /// These need to be handled via a database query + case (_, .unreadCount), (_, .none): return + + /// These events are handled via a libSession query + case (_, .markedAsKicked), (_, .markedAsDestroyed): return + + /// These events can be ignored as they will be handled via profile changes + case (.contact, .displayName), (.contact, .displayPictureUrl): return + + /// These combinations are not supported so can be ignored + case (.contact, .description), (.legacyGroup, _): return + } + } + + /// Profile changes + changes.forEach(.profile, as: ProfileEvent.self) { event in + /// This profile (somehow) isn't in the cache so ignore event updates (it'll be fetched from the database when we hit that query) + guard var profile: Profile = updatedCache.profile(for: event.id) else { return } + + switch event.change { + case .name(let name): profile = profile.with(name: name) + case .nickname(let nickname): profile = profile.with(nickname: .set(to: nickname)) + case .displayPictureUrl(let url): profile = profile.with(displayPictureUrl: .set(to: url)) + case .proStatus(_, let features, let proExpiryUnixTimestampMs, let proGenIndexHashHex): + /// **Note:** The final view model initialiser is responsible for mocking out or removing `proFeatures` + /// based on the dev settings + profile = profile.with( + proFeatures: .set(to: features), + proExpiryUnixTimestampMs: .set(to: proExpiryUnixTimestampMs), + proGenIndexHashHex: .set(to: proGenIndexHashHex) + ) + } + + updatedCache.insert(profile) + } + + /// Contact Changes + changes.forEach(.contact, as: ContactEvent.self) { event in + switch event.change { + case .isTrusted(let value): + guard let contact: Contact = updatedCache.contact(for: event.id) else { return } + + updatedCache.insert(contact.with( + isTrusted: .set(to: value), + currentUserSessionId: currentCache.userSessionId + )) + + case .isApproved(let value): + guard let contact: Contact = updatedCache.contact(for: event.id) else { return } + + updatedCache.insert(contact.with( + isApproved: .set(to: value), + currentUserSessionId: currentCache.userSessionId + )) + + case .isBlocked(let value): + guard let contact: Contact = updatedCache.contact(for: event.id) else { return } + + updatedCache.insert(contact.with( + isBlocked: .set(to: value), + currentUserSessionId: currentCache.userSessionId + )) + + case .didApproveMe(let value): + guard let contact: Contact = updatedCache.contact(for: event.id) else { return } + + updatedCache.insert(contact.with( + didApproveMe: .set(to: value), + currentUserSessionId: currentCache.userSessionId + )) + + case .unblinded: break /// Needs custom handling + } + } + + /// Group Changes + changes.forEach(.groupInfo, as: LibSession.GroupInfo.self) { info in + updatedCache.insert(info) + } + + changes.forEach(.groupMemberUpdated, as: GroupMemberEvent.self) { event in + switch event.change { + case .none: break + case .role(let role, let status): + if event.profileId == currentCache.userSessionId.hexString { + updatedCache.insert(isUserModeratorOrAdmin: (role == .admin), in: event.threadId) + } + + var updatedMembers: [GroupMember] = updatedCache.groupMembers(for: event.threadId) + + if let memberIndex: Int = updatedMembers.firstIndex(where: { $0.profileId == event.profileId }) { + updatedMembers[memberIndex] = GroupMember( + groupId: event.threadId, + profileId: event.profileId, + role: role, + roleStatus: status, + isHidden: updatedMembers[memberIndex].isHidden + ) + updatedCache.insert(groupMembers: [event.threadId: updatedMembers]) + } + } + } + + /// Community changes + changes.forEach(.communityUpdated, as: CommunityEvent.self) { event in + switch event.change { + case .capabilities(let capabilities): + updatedCache.insert(communityCapabilities: [event.id: Set(capabilities)]) + + case .permissions(let read, let write, let upload): + guard let openGroup: OpenGroup = updatedCache.community(for: event.id) else { return } + + updatedCache.insert( + openGroup.with( + permissions: .set(to: OpenGroup.Permissions( + read: read, + write: write, + upload: upload + )) + ) + ) + + case .role(let moderator, let admin, let hiddenModerator, let hiddenAdmin): + updatedCache.insert( + isUserModeratorOrAdmin: (moderator || admin || hiddenModerator || hiddenAdmin), + in: event.id + ) + + case .moderatorsAndAdmins(let admins, let hiddenAdmins, let moderators, let hiddenModerators): + var combined: [String] = admins + combined.insert(contentsOf: hiddenAdmins, at: 0) + combined.insert(contentsOf: moderators, at: 0) + combined.insert(contentsOf: hiddenModerators, at: 0) + + let modAdminIds: Set = Set(combined) + updatedCache.insert(communityModAdminIds: [event.id: modAdminIds]) + updatedCache.insert( + isUserModeratorOrAdmin: !modAdminIds + .isDisjoint(with: updatedCache.currentUserSessionIds(for: event.id)), + in: event.id + ) + + /// No need to do anything for these changes + case .receivedInitialMessages: break + } + } + + /// General unblinding handling + changes.forEach(.anyContactUnblinded, as: ContactEvent.self) { event in + switch event.change { + case .unblinded(let blindedId, let unblindedId): + updatedCache.insert(unblindedIdMap: [blindedId: unblindedId]) + + default: break + } + } + + /// Typing indicators + changes.forEach(.typingIndicator, as: TypingIndicatorEvent.self) { event in + switch event.change { + case .started: updatedCache.setTyping(true, in: event.threadId) + case .stopped: updatedCache.setTyping(false, in: event.threadId) + } + } + + return updatedCache + } + + static func fetchFromDatabase( + _ db: ObservingDatabase, + requirements: FetchRequirements, + currentCache: ConversationDataCache, + loadResult: PagedData.LoadResult, + loadPageEvent: LoadPageEvent?, + using dependencies: Dependencies + ) throws -> (loadResult: PagedData.LoadResult, cache: ConversationDataCache) { + guard requirements.needsAnyFetch else { + return (loadResult, currentCache) + } + + var updatedLoadResult: PagedData.LoadResult = loadResult + var updatedCache: ConversationDataCache = currentCache + var updatedRequirements: FetchRequirements = requirements.resettingExternalFetchFlags() + + /// Handle page loads first + if updatedRequirements.needsPageLoad { + let target: PagedData.Target + + switch (loadPageEvent?.target(with: loadResult), currentCache.context.source) { + case (.some(let explicitTarget), _): target = explicitTarget + case (.none, .searchResults), (.none, .conversationSettings): target = .newItems(insertedIds: [], deletedIds: []) + case (.none, .conversationList): + target = .reloadCurrent( + insertedIds: currentCache.context.insertedItemIds(updatedRequirements, as: ID.self), + deletedIds: currentCache.context.deletedItemIds(updatedRequirements, as: ID.self) + ) + + case (.none, .messageList): + target = .newItems( + insertedIds: currentCache.context.insertedItemIds(updatedRequirements, as: ID.self), + deletedIds: currentCache.context.deletedItemIds(updatedRequirements, as: ID.self) + ) + } + + updatedLoadResult = try loadResult.load(db, target: target) + updatedRequirements.needsPageLoad = false + } + + switch currentCache.context.source { + case .searchResults, .conversationSettings: break + case .conversationList: + if let newIds: [String] = updatedLoadResult.newIds as? [String], !newIds.isEmpty { + updatedRequirements.threadIdsNeedingFetch.insert(contentsOf: Set(newIds)) + updatedRequirements.threadIdsNeedingInteractionStats.insert(contentsOf: Set(newIds)) + } + + case .messageList(let threadId): + /// Always re-fetch the interaction stats + updatedRequirements.threadIdsNeedingInteractionStats.insert(threadId) + + if let newIds: [Int64] = updatedLoadResult.newIds as? [Int64], !newIds.isEmpty { + updatedRequirements.interactionIdsNeedingFetch.insert(contentsOf: Set(newIds)) + } + + /// Fetch any associated data that isn't already cached + if let thread: SessionThread = updatedCache.thread(for: threadId) { + switch thread.variant { + case .contact: + if updatedCache.profile(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.profileIdsNeedingFetch.insert(thread.id) + } + + if updatedCache.contact(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.contactIdsNeedingFetch.insert(thread.id) + } + + case .group, .legacyGroup: + if updatedCache.group(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.groupIdsNeedingFetch.insert(thread.id) + } + + if updatedCache.groupMembers(for: thread.id).isEmpty || currentCache.context.requireFullRefresh { + updatedRequirements.groupIdsNeedingMemberFetch.insert(thread.id) + } + + case .community: + if updatedCache.community(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.communityIdsNeedingFetch.insert(thread.id) + } + } + } + } + + /// Now that we've finished the page load we can clear out the "insertedIds" sets (should only be used for the above) + updatedRequirements.insertedThreadIds.removeAll() + updatedRequirements.insertedInteractionIds.removeAll() + + /// Loop through the data until we no longer need to fetch anything + /// + /// **Note:** The code below _should_ only run once but it's dependant on being run in a specific order (as fetching one + /// type can result in the need to fetch more data for other types). In order to avoid bugs being introduced in the future due + /// to re-ordering the below we instead loop until there is nothing left to fetch. + var loopCounter: Int = 0 + + while updatedRequirements.needsAnyFetch { + /// Fetch any required records and update the caches + if !updatedRequirements.threadIdsNeedingFetch.isEmpty { + let threads: [SessionThread] = try SessionThread + .filter(ids: updatedRequirements.threadIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(threads: threads) + updatedRequirements.threadIdsNeedingFetch.removeAll() + + /// Fetch the disappearing messages config for the conversation + let disappearingConfigs: [DisappearingMessagesConfiguration] = try DisappearingMessagesConfiguration + .filter(ids: threads.map { $0.id }) + .fetchAll(db) + updatedCache.insert(disappearingMessagesConfigurations: disappearingConfigs) + + /// Fetch any associated data that isn't already cached + threads.forEach { thread in + switch thread.variant { + case .contact: + if updatedCache.profile(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.profileIdsNeedingFetch.insert(thread.id) + } + + if updatedCache.contact(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.contactIdsNeedingFetch.insert(thread.id) + } + + case .group, .legacyGroup: + if updatedCache.group(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.groupIdsNeedingFetch.insert(thread.id) + } + + if updatedCache.groupMembers(for: thread.id).isEmpty || currentCache.context.requireFullRefresh { + updatedRequirements.groupIdsNeedingMemberFetch.insert(thread.id) + } + + case .community: + if updatedCache.community(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.communityIdsNeedingFetch.insert(thread.id) + } + } + } + } + + if !updatedRequirements.threadIdsNeedingInteractionStats.isEmpty { + /// If we can't get the stats then it means the conversation has no more interactions which means we need to clear + /// out any old stats for that conversation (otherwise it'll show the wrong unread count) + let stats: [ConversationInfoViewModel.InteractionStats] = try ConversationInfoViewModel.InteractionStats + .request(for: updatedRequirements.threadIdsNeedingInteractionStats) + .fetchAll(db) + updatedCache.insert(interactionStats: stats) + updatedCache.remove(interactionStatsForThreadIds: updatedRequirements.threadIdsNeedingInteractionStats + .subtracting(Set(stats.map { $0.threadId }))) + + updatedRequirements.interactionIdsNeedingFetch.insert( + contentsOf: Set(stats.map { $0.latestInteractionId }) + ) + updatedRequirements.threadIdsNeedingInteractionStats.removeAll() + } + + if !updatedRequirements.interactionIdsNeedingFetch.isEmpty { + /// If the source is `messageList` then before we fetch the interactions we need to get the ids of any quoted interactions + /// + /// **Note:** We may not be able to find the quoted interaction (hence the `Int64?` but would still want to render + /// the message as a quote) + switch currentCache.context.source { + case .conversationList, .conversationSettings, .searchResults: break + case .messageList(let threadId): + let quoteInteractionIdResults: Set> = try MessageViewModel + .quotedInteractionIds( + for: updatedRequirements.interactionIdsNeedingFetch, + currentUserSessionIds: updatedCache.currentUserSessionIds(for: threadId) + ) + .fetchSet(db) + + updatedCache.insert(quoteMap: quoteInteractionIdResults.reduce(into: [:]) { result, next in + result[next.first] = MessageViewModel.MaybeUnresolvedQuotedInfo( + foundQuotedInteractionId: next.second + ) + }) + updatedRequirements.interactionIdsNeedingFetch.insert( + contentsOf: Set(quoteInteractionIdResults.compactMap { $0.second }) + ) + + /// We want to just refetch all reactions (handling individual reaction events, especially with "pending" + /// reactions in SOGS, will likely result in bugs) + updatedRequirements.interactionIdsNeedingReactionUpdates.insert( + contentsOf: updatedRequirements.interactionIdsNeedingFetch + ) + } + + /// Now fetch the interactions + let interactions: [Interaction] = try Interaction + .filter(ids: updatedRequirements.interactionIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(interactions: interactions) + updatedRequirements.interactionIdsNeedingFetch.removeAll() + + let attachmentMap: [Int64: Set] = try InteractionAttachment + .filter(interactions.map { $0.id }.contains(InteractionAttachment.Columns.interactionId)) + .fetchAll(db) + .grouped(by: \.interactionId) + .mapValues { Set($0) } + updatedCache.insert(attachmentMap: attachmentMap) + + /// In the `conversationList` we only care about the first attachment and the total number of attachments (for the + /// snippet) so no need to fetch others + let targetAttachmentIds: Set = Set(attachmentMap.values + .flatMap { $0 } + .filter { interactionAttachment in + switch currentCache.context.source { + case .conversationList, .searchResults: return (interactionAttachment.albumIndex == 0) + case .messageList: return true + case .conversationSettings: return false + } + } + .map { $0.attachmentId }) + updatedRequirements.attachmentIdsNeedingFetch.insert(contentsOf: targetAttachmentIds) + + /// Fetch any link previews needed + let linkPreviewLookupInfo: [(url: String, timestamp: Int64)] = interactions.compactMap { + guard let url: String = $0.linkPreviewUrl else { return nil } + + return (url, $0.timestampMs) + } + + if !linkPreviewLookupInfo.isEmpty { + let urls: [String] = linkPreviewLookupInfo.map(\.url) + let minTimestampMs: Int64 = (linkPreviewLookupInfo.map(\.timestamp).min() ?? 0) + let maxTimestampMs: Int64 = (linkPreviewLookupInfo.map(\.timestamp).max() ?? Int64.max) + let finalMinTimestamp: TimeInterval = (TimeInterval(minTimestampMs / 1000) - LinkPreview.timstampResolution) + let finalMaxTimestamp: TimeInterval = (TimeInterval(maxTimestampMs / 1000) + LinkPreview.timstampResolution) + + let linkPreviews: [LinkPreview] = try LinkPreview + .filter(urls.contains(LinkPreview.Columns.url)) + .filter(LinkPreview.Columns.timestamp > finalMinTimestamp) + .filter(LinkPreview.Columns.timestamp < finalMaxTimestamp) + .fetchAll(db) + updatedCache.insert(linkPreviews: linkPreviews) + updatedRequirements.attachmentIdsNeedingFetch.insert( + contentsOf: Set(linkPreviews.compactMap { $0.attachmentId }) + ) + } + + /// If the interactions contain any profiles that we don't have cached then we need to fetch those as well + interactions.forEach { interaction in + if updatedCache.profile(for: interaction.authorId) == nil { + updatedRequirements.profileIdsNeedingFetch.insert(interaction.authorId) + } + + MentionUtilities.allPubkeys(in: (interaction.body ?? "")).forEach { mentionedId in + if updatedCache.profile(for: mentionedId) == nil { + updatedRequirements.profileIdsNeedingFetch.insert(mentionedId) + } + } + } + } + + if !updatedRequirements.attachmentIdsNeedingFetch.isEmpty { + let attachments: [Attachment] = try Attachment + .filter(ids: updatedRequirements.attachmentIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(attachments: attachments) + updatedRequirements.attachmentIdsNeedingFetch.removeAll() + } + + if !updatedRequirements.interactionIdsNeedingReactionUpdates.isEmpty { + let reactions: [Int64: [Reaction]] = try Reaction + .filter(updatedRequirements.interactionIdsNeedingReactionUpdates.contains(Reaction.Columns.interactionId)) + .fetchAll(db) + .grouped(by: \.interactionId) + updatedCache.insert(reactions: reactions) + updatedRequirements.interactionIdsNeedingReactionUpdates.removeAll() + } + + if !updatedRequirements.contactIdsNeedingFetch.isEmpty { + let contacts: [Contact] = try Contact + .filter(ids: updatedRequirements.contactIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(contacts: contacts) + updatedRequirements.contactIdsNeedingFetch.removeAll() + + contacts.forEach { contact in + if updatedCache.profile(for: contact.id) == nil { + updatedRequirements.profileIdsNeedingFetch.insert(contact.id) + } + } + } + + if !updatedRequirements.groupIdsNeedingFetch.isEmpty { + let groups: [ClosedGroup] = try ClosedGroup + .filter(ids: updatedRequirements.groupIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(groups: groups) + updatedRequirements.groupIdsNeedingFetch.removeAll() + + updatedRequirements.groupIdsNeedingMemberFetch.insert(contentsOf: Set(groups.map { $0.threadId })) + } + + if !updatedRequirements.groupIdsNeedingMemberFetch.isEmpty { + let groupMembers: [GroupMember] = try GroupMember + .filter(updatedRequirements.groupIdsNeedingMemberFetch.contains(GroupMember.Columns.groupId)) + .fetchAll(db) + updatedCache.insert(groupMembers: groupMembers.grouped(by: \.groupId)) + updatedRequirements.groupIdsNeedingMemberFetch.removeAll() + + groupMembers.forEach { member in + if updatedCache.profile(for: member.profileId) == nil { + updatedRequirements.profileIdsNeedingFetch.insert(member.profileId) + } + } + } + + if !updatedRequirements.communityIdsNeedingFetch.isEmpty { + let communities: [OpenGroup] = try OpenGroup + .filter(ids: updatedRequirements.communityIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(communities: communities) + updatedRequirements.communityIdsNeedingFetch.removeAll() + + /// Also need to fetch capabilities if we don't have them cached for this server + let communityServersNeedingCapabilityFetch: Set = Set(communities.compactMap { openGroup in + guard updatedCache.communityCapabilities(for: openGroup.server).isEmpty else { return nil } + + return openGroup.server + }) + + if !communityServersNeedingCapabilityFetch.isEmpty { + let capabilities: [Capability] = try Capability + .filter(communityServersNeedingCapabilityFetch.contains(Capability.Columns.openGroupServer)) + .fetchAll(db) + + updatedCache.insert( + communityCapabilities: capabilities + .grouped(by: \.openGroupServer) + .mapValues { capabilities in Set(capabilities.map { $0.variant }) } + ) + } + } + + if !updatedRequirements.profileIdsNeedingFetch.isEmpty { + let profiles: [Profile] = try Profile + .filter(ids: updatedRequirements.profileIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(profiles: profiles) + updatedRequirements.profileIdsNeedingFetch.removeAll() + + /// If the source is `messageList` or `conversationSettings` and we have blinded ids then we want to + /// update the `unblindedIdMap` so that we can show a users unblinded profile information if possible + let blindedIds: Set = Set(profiles.map { $0.id } + .filter { SessionId.Prefix.isCommunityBlinded($0) }) + + if !blindedIds.isEmpty { + switch currentCache.context.source { + case .conversationList, .searchResults: break + case .messageList, .conversationSettings: + let blindedIdMap: [String: String] = try BlindedIdLookup + .filter(ids: blindedIds) + .filter(BlindedIdLookup.Columns.sessionId != nil) + .fetchAll(db) + .reduce(into: [:]) { result, next in result[next.blindedId] = next.sessionId } + + updatedCache.insert(unblindedIdMap: blindedIdMap) + } + } + } + + loopCounter += 1 + + guard loopCounter < 10 else { + Log.critical("[ConversationDataHelper] We ended up looping 10 times trying to update the cache, something went wrong: \(updatedRequirements).") + break + } + } + + /// Remove any values which are no longer needed + updatedCache.remove(threadIds: updatedRequirements.deletedThreadIds) + updatedCache.remove(interactionIds: updatedRequirements.deletedInteractionIds) + + return (updatedLoadResult, updatedCache) + } + + /// This function currently assumes that it will be run after the `fetchFromDatabase` function - we may have to rework in the future + /// to support additional data being sourced from `libSession` (potentially calling this both before and after `fetchFromDatabase`) + static func fetchFromLibSession( + requirements: FetchRequirements, + cache: ConversationDataCache, + using dependencies: Dependencies + ) throws -> ConversationDataCache { + var updatedCache: ConversationDataCache = cache + let groupInfoIdsNeedingFetch: Set = Set(cache.groups.keys) + .filter { cache.groupInfo(for: $0) == nil } + .inserting(contentsOf: requirements.groupIdsNeedingFetch) + + if !groupInfoIdsNeedingFetch.isEmpty { + let groupInfo: [LibSession.GroupInfo?] = dependencies.mutate(cache: .libSession) { cache in + cache.groupInfo(for: groupInfoIdsNeedingFetch) + } + + updatedCache.insert(groupInfo: groupInfo.compactMap { $0 }) + } + + return updatedCache + } +} + +// MARK: - Convenience + +public extension ConversationDataHelper { + static func fetchFromDatabase( + _ db: ObservingDatabase, + requirements: FetchRequirements, + currentCache: ConversationDataCache, + using dependencies: Dependencies + ) throws -> ConversationDataCache { + return try fetchFromDatabase( + db, + requirements: requirements, + currentCache: currentCache, + loadResult: PagedData.LoadResult.createInvalid(), + loadPageEvent: nil, + using: dependencies + ).cache + } +} + +// MARK: - Specific Event Handling + +private extension ConversationDataHelper { + static func handleConversationEvent( + _ event: ObservedEvent, + cache: ConversationDataCache, + itemCache: [Item.ID: Item], + requirements: inout FetchRequirements + ) { + guard let conversationEvent: ConversationEvent = event.value as? ConversationEvent else { return } + + switch (event.key.generic, conversationEvent.change, cache.context.source) { + case (.conversationCreated, _, _): requirements.insertedThreadIds.insert(conversationEvent.id) + case (.conversationDeleted, _, _): requirements.deletedThreadIds.insert(conversationEvent.id) + + case (_, .disappearingMessageConfiguration, .messageList): + /// Since we cache whether a messages disappearing message config can be followed we + /// need to update the value if the disappearing message config on the conversation changes + itemCache.forEach { _, item in + guard + let messageViewModel: MessageViewModel = item as? MessageViewModel, + messageViewModel.canFollowDisappearingMessagesSetting + else { return } + + requirements.interactionIdsNeedingFetch.insert(messageViewModel.id) + } + + case (_, .markedAsKicked, _), (_, .markedAsDestroyed, _): + requirements.groupIdsNeedingFetch.insert(conversationEvent.id) + + default: break + } + } + + static func handleMessageEvent( + _ event: ObservedEvent, + cache: ConversationDataCache, + requirements: inout FetchRequirements + ) { + guard + let messageEvent: MessageEvent = event.value as? MessageEvent, + let interactionId: Int64 = messageEvent.id + else { return } + + switch event.key.generic { + case .messageCreated: requirements.insertedInteractionIds.insert(interactionId) + case .messageUpdated: requirements.interactionIdsNeedingFetch.insert(interactionId) + case .messageDeleted: requirements.deletedInteractionIds.insert(interactionId) + + case GenericObservableKey(.anyMessageCreatedInAnyConversation): + requirements.insertedInteractionIds.insert(interactionId) + + /// If we don't currently have the thread in the cache then it's likely a thread from a page which hasn't been fetched + /// yet, we now need to fetch it in case in now belongs in the current page + if cache.thread(for: messageEvent.threadId) == nil { + requirements.insertedThreadIds.insert(messageEvent.threadId) + } + + default: break + } + + switch cache.context.source { + case .conversationSettings, .searchResults: break + case .conversationList, .messageList: + /// Any message event means we need to refetch interaction stats and latest message + requirements.threadIdsNeedingInteractionStats.insert(messageEvent.threadId) + } + } +} + +// MARK: - FetchRequirements + +public extension ConversationDataHelper { + struct FetchRequirements { + public var requireAuthMethodFetch: Bool + public var requiresMessageRequestCountUpdate: Bool + public var requiresInitialUnreadInteractionInfo: Bool + public var requireRecentReactionEmojiUpdate: Bool + fileprivate var needsPageLoad: Bool + + fileprivate var insertedThreadIds: Set + fileprivate var deletedThreadIds: Set + fileprivate var insertedInteractionIds: Set + fileprivate var deletedInteractionIds: Set + + fileprivate var threadIdsNeedingFetch: Set + fileprivate var threadIdsNeedingInteractionStats: Set + fileprivate var contactIdsNeedingFetch: Set + fileprivate var groupIdsNeedingFetch: Set + fileprivate var groupIdsNeedingMemberFetch: Set + fileprivate var communityIdsNeedingFetch: Set + fileprivate var profileIdsNeedingFetch: Set + fileprivate var interactionIdsNeedingFetch: Set + fileprivate var interactionIdsNeedingReactionUpdates: Set + fileprivate var attachmentIdsNeedingFetch: Set + + public var needsAnyFetch: Bool { + requireAuthMethodFetch || + requiresMessageRequestCountUpdate || + requiresInitialUnreadInteractionInfo || + requireRecentReactionEmojiUpdate || + needsPageLoad || + !insertedThreadIds.isEmpty || + !insertedInteractionIds.isEmpty || + + !threadIdsNeedingFetch.isEmpty || + !threadIdsNeedingInteractionStats.isEmpty || + !contactIdsNeedingFetch.isEmpty || + !groupIdsNeedingFetch.isEmpty || + !groupIdsNeedingMemberFetch.isEmpty || + !communityIdsNeedingFetch.isEmpty || + !profileIdsNeedingFetch.isEmpty || + !interactionIdsNeedingFetch.isEmpty || + !interactionIdsNeedingReactionUpdates.isEmpty || + !attachmentIdsNeedingFetch.isEmpty + } + + public init( + requireAuthMethodFetch: Bool, + requiresMessageRequestCountUpdate: Bool, + requiresInitialUnreadInteractionInfo: Bool, + requireRecentReactionEmojiUpdate: Bool, + needsPageLoad: Bool = false, + insertedThreadIds: Set = [], + deletedThreadIds: Set = [], + insertedInteractionIds: Set = [], + deletedInteractionIds: Set = [], + threadIdsNeedingFetch: Set = [], + threadIdsNeedingInteractionStats: Set = [], + contactIdsNeedingFetch: Set = [], + groupIdsNeedingFetch: Set = [], + groupIdsNeedingMemberFetch: Set = [], + communityIdsNeedingFetch: Set = [], + profileIdsNeedingFetch: Set = [], + interactionIdsNeedingFetch: Set = [], + interactionIdsNeedingReactionUpdates: Set = [], + attachmentIdsNeedingFetch: Set = [] + ) { + self.requireAuthMethodFetch = requireAuthMethodFetch + self.requiresMessageRequestCountUpdate = requiresMessageRequestCountUpdate + self.requiresInitialUnreadInteractionInfo = requiresInitialUnreadInteractionInfo + self.requireRecentReactionEmojiUpdate = requireRecentReactionEmojiUpdate + self.needsPageLoad = needsPageLoad + self.insertedThreadIds = insertedThreadIds + self.deletedThreadIds = deletedThreadIds + self.insertedInteractionIds = insertedInteractionIds + self.deletedInteractionIds = deletedInteractionIds + self.threadIdsNeedingFetch = threadIdsNeedingFetch + self.threadIdsNeedingInteractionStats = threadIdsNeedingInteractionStats + self.contactIdsNeedingFetch = contactIdsNeedingFetch + self.groupIdsNeedingFetch = groupIdsNeedingFetch + self.groupIdsNeedingMemberFetch = groupIdsNeedingMemberFetch + self.communityIdsNeedingFetch = communityIdsNeedingFetch + self.profileIdsNeedingFetch = profileIdsNeedingFetch + self.interactionIdsNeedingFetch = interactionIdsNeedingFetch + self.interactionIdsNeedingReactionUpdates = interactionIdsNeedingReactionUpdates + self.attachmentIdsNeedingFetch = attachmentIdsNeedingFetch + } + + public func resettingExternalFetchFlags() -> FetchRequirements { + var result: FetchRequirements = self + result.requireAuthMethodFetch = false + result.requiresMessageRequestCountUpdate = false + result.requiresInitialUnreadInteractionInfo = false + result.requireRecentReactionEmojiUpdate = false + + return result + } + } +} diff --git a/SessionMessagingKit/Types/ConversationInfoViewModel.swift b/SessionMessagingKit/Types/ConversationInfoViewModel.swift new file mode 100644 index 0000000000..cc7e68da32 --- /dev/null +++ b/SessionMessagingKit/Types/ConversationInfoViewModel.swift @@ -0,0 +1,882 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import GRDB +import DifferenceKit +import SessionUIKit +import SessionUtilitiesKit + +/// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewModel` and the +/// `GlobalSearchViewController`, it should be populated via the `ConversationDataHelper` and should be tied to a screen +/// using the `ObservationBuilder` in order to properly populate it's content +public struct ConversationInfoViewModel: PagableRecord, Sendable, Equatable, Hashable, Identifiable, Differentiable { + public typealias PagedDataType = SessionThread + + public var differenceIdentifier: String { id } + + public let id: String + public let variant: SessionThread.Variant + public let displayName: String + public let displayPictureUrl: String? + public let conversationDescription: String? + public let creationDateTimestamp: TimeInterval + public let shouldBeVisible: Bool + public let pinnedPriority: Int32 + + public let isDraft: Bool + public let isNoteToSelf: Bool + public let isBlocked: Bool + + /// This flag indicates whether the thread is an outgoing message request + public let isMessageRequest: Bool + + /// This flag indicates whether the thread is an incoming message request + public let requiresApproval: Bool + + public let mutedUntilTimestamp: TimeInterval? + public let onlyNotifyForMentions: Bool + public let wasMarkedUnread: Bool + public let unreadCount: Int + public let unreadMentionCount: Int + public let hasUnreadMessagesOfAnyKind: Bool + public let disappearingMessagesConfiguration: DisappearingMessagesConfiguration? + public let messageDraft: String + + public let canWrite: Bool + public let canUpload: Bool + public let canAccessSettings: Bool + public let shouldShowProBadge: Bool + public let isTyping: Bool + public let userCount: Int? + public let memberNames: String + public let messageSnippet: String? + public let targetInteraction: InteractionInfo? + public let lastInteraction: InteractionInfo? + public let userSessionId: SessionId + public let currentUserSessionIds: Set + + // Variant-specific configuration + + public let profile: Profile? + public let additionalProfile: Profile? + public let contactInfo: ContactInfo? + public let groupInfo: GroupInfo? + public let communityInfo: CommunityInfo? + + public var dateForDisplay: String { + let timestamp: TimeInterval + + switch (targetInteraction, lastInteraction) { + case (.some(let interaction), _): timestamp = (Double(interaction.timestampMs) / 1000) + case (_, .some(let interaction)): timestamp = (Double(interaction.timestampMs) / 1000) + default: timestamp = creationDateTimestamp + } + + return Date(timeIntervalSince1970: timestamp).formattedForDisplay + } + + public init( + thread: SessionThread, + dataCache: ConversationDataCache, + targetInteractionId: Int64? = nil, + searchText: String? = nil, + using dependencies: Dependencies + ) { + let currentUserSessionIds: Set = dataCache.currentUserSessionIds(for: thread.id) + let isMessageRequest: Bool = ( + ( + thread.variant == .group && + dataCache.group(for: thread.id)?.invited != false + ) || ( + thread.variant == .contact && + !currentUserSessionIds.contains(thread.id) && + dataCache.contact(for: thread.id)?.isApproved != true + ) + ) + let requiresApproval: Bool = ( + thread.variant == .contact && + dataCache.contact(for: thread.id)?.didApproveMe != true + ) + let sortedMemberIds: [String] = dataCache.groupMembers(for: thread.id) + .map({ $0.profileId }) + .filter({ !currentUserSessionIds.contains($0) }) + .sorted() + let profile: Profile? = { + switch thread.variant { + case .contact: + /// If the thread is the Note to Self one then use the proper profile from the cache (instead of a random blinded one) + guard !currentUserSessionIds.contains(thread.id) else { + return ( + dataCache.profile(for: dataCache.userSessionId.hexString) ?? + Profile.defaultFor(dataCache.userSessionId.hexString) + ) + } + + return (dataCache.profile(for: thread.id) ?? Profile.defaultFor(thread.id)) + + case .legacyGroup, .group: + let maybeTargetId: String? = sortedMemberIds.first + + return dataCache.profile(for: maybeTargetId ?? dataCache.userSessionId.hexString) + + case .community: return nil + } + }() + let lastInteractionContentBuilder: Interaction.ContentBuilder = Interaction.ContentBuilder( + interaction: dataCache.interactionStats(for: thread.id).map { + dataCache.interaction(for: $0.latestInteractionId) + }, + threadId: thread.id, + threadVariant: thread.variant, + searchText: searchText, + dataCache: dataCache + ) + let targetInteractionContentBuilder: Interaction.ContentBuilder? = targetInteractionId.map { + Interaction.ContentBuilder( + interaction: dataCache.interaction(for: $0), + threadId: thread.id, + threadVariant: thread.variant, + searchText: searchText, + dataCache: dataCache + ) + } + + let lastInteraction: InteractionInfo? = InteractionInfo(contentBuilder: lastInteractionContentBuilder) + let groupInfo: GroupInfo? = dataCache.group(for: thread.id).map { + GroupInfo( + group: $0, + dataCache: dataCache, + currentUserSessionIds: currentUserSessionIds + ) + } + let communityInfo: CommunityInfo? = dataCache.community(for: thread.id).map { + CommunityInfo( + openGroup: $0, + dataCache: dataCache + ) + } + + self.id = thread.id + self.variant = thread.variant + self.displayName = { + let result: String = SessionThread.displayName( + threadId: thread.id, + variant: thread.variant, + groupName: dataCache.group(for: thread.id)?.name, + communityName: dataCache.community(for: thread.id)?.name, + isNoteToSelf: currentUserSessionIds.contains(thread.id), + ignoreNickname: false, + profile: profile + ) + + /// If this is being displayed as a conversation search result then we want to highlight the `searchTerm` in the `displayName` + /// + /// **Note:** If there is a `targetInteractionId` then this is a message search result and we don't want to highlight + /// the `searchText` within the title + guard let searchText: String = searchText, targetInteractionId == nil else { + return result + } + + return GlobalSearch.highlightSearchText( + searchText: searchText, + content: result + ) + }() + self.displayPictureUrl = { + switch thread.variant { + case .community: return dataCache.community(for: thread.id)?.displayPictureOriginalUrl + case .group, .legacyGroup: return dataCache.group(for: thread.id)?.displayPictureUrl + case .contact: return dataCache.profile(for: thread.id)?.displayPictureUrl + } + }() + self.conversationDescription = { + switch thread.variant { + case .contact, .legacyGroup: return nil + case .community: return dataCache.community(for: thread.id)?.roomDescription + case .group: return dataCache.group(for: thread.id)?.groupDescription + } + }() + self.creationDateTimestamp = thread.creationDateTimestamp + self.shouldBeVisible = thread.shouldBeVisible + self.pinnedPriority = (thread.pinnedPriority.map { Int32($0) } ?? LibSession.visiblePriority) + + self.isDraft = (thread.isDraft == true) + self.isNoteToSelf = currentUserSessionIds.contains(thread.id) + self.isBlocked = (dataCache.contact(for: thread.id)?.isBlocked == true) + self.isMessageRequest = isMessageRequest + self.requiresApproval = requiresApproval + + self.mutedUntilTimestamp = thread.mutedUntilTimestamp + self.onlyNotifyForMentions = thread.onlyNotifyForMentions + self.wasMarkedUnread = (thread.markedAsUnread == true) + self.disappearingMessagesConfiguration = dataCache.disappearingMessageConfiguration(for: thread.id) + self.messageDraft = (thread.messageDraft ?? "") + + self.canWrite = { + switch thread.variant { + case .contact: + guard isMessageRequest else { return true } + + /// If the thread is an incoming message request then we should be able to reply regardless of the original + /// senders `blocksCommunityMessageRequests` setting + guard requiresApproval else { return true } + + return (profile?.blocksCommunityMessageRequests != true) + + case .legacyGroup: return false + case .group: + guard + groupInfo?.isDestroyed != true, + groupInfo?.wasKicked != true + else { return false } + guard !isMessageRequest else { return true } + + return (lastInteraction?.variant.isGroupLeavingStatus != true) + + case .community: return (communityInfo?.permissions.contains(.write) ?? false) + } + }() + self.canUpload = { + switch thread.variant { + case .contact: + /// If the thread is an outgoing message request then we shouldn't be able to upload + return (requiresApproval == false) + + case .legacyGroup: return false + case .group: + guard + groupInfo?.isDestroyed != true, + groupInfo?.wasKicked != true + else { return false } + guard !isMessageRequest else { return true } + + return (lastInteraction?.variant.isGroupLeavingStatus != true) + + case .community: return (communityInfo?.permissions.contains(.upload) ?? false) + } + }() + self.canAccessSettings = ( + !requiresApproval && + !isMessageRequest && + variant != .legacyGroup + ) + + self.shouldShowProBadge = { + guard dependencies[feature: .sessionProEnabled] else { return false } + + switch thread.variant { + case .contact: + return dependencies[singleton: .sessionProManager] + .profileFeatures(for: profile) + .contains(.proBadge) + + case .group: return false // TODO: [PRO] Determine if the group is PRO + case .community, .legacyGroup: return false + } + }() + self.isTyping = dataCache.isTyping(in: thread.id) + self.userCount = { + switch thread.variant { + case .contact: return nil + case .legacyGroup, .group: + return dataCache.groupMembers(for: thread.id) + .filter { $0.role != .zombie } + .count + + case .community: return Int(dataCache.community(for: thread.id)?.userCount ?? 0) + } + }() + self.memberNames = { + let memberNameString: String = dataCache.groupMembers(for: thread.id) + .compactMap { member in dataCache.profile(for: member.profileId) } + .map { profile in + profile.displayName( + showYouForCurrentUser: false /// Don't want to show `You` here as this is displayed in Global Search + ) + } + .joined(separator: ", ") + + /// If this is being displayed as a search result then we want to highlight the `searchTerm` in the `memberNameString` + /// + /// **Note:** If there is a `targetInteractionId` then this is a message search result and we won't be showing the + /// `memberNameString` so no need to highlight it + guard let searchText: String = searchText, targetInteractionId == nil else { + return memberNameString + } + + return GlobalSearch.highlightSearchText( + searchText: searchText, + content: memberNameString + ) + }() + self.messageSnippet = (targetInteractionContentBuilder ?? lastInteractionContentBuilder) + .makeSnippet(dateNow: dependencies.dateNow) + + self.unreadCount = (dataCache.interactionStats(for: thread.id)?.unreadCount ?? 0) + self.unreadMentionCount = (dataCache.interactionStats(for: thread.id)?.unreadMentionCount ?? 0) + self.hasUnreadMessagesOfAnyKind = (dataCache.interactionStats(for: thread.id)?.hasUnreadMessagesOfAnyKind == true) + self.targetInteraction = targetInteractionContentBuilder.map { + InteractionInfo(contentBuilder: $0) + } + self.lastInteraction = lastInteraction + self.userSessionId = dataCache.userSessionId + self.currentUserSessionIds = currentUserSessionIds + + // Variant-specific configuration + + self.profile = profile.map { profile in + profile.with( + proFeatures: .set(to: dependencies[singleton: .sessionProManager].profileFeatures(for: profile)) + ) + } + self.additionalProfile = { + switch thread.variant { + case .legacyGroup, .group: + guard + sortedMemberIds.count > 1, + let targetId: String = sortedMemberIds.last, + targetId != profile?.id + else { return nil } + + return dataCache.profile(for: targetId).map { profile in + profile.with( + proFeatures: .set(to: dependencies[singleton: .sessionProManager].profileFeatures(for: profile)) + ) + } + + default: return nil + } + }() + self.contactInfo = dataCache.contact(for: thread.id).map { + ContactInfo( + contact: $0, + profile: profile, + threadVariant: thread.variant, + currentUserSessionIds: currentUserSessionIds + ) + } + self.groupInfo = groupInfo + self.communityInfo = communityInfo + } +} + +// MARK: - Observations + +extension ConversationInfoViewModel: ObservableKeyProvider { + public var observedKeys: Set { + var result: Set = [ + .conversationCreated, + .conversationUpdated(id), + .conversationDeleted(id), + .messageCreated(threadId: id), + .typingIndicator(id) + ] + + if SessionId.Prefix.isCommunityBlinded(id) { + result.insert(.anyContactUnblinded) + } + + if let targetInteraction: InteractionInfo = self.targetInteraction { + result.insert(.profile(targetInteraction.authorId)) + result.insert(.messageUpdated(id: targetInteraction.id, threadId: id)) + result.insert(.messageDeleted(id: targetInteraction.id, threadId: id)) + } + + if let lastInteraction: InteractionInfo = self.lastInteraction { + result.insert(.profile(lastInteraction.authorId)) + result.insert(.messageUpdated(id: lastInteraction.id, threadId: id)) + result.insert(.messageDeleted(id: lastInteraction.id, threadId: id)) + } + + if let profile: Profile = self.profile { + result.insert(.profile(profile.id)) + } + + if let additionalProfile: Profile = self.additionalProfile { + result.insert(.profile(additionalProfile.id)) + } + + switch variant { + case .contact: result.insert(.contact(id)) + case .group: + result.insert(.groupInfo(groupId: id)) + result.insert(.groupMemberCreated(threadId: id)) + result.insert(.anyGroupMemberDeleted(threadId: id)) + + case .community: + result.insert(.communityUpdated(id)) + result.insert(.anyContactUnblinded) /// To update profile info and blinded mapping + + case .legacyGroup: break + } + + return result + } + + public static func handlingStrategy(for event: ObservedEvent) -> EventHandlingStrategy? { + return event.handlingStrategy + } +} + +public extension ConversationInfoViewModel { + // MARK: - Marking as Read + + enum ReadTarget { + /// Only the thread should be marked as read + case thread + + /// Both the thread and interactions should be marked as read, if no interaction id is provided then all interactions for the + /// thread will be marked as read + case threadAndInteractions(interactionsBeforeInclusive: Int64?) + } + + /// This method marks a thread as read and depending on the target may also update the interactions within a thread as read + func markAsRead(target: ReadTarget, using dependencies: Dependencies) async throws { + let targetInteractionId: Int64? = { + guard case .threadAndInteractions(let interactionId) = target else { return nil } + guard hasUnreadMessagesOfAnyKind else { return nil } + + return (interactionId ?? self.lastInteraction?.id) + }() + + /// No need to do anything if the thread is already marked as read and we don't have a target interaction + guard wasMarkedUnread || targetInteractionId != nil else { return } + + /// Perform the updates + try await dependencies[singleton: .storage].writeAsync { [id, variant] db in + if wasMarkedUnread { + try SessionThread + .filter(id: id) + .updateAllAndConfig( + db, + SessionThread.Columns.markedAsUnread.set(to: false), + using: dependencies + ) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.markedAsUnread(false)) + ) + } + + if let interactionId: Int64 = targetInteractionId { + try Interaction.markAsRead( + db, + interactionId: interactionId, + threadId: id, + threadVariant: variant, + includingOlder: true, + trySendReadReceipt: SessionThread.canSendReadReceipt( + threadId: id, + threadVariant: variant, + using: dependencies + ), + using: dependencies + ) + } + } + } + + /// This method will mark a thread as read + func markAsUnread(using dependencies: Dependencies) async throws { + guard !wasMarkedUnread else { return } + + try await dependencies[singleton: .storage].writeAsync { [id] db in + try SessionThread + .filter(id: id) + .updateAllAndConfig( + db, + SessionThread.Columns.markedAsUnread.set(to: true), + using: dependencies + ) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.markedAsUnread(true)) + ) + } + } + + // MARK: - Draft + + func updateDraft(_ draft: String, using dependencies: Dependencies) async throws { + guard draft != self.messageDraft else { return } + + try await dependencies[singleton: .storage].writeAsync { [id, variant] db in + try SessionThread + .filter(id: id) + .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.messageDraft(draft)) + ) + } + } +} + +// MARK: - Convenience Initialization + +public extension ConversationInfoViewModel { + private static let messageRequestsSectionId: String = "MESSAGE_REQUESTS_SECTION_INVALID_THREAD_ID" + + var isMessageRequestsSection: Bool { id == ConversationInfoViewModel.messageRequestsSectionId } + + static func unreadMessageRequestsBanner(unreadCount: Int) -> ConversationInfoViewModel { + return ConversationInfoViewModel( + id: messageRequestsSectionId, + displayName: "sessionMessageRequests".localized(), + unreadCount: unreadCount + ) + } + + private init( + id: String, + displayName: String = "", + unreadCount: Int = 0 + ) { + self.id = id + self.variant = .contact + self.displayName = displayName + self.displayPictureUrl = nil + self.conversationDescription = nil + self.creationDateTimestamp = 0 + self.shouldBeVisible = true + self.pinnedPriority = LibSession.visiblePriority + + self.isDraft = false + self.isNoteToSelf = false + self.isBlocked = false + self.isMessageRequest = false + self.requiresApproval = false + + self.mutedUntilTimestamp = nil + self.onlyNotifyForMentions = false + self.wasMarkedUnread = false + self.unreadCount = unreadCount + self.unreadMentionCount = 0 + self.hasUnreadMessagesOfAnyKind = false + self.disappearingMessagesConfiguration = nil + self.messageDraft = "" + + self.canWrite = false + self.canUpload = false + self.canAccessSettings = false + self.shouldShowProBadge = false + self.isTyping = false + self.userCount = nil + self.memberNames = "" + self.messageSnippet = "" + self.targetInteraction = nil + self.lastInteraction = nil + self.userSessionId = .invalid + self.currentUserSessionIds = [] + + // Variant-specific configuration + + self.profile = nil + self.additionalProfile = nil + self.contactInfo = nil + self.groupInfo = nil + self.communityInfo = nil + } +} + +// MARK: - ContactInfo + +public extension ConversationInfoViewModel { + struct ContactInfo: Sendable, Equatable, Hashable { + public let id: String + public let isCurrentUser: Bool + public let displayName: String + public let displayNameInMessageBody: String + public let isApproved: Bool + public let lastKnownClientVersion: FeatureVersion? + + init( + contact: Contact, + profile: Profile?, + threadVariant: SessionThread.Variant, + currentUserSessionIds: Set + ) { + self.id = contact.id + self.isCurrentUser = currentUserSessionIds.contains(contact.id) + self.displayName = (profile ?? Profile.defaultFor(contact.id)).displayName() + self.displayNameInMessageBody = (profile ?? Profile.defaultFor(contact.id)).displayName( + includeSessionIdSuffix: (threadVariant == .community) + ) + self.isApproved = contact.isApproved + self.lastKnownClientVersion = contact.lastKnownClientVersion + } + } +} + +// MARK: - GroupInfo + +public extension ConversationInfoViewModel { + struct GroupInfo: Sendable, Equatable, Hashable { + public let name: String + public let expired: Bool + public let wasKicked: Bool + public let isDestroyed: Bool + public let adminProfile: Profile? + public let currentUserRole: GroupMember.Role? + public let isProGroup: Bool + + init( + group: ClosedGroup, + dataCache: ConversationDataCache, + currentUserSessionIds: Set + ) { + let adminIds: [String] = dataCache.groupMembers(for: group.threadId) + .filter { $0.role == .admin } + .map { $0.profileId } + .sorted() + + self.name = group.name + self.expired = (group.expired == true) + self.wasKicked = (dataCache.groupInfo(for: group.threadId)?.wasKickedFromGroup == true) + self.isDestroyed = (dataCache.groupInfo(for: group.threadId)?.wasGroupDestroyed == true) + self.adminProfile = adminIds.compactMap { dataCache.profile(for: $0) }.first + self.currentUserRole = dataCache.groupMembers(for: group.threadId) + .filter { currentUserSessionIds.contains($0.profileId) } + .map { $0.role } + .sorted() + .last /// We want the highest-ranking role (in case there are multiple entries) + + // TODO: [PRO] Need to determine whether it's a PRO group conversation + self.isProGroup = false + } + } +} + +// MARK: - CommunityInfo + +public extension ConversationInfoViewModel { + struct CommunityInfo: Sendable, Equatable, Hashable { + public let name: String + public let server: String + public let roomToken: String + public let publicKey: String + public let permissions: OpenGroup.Permissions + public let capabilities: Set + + init( + openGroup: OpenGroup, + dataCache: ConversationDataCache + ) { + self.name = openGroup.name + self.server = openGroup.server + self.roomToken = openGroup.roomToken + self.publicKey = openGroup.publicKey + self.permissions = (openGroup.permissions ?? .noPermissions) + self.capabilities = dataCache.communityCapabilities(for: openGroup.server) + } + } +} + +// MARK: - InteractionInfo + +public extension ConversationInfoViewModel { + struct InteractionInfo: Sendable, Equatable, Hashable { + public let id: Int64 + public let threadId: String + public let authorId: String + public let authorName: String + public let variant: Interaction.Variant + public let bubbleBody: String? + public let timestampMs: Int64 + public let state: Interaction.State + public let hasBeenReadByRecipient: Bool + public let hasAttachments: Bool + + internal init?(contentBuilder: Interaction.ContentBuilder) { + guard + let interaction: Interaction = contentBuilder.interaction, + let interactionId: Int64 = interaction.id + else { return nil } + + self.id = interactionId + self.threadId = interaction.threadId + self.authorId = interaction.authorId + self.authorName = contentBuilder.authorDisplayName + self.variant = interaction.variant + self.bubbleBody = contentBuilder.makeBubbleBody() + self.timestampMs = interaction.timestampMs + self.state = interaction.state + self.hasBeenReadByRecipient = (interaction.recipientReadTimestampMs != nil) + self.hasAttachments = contentBuilder.hasAttachments + } + } +} + +// MARK: - InteractionStats + +public extension ConversationInfoViewModel { + struct InteractionStats: Sendable, Codable, Equatable, Hashable, ColumnExpressible, FetchableRecord { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case threadId + case unreadCount + case unreadMentionCount + case hasUnreadMessagesOfAnyKind + case latestInteractionId + case latestInteractionTimestampMs + } + + public let threadId: String + public let unreadCount: Int + public let unreadMentionCount: Int + public let hasUnreadMessagesOfAnyKind: Bool + public let latestInteractionId: Int64 + public let latestInteractionTimestampMs: Int64 + + public static func request(for conversationIds: Set) -> SQLRequest { + let interaction: TypedTableAlias = TypedTableAlias() + + return """ + SELECT + \(interaction[.threadId]) AS \(Columns.threadId), + SUM(\(interaction[.wasRead]) = false) AS \(Columns.unreadCount), + SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(Columns.unreadMentionCount), + (SUM(\(interaction[.wasRead]) = false) > 0) AS \(Columns.hasUnreadMessagesOfAnyKind), + \(interaction[.id]) AS \(Columns.latestInteractionId), + MAX(\(interaction[.timestampMs])) AS \(Columns.latestInteractionTimestampMs) + FROM \(Interaction.self) + WHERE ( + \(interaction[.threadId]) IN \(conversationIds) AND + \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToShowConversationSnippet)")) + ) + GROUP BY \(interaction[.threadId]) + """ + } + } +} + +// MARK: - Convenience + +private extension ObservedEvent { + var handlingStrategy: EventHandlingStrategy? { + switch (key, key.generic) { + case (_, .profile): return [.databaseQuery, .directCacheUpdate] + case (_, .groupMemberCreated), (_, .groupMemberUpdated), (_, .groupMemberDeleted): + return [.databaseQuery, .directCacheUpdate] + + case (_, .groupInfo): return .libSessionQuery + + case (_, .typingIndicator): return .directCacheUpdate + case (_, .conversationUpdated): return [.directCacheUpdate, .libSessionQuery] + case (_, .contact): return .directCacheUpdate + case (_, .communityUpdated): return .directCacheUpdate + + case (.anyContactBlockedStatusChanged, _): return .databaseQuery + case (_, .conversationCreated), (_, .conversationDeleted): return .databaseQuery + case (.anyMessageCreatedInAnyConversation, _): return .databaseQuery + case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery + default: return nil + } + } +} + +private extension ContactEvent.Change { + var isUnblindEvent: Bool { + switch self { + case .unblinded: return true + default: return false + } + } +} + +public extension SessionId.Prefix { + static func isCommunityBlinded(_ id: String?) -> Bool { + switch try? SessionId.Prefix(from: id) { + case .blinded15, .blinded25: return true + case .standard, .unblinded, .group, .versionBlinded07, .none: return false + } + } +} + +public extension ConversationInfoViewModel { + static var requiredJoinSQL: SQL = { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + let timestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) + + return """ + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + LEFT JOIN ( + SELECT + \(interaction[.threadId]), + MAX(\(interaction[.timestampMs])) AS \(timestampMsColumnLiteral) + FROM \(Interaction.self) + WHERE \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToShowConversationSnippet)")) + GROUP BY \(interaction[.threadId]) + ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + """ + }() + + static func homeFilterSQL(userSessionId: SessionId) -> SQL { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + + return """ + \(thread[.shouldBeVisible]) = true AND + -- Is not a message request + COALESCE(\(closedGroup[.invited]), false) = false AND ( + \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR + \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) OR + \(contact[.isApproved]) = true + ) AND + -- Is not a blocked contact + ( + \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR + \(contact[.isBlocked]) != true + ) + """ + } + + static let homeOrderSQL: SQL = { + let thread: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL(""" + (IFNULL(\(thread[.pinnedPriority]), 0) > 0) DESC, + IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC, + \(thread[.id]) DESC + """) + }() + + static func messageRequestsFilterSQL(userSessionId: SessionId) -> SQL { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + + return """ + \(thread[.shouldBeVisible]) = true AND ( + -- Is a message request + COALESCE(\(closedGroup[.invited]), false) = true OR ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND + IFNULL(\(contact[.isApproved]), false) = false + ) + ) + """ + } + + static let messageRequestsOrderSQL: SQL = { + let thread: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL(""" + IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC, + \(thread[.id]) DESC + """) + }() +} diff --git a/SessionMessagingKit/Types/GlobalSearch.swift b/SessionMessagingKit/Types/GlobalSearch.swift new file mode 100644 index 0000000000..6f8241b7d2 --- /dev/null +++ b/SessionMessagingKit/Types/GlobalSearch.swift @@ -0,0 +1,755 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUIKit +import SessionUtilitiesKit + +public enum GlobalSearch {} + +// MARK: - Helper Functions + +public extension GlobalSearch { + private class SearchTermParts { + let parts: [String] + + init(_ parts: [String]) { + self.parts = parts + } + } + + static let searchResultsLimit: Int = 500 + private static let rangeOptions: NSString.CompareOptions = [.caseInsensitive, .diacriticInsensitive] + private static let alphanumericSet: NSCharacterSet = (CharacterSet.alphanumerics as NSCharacterSet) + private static let quoteCharacterSet: CharacterSet = CharacterSet(charactersIn: "\"") + private static let searchTermPartRegex: NSRegularExpression? = try? NSRegularExpression( + pattern: "[^\\s\"']+|\"([^\"]*)\"" // stringlint:ignore + ) + + /// Processing a search term requires a little logic and regex execution and we need the processed version for every search result in + /// order to properly highlight, as a result we cache the processed parts to avoid having to re-process. + private static let searchTermPartCache: NSCache = { + let result: NSCache = NSCache() + result.name = "GlobalSearchTermPartsCache" // stringlint:ignore + result.countLimit = 25 /// Last 25 search terms + + return result + }() + + /// FTS will fail or try to process characters outside of `[A-Za-z0-9]` are included directly in a search + /// term, in order to resolve this the term needs to be wrapped in quotation marks so the eventual SQL + /// is `MATCH '"{term}"'` or `MATCH '"{term}"*'` + static func searchSafeTerm(_ term: String) -> String { + return "\"\(term)\"" + } + + // stringlint:ignore_contents + static func searchTermParts(_ searchTerm: String) -> [String] { + /// Process the search term in order to extract the parts of the search pattern we want + /// + /// Step 1 - Keep any "quoted" sections as stand-alone search + /// Step 2 - Separate any words outside of quotes + /// Step 3 - Join the different search term parts with 'OR" (include results for each individual term) + /// Step 4 - Append a wild-card character to the final word (as long as the last word doesn't end in a quote) + let normalisedTerm: String = standardQuotes(searchTerm) + + guard let regex: NSRegularExpression = searchTermPartRegex else { + // Fallback to removing the quotes and just splitting on spaces + return normalisedTerm + .replacingOccurrences(of: "\"", with: "") + .split(separator: " ") + .map { "\"\($0)\"" } + .filter { !$0.isEmpty } + } + + return regex + .matches(in: normalisedTerm, range: NSRange(location: 0, length: normalisedTerm.count)) + .compactMap { Range($0.range, in: normalisedTerm) } + .map { normalisedTerm[$0].trimmingCharacters(in: quoteCharacterSet) } + .map { "\"\($0)\"" } + } + + // stringlint:ignore_contents + static func standardQuotes(_ term: String) -> String { + guard term.contains("”") || term.contains("“") else { + return term + } + + /// Apple like to use the special '""' quote characters when typing so replace them with normal ones + return term + .replacingOccurrences(of: "”", with: "\"") + .replacingOccurrences(of: "“", with: "\"") + } + + static func pattern(_ db: ObservingDatabase, searchTerm: String) throws -> FTS5Pattern { + return try pattern(db, searchTerm: searchTerm, forTable: Interaction.self) + } + + // stringlint:ignore_contents + static func pattern(_ db: ObservingDatabase, searchTerm: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { + // Note: FTS doesn't support both prefix/suffix wild cards so don't bother trying to + // add a prefix one + let rawPattern: String = { + let result: String = searchTermParts(searchTerm) + .joined(separator: " OR ") + + // If the last character is a quotation mark then assume the user doesn't want to append + // a wildcard character + guard !standardQuotes(searchTerm).hasSuffix("\"") else { return result } + + return "\(result)*" + }() + let fallbackTerm: String = "\(searchSafeTerm(searchTerm))*" + + /// There are cases where creating a pattern can fail, we want to try and recover from those cases + /// by failling back to simpler patterns if needed + return try { + if let pattern: FTS5Pattern = try? db.makeFTS5Pattern(rawPattern: rawPattern, forTable: table) { + return pattern + } + + if let pattern: FTS5Pattern = try? db.makeFTS5Pattern(rawPattern: fallbackTerm, forTable: table) { + return pattern + } + + return try FTS5Pattern(matchingAnyTokenIn: fallbackTerm) ?? { throw StorageError.invalidSearchPattern }() + }() + } + + static func ranges( + for searchText: String, + in content: String + ) -> [NSRange] { + if content.isEmpty || searchText.isEmpty { return [] } + + let parts: [String] = { + let key: NSString = searchText as NSString + if let cacheHit: SearchTermParts = searchTermPartCache.object(forKey: key) { + return cacheHit.parts + } + + /// The search logic only finds results that start with the term so we use the regex below to ensure we only highlight those cases + let parts: [String] = GlobalSearch + .searchTermParts(searchText) + .map { part in + (part.hasPrefix("\"") && part.hasSuffix("\"") ? + part.trimmingCharacters(in: quoteCharacterSet) : + part + ) + } + + searchTermPartCache.setObject(SearchTermParts(parts), forKey: key) + return parts + }() + + let nsContent: NSString = content as NSString /// For O(1) indexing and direct `NSRange` usage + let contentLength: Int = nsContent.length + var allMatches: [NSRange] = [] + allMatches.reserveCapacity(4) // Estimate + + for part in parts { + var searchRange: NSRange = NSRange(location: 0, length: contentLength) + + while true { + let matchRange: NSRange = nsContent.range(of: part, options: rangeOptions, range: searchRange) + + guard matchRange.location != NSNotFound else { break } + + let isStartOfWord: Bool = { + if matchRange.location == 0 { return true } + + /// If the character before is a letter or number then we are inside a word (Invalid), otherwise (space, + /// punctuation, etc), we are at the start of a word (Valid) + let charBefore: unichar = nsContent.character(at: matchRange.location - 1) + + return !alphanumericSet.characterIsMember(charBefore) + }() + + /// If the match is at the start of the word then we can add it + if isStartOfWord { + allMatches.append(matchRange) + } + + /// We can now jump to the end of the match, if the same part has another match within the word (eg. "na" in + /// "banana") then the `isStartOfWord` check will prevent that from being added + let nextStart: Int = matchRange.location + matchRange.length + if nextStart >= contentLength { break } + searchRange = NSRange(location: nextStart, length: contentLength - nextStart) + } + } + + /// If we 0 or 1 match then we can just return now + guard allMatches.count <= 1 else { return allMatches } + + /// We want to match the longest parts if there were overlaps (eg. match "Testing" before "Test" if both are present) + /// + /// Sort by location ASC, then length DESC + if allMatches.count > 1 { + allMatches.sort { lhs, rhs in + if lhs.location != rhs.location { + return lhs.location < rhs.location + } + + return lhs.length > rhs.length + } + } + + /// Remove overlaps + var maxIndexProcessed: Int = 0 + var results: [NSRange] = [] + results.reserveCapacity(allMatches.count) + + for range in allMatches { + if range.location >= maxIndexProcessed { + results.append(range) + maxIndexProcessed = (range.location + range.length) + } + } + + return results + } + + static func highlightSearchText( + searchText: String, + content: String, + authorName: String? = nil + ) -> String { + guard !content.isEmpty, content != "noteToSelf".localized() else { + if let authorName: String = authorName, !authorName.isEmpty { + return "messageSnippetGroup" + .put(key: "author", value: authorName) + .put(key: "message_snippet", value: content) + .localized() + } + + return content + } + + /// Bold each part of the searh term which matched + var ranges: [NSRange] = GlobalSearch.ranges(for: searchText, in: content) + let mutableResult: NSMutableString = NSMutableString(string: content) + + // stringlint:ignore_contents + if !ranges.isEmpty { + /// Sort the ranges so they are in reverse order (that way we can insert bold tags without messing up the ranges + ranges.sort { $0.lowerBound > $1.lowerBound } + + for range in ranges { + mutableResult.insert("", at: range.upperBound) + mutableResult.insert("", at: range.lowerBound) + } + } + + /// Wrap entire result in `` tags (since we want everything else to be faded) + /// + /// **Note:** We do this even when `ranges` is empty because we want anything that doesn't contain a match to also + /// be faded + mutableResult.insert("", at: 0) // stringlint:ignore + mutableResult.append("") // stringlint:ignore + + /// If we don't have an `authorName` then we can finish here + guard let authorName: String = authorName, !authorName.isEmpty else { + return (mutableResult as String) + } + + /// Since it was provided we want to include the author name + return "messageSnippetGroup" + .put(key: "author", value: authorName) + .put(key: "message_snippet", value: (mutableResult as String)) + .localized() + } +} + +public extension ConversationDataHelper { + static func updateCacheForSearchResults( + _ db: ObservingDatabase, + currentCache: ConversationDataCache, + conversationResults: [GlobalSearch.ConversationSearchResult], + messageResults: [GlobalSearch.MessageSearchResult], + using dependencies: Dependencies + ) throws -> ConversationDataCache { + /// Find which ids need to be fetched (no need to re-fetch values we already have in the cache as they are very unlikely to + /// have changed during this search session) + let threadIds: Set = Set(conversationResults.map { $0.id }) + .subtracting(currentCache.threads.keys) + let messageThreadIds: Set = Set(messageResults.map { $0.threadId }) + .subtracting(currentCache.threads.keys) + let interactionIds: Set = Set(messageResults.map { $0.interactionId }) + .subtracting(currentCache.interactions.keys) + let allThreadIds: Set = threadIds.union(messageThreadIds) + + return try ConversationDataHelper.fetchFromDatabase( + db, + requirements: FetchRequirements( + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false, + threadIdsNeedingFetch: allThreadIds, + threadIdsNeedingInteractionStats: messageThreadIds, + interactionIdsNeedingFetch: interactionIds + ), + currentCache: currentCache, + using: dependencies + ) + } + + static func processSearchResults( + cache: ConversationDataCache, + searchText: String, + conversationResults: [GlobalSearch.ConversationSearchResult], + messageResults: [GlobalSearch.MessageSearchResult], + userSessionId: SessionId, + using dependencies: Dependencies + ) -> (conversations: [ConversationInfoViewModel], messages: [ConversationInfoViewModel]) { + let conversations: [ConversationInfoViewModel] = conversationResults.compactMap { result -> ConversationInfoViewModel? in + guard let thread: SessionThread = cache.thread(for: result.id) else { return nil } + + return ConversationInfoViewModel( + thread: thread, + dataCache: cache, + searchText: searchText, + using: dependencies + ) + } + let messages: [ConversationInfoViewModel] = messageResults.compactMap { result -> ConversationInfoViewModel? in + guard + let thread: SessionThread = cache.thread(for: result.threadId), + cache.interaction(for: result.interactionId) != nil + else { return nil } + + return ConversationInfoViewModel( + thread: thread, + dataCache: cache, + targetInteractionId: result.interactionId, + searchText: searchText, + using: dependencies + ) + } + + return (conversations, messages) + } + + static func generateCacheForDefaultContacts( + _ db: ObservingDatabase, + contactIds: [String], + using dependencies: Dependencies + ) throws -> ConversationDataCache { + return try ConversationDataHelper.fetchFromDatabase( + db, + requirements: FetchRequirements( + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false, + contactIdsNeedingFetch: Set(contactIds) + ), + currentCache: ConversationDataCache( + userSessionId: dependencies[cache: .general].sessionId, + context: ConversationDataCache.Context( + source: .searchResults, + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + using: dependencies + ) + } + + static func processDefaultContacts( + cache: ConversationDataCache, + contactIds: [String], + userSessionId: SessionId, + using dependencies: Dependencies + ) -> [ConversationInfoViewModel] { + return contactIds.compactMap { contactId -> ConversationInfoViewModel? in + guard cache.contact(for: contactId) != nil else { return nil } + + /// If there isn't a thread for the contact (because it's hidden) then we need to create one and insert it into a temporary + /// cache in order to build the view model + let thread: SessionThread = (cache.thread(for: contactId) ?? SessionThread( + id: contactId, + variant: .contact, + creationDateTimestamp: dependencies.dateNow.timeIntervalSince1970, + shouldBeVisible: false + )) + + var tempCache: ConversationDataCache = cache + tempCache.insert(thread) + + return ConversationInfoViewModel( + thread: thread, + dataCache: tempCache, + using: dependencies + ) + } + } +} + +// MARK: - ConversationSearchResult + +public extension GlobalSearch { + struct ConversationSearchResult: Decodable, FetchableRecord, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rank + case id + } + + public let rank: Double + public let id: String + + public static func defaultContactsQuery(userSessionId: SessionId) -> SQLRequest { + let contact: TypedTableAlias = TypedTableAlias() + + return """ + SELECT + 100 AS rank, + \(contact[.id]) AS id + FROM \(Contact.self) + WHERE \(contact[.isBlocked]) = false + """ + } + + public static func noteToSelfOnlyQuery(userSessionId: SessionId) -> SQLRequest { + let thread: TypedTableAlias = TypedTableAlias() + + return """ + SELECT + 100 AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + WHERE \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) + """ + } + + /// This function does an FTS search against threads and their contacts to find any which contain the pattern + /// + /// **Note:** Unfortunately the FTS search only allows for a single pattern match per query which means we + /// need to combine the results of **all** of the following potential matches as unioned queries: + /// - Contact thread contact nickname + /// - Contact thread contact name + /// - Group name + /// - Group member nickname + /// - Group member name + /// - Community name + /// - "Note to self" text match + /// - Hidden contact nickname + /// - Hidden contact name + /// + /// **Note 2:** Since the "Hidden Contact" records don't have associated threads the `rowId` value in the + /// returned results will always be `-1` for those results + public static func query( + userSessionId: SessionId, + pattern: FTS5Pattern, + searchTerm: String + ) -> SQLRequest { + let thread: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let groupMemberProfile: TypedTableAlias = TypedTableAlias( + name: "groupMemberProfile" // stringlint:ignore + ) + let openGroup: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let profileFullTextSearch: TypedTableAlias = TypedTableAlias( + name: Profile.fullTextSearchTableName + ) + let closedGroupFullTextSearch: TypedTableAlias = TypedTableAlias( + name: ClosedGroup.fullTextSearchTableName + ) + let openGroupFullTextSearch: TypedTableAlias = TypedTableAlias( + name: OpenGroup.fullTextSearchTableName + ) + + let noteToSelfLiteral: SQL = SQL(stringLiteral: "noteToSelf".localized().lowercased()) + let searchTermLiteral: SQL = SQL(stringLiteral: searchTerm.lowercased()) + + var sqlQuery: SQL = "" + + // MARK: - Contact Thread Searches + + // Contact nickname search + sqlQuery += """ + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) + ) + WHERE ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) + ) + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) + ) + WHERE ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) + ) + """ + + // MARK: - Group Searches + + // Group name search + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + JOIN \(closedGroupFullTextSearch) ON ( + \(closedGroupFullTextSearch[.rowId]) = \(closedGroup[.rowId]) AND + \(closedGroupFullTextSearch[.name]) MATCH \(pattern) + ) + WHERE ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyGroup)")) OR + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.group)")) + ) + """ + + // Group member nickname search + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(thread[.id]) + ) + JOIN \(groupMemberProfile) ON \(groupMemberProfile[.id]) = \(groupMember[.profileId]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(groupMemberProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) + ) + WHERE ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyGroup)")) OR + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.group)")) + ) + """ + + // Group member name search + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(thread[.id]) + ) + JOIN \(groupMemberProfile) ON \(groupMemberProfile[.id]) = \(groupMember[.profileId]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(groupMemberProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) + ) + WHERE ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyGroup)")) OR + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.group)")) + ) + """ + + // MARK: - Community Search + + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) + JOIN \(openGroupFullTextSearch) ON ( + \(openGroupFullTextSearch[.rowId]) = \(openGroup[.rowId]) AND + \(openGroupFullTextSearch[.name]) MATCH \(pattern) + ) + WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) + """ + + // MARK: - Note to Self Searches + + // "Note to Self" literal match + sqlQuery += """ + + UNION ALL + + SELECT + 100 AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + WHERE ( + \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) AND + '\(noteToSelfLiteral)' LIKE '%\(searchTermLiteral)%' + ) + """ + + // Note to self nickname search + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) + ) + WHERE \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) + """ + + // Note to self name search + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) + ) + WHERE \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) + """ + + // MARK: - Hidden Contact Searches + + // Hidden contact nickname + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(contact[.id]) AS id + FROM \(Contact.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(contact[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) + ) + WHERE NOT EXISTS ( + SELECT 1 FROM \(SessionThread.self) + WHERE \(thread[.id]) = \(contact[.id]) + ) + """ + + // Hidden contact name + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(contact[.id]) AS id + FROM \(Contact.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(contact[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) + ) + WHERE NOT EXISTS ( + SELECT 1 FROM \(SessionThread.self) + WHERE \(thread[.id]) = \(contact[.id]) + ) + """ + + // Final grouping and ordering + let finalQuery: SQL = """ + WITH ranked_results AS ( + \(sqlQuery) + ) + SELECT r.rank, r.id + FROM ranked_results AS r + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = r.id + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = r.id + GROUP BY r.id + ORDER BY + r.rank, + CASE WHEN r.id = \(userSessionId.hexString) THEN 0 ELSE 1 END, + COALESCE(\(closedGroup[.name]), ''), + COALESCE(\(openGroup[.name]), ''), + r.id + LIMIT \(SQL("\(searchResultsLimit)")) + """ + + return SQLRequest(literal: finalQuery, cached: false) + } + } +} + +// MARK: - MessageSearchResult + +public extension GlobalSearch { + struct MessageSearchResult: Decodable, FetchableRecord, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rank + case interactionId + case threadId + } + + public let rank: Double + public let interactionId: Int64 + public let threadId: String + + public static func query( + userSessionId: SessionId, + pattern: FTS5Pattern + ) -> SQLRequest { + let interaction: TypedTableAlias = TypedTableAlias() + let interactionFullTextSearch: TypedTableAlias = TypedTableAlias( + name: Interaction.fullTextSearchTableName + ) + + return """ + SELECT + \(Column.rank) AS rank, + \(interaction[.id]) AS interactionId, + \(interaction[.threadId]) AS threadId + FROM \(Interaction.self) + JOIN \(interactionFullTextSearch) ON ( + \(interactionFullTextSearch[.rowId]) = \(interaction[.rowId]) AND + \(interactionFullTextSearch[.body]) MATCH \(pattern) + ) + ORDER BY \(Column.rank), \(interaction[.timestampMs].desc) + LIMIT \(SQL("\(searchResultsLimit)")) + """ + } + } +} diff --git a/SessionMessagingKit/Types/LinkPreviewManager.swift b/SessionMessagingKit/Types/LinkPreviewManager.swift index 6d96ce63bb..bec7bff653 100644 --- a/SessionMessagingKit/Types/LinkPreviewManager.swift +++ b/SessionMessagingKit/Types/LinkPreviewManager.swift @@ -240,12 +240,13 @@ public actor LinkPreviewManager: LinkPreviewManagerType { return urlMatches } + // stringlint:ignore_contents private func downloadLink( url urlString: String, remainingRetries: UInt = 3 ) async throws -> (Data, URLResponse) { /// We only load Link Previews for HTTPS urls so append an explanation for not - let httpsScheme: String = "https" // stringlint:ignore + let httpsScheme: String = "https" guard URLComponents(string: urlString)?.scheme?.lowercased() == httpsScheme else { throw LinkPreviewError.insecureLink diff --git a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift b/SessionMessagingKit/Types/MessageViewModel+DeletionActions.swift similarity index 57% rename from SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift rename to SessionMessagingKit/Types/MessageViewModel+DeletionActions.swift index 67fd9318a2..ca44420ade 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift +++ b/SessionMessagingKit/Types/MessageViewModel+DeletionActions.swift @@ -118,9 +118,11 @@ public extension MessageViewModel { public extension MessageViewModel.DeletionBehaviours { static func deletionActions( for cellViewModels: [MessageViewModel], - with threadData: SessionThreadViewModel, + threadInfo: ConversationInfoViewModel, + authMethod: AuthenticationMethod, + isUserModeratorOrAdmin: Bool, using dependencies: Dependencies - ) -> MessageViewModel.DeletionBehaviours? { + ) throws -> MessageViewModel.DeletionBehaviours? { enum SelectedMessageState { case outgoingOnly case containsIncoming @@ -128,7 +130,7 @@ public extension MessageViewModel.DeletionBehaviours { } /// If it's a legacy group and they have been deprecated then the user shouldn't be able to delete messages - guard threadData.threadVariant != .legacyGroup else { return nil } + guard threadInfo.variant != .legacyGroup else { return nil } /// First determine the state of the selected messages let state: SelectedMessageState = { @@ -145,231 +147,204 @@ public extension MessageViewModel.DeletionBehaviours { }() /// The remaining deletion options are more complicated to determine - // FIXME: [Database Relocation] Remove this database usage - var deletionBehaviours: MessageViewModel.DeletionBehaviours? - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + let isAdmin: Bool = { + switch threadInfo.variant { + case .contact: return false + case .group, .legacyGroup: return (threadInfo.groupInfo?.currentUserRole == .admin) + case .community: return isUserModeratorOrAdmin + } + }() - dependencies[singleton: .storage].readAsync( - retrieve: { [dependencies] db -> MessageViewModel.DeletionBehaviours? in - let isAdmin: Bool = { - switch threadData.threadVariant { - case .contact: return false - case .group, .legacyGroup: return (threadData.currentUserIsClosedGroupAdmin == true) - case .community: - guard - let server: String = threadData.openGroupServer, - let roomToken: String = threadData.openGroupRoomToken - else { return false } - - return dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( - db, - publicKey: threadData.currentUserSessionId, - for: roomToken, - on: server, - currentUserSessionIds: (threadData.currentUserSessionIds ?? []) - ) - } - }() - - switch (state, isAdmin) { - /// User selects messages including a control, pending or “deleted” message - case (.containsLocalOnlyMessages, _): - return MessageViewModel.DeletionBehaviours( - title: "deleteMessage" - .putNumber(cellViewModels.count) - .localized(), - warning: (threadData.threadIsNoteToSelf ? - "deleteMessageNoteToSelfWarning" - .putNumber(cellViewModels.count) - .localized() : - "deleteMessageWarning" - .putNumber(cellViewModels.count) - .localized() - ), - body: "deleteMessageConfirm" - .putNumber(cellViewModels.count) - .localized(), - actions: [ - NamedAction( - title: "deleteMessageDeviceOnly".localized(), - state: .enabledAndDefaultSelected, - accessibility: Accessibility(identifier: "Delete for me"), - behaviours: [ - .cancelPendingSendJobs(cellViewModels.map { $0.id }), - - /// Control messages and deleted messages should be immediately deleted from the database - .deleteFromDatabase( - cellViewModels - .filter { viewModel in - viewModel.variant.isInfoMessage || - viewModel.variant.isDeletedMessage - } - .map { $0.id } - ), - - /// Other message types should only be marked as deleted - .markAsDeleted( - ids: cellViewModels - .filter { viewModel in - !viewModel.variant.isInfoMessage && - !viewModel.variant.isDeletedMessage - } - .map { $0.id }, - options: .local, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant - ) - ] + switch (state, isAdmin) { + /// User selects messages including a control, pending or “deleted” message + case (.containsLocalOnlyMessages, _): + return MessageViewModel.DeletionBehaviours( + title: "deleteMessage" + .putNumber(cellViewModels.count) + .localized(), + warning: (threadInfo.isNoteToSelf ? + "deleteMessageNoteToSelfWarning" + .putNumber(cellViewModels.count) + .localized() : + "deleteMessageWarning" + .putNumber(cellViewModels.count) + .localized() + ), + body: "deleteMessageConfirm" + .putNumber(cellViewModels.count) + .localized(), + actions: [ + NamedAction( + title: "deleteMessageDeviceOnly".localized(), + state: .enabledAndDefaultSelected, + accessibility: Accessibility(identifier: "Delete for me"), + behaviours: [ + .cancelPendingSendJobs(cellViewModels.map { $0.id }), + + /// Control messages and deleted messages should be immediately deleted from the database + .deleteFromDatabase( + cellViewModels + .filter { viewModel in + viewModel.variant.isInfoMessage || + viewModel.variant.isDeletedMessage + } + .map { $0.id } ), - NamedAction( - title: (threadData.threadIsNoteToSelf ? - "deleteMessageDevicesAll".localized() : - "deleteMessageEveryone".localized() - ), - state: .disabled, - accessibility: Accessibility(identifier: "Delete for everyone") + + /// Other message types should only be marked as deleted + .markAsDeleted( + ids: cellViewModels + .filter { viewModel in + !viewModel.variant.isInfoMessage && + !viewModel.variant.isDeletedMessage + } + .map { $0.id }, + options: .local, + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ] + ), + NamedAction( + title: (threadInfo.isNoteToSelf ? + "deleteMessageDevicesAll".localized() : + "deleteMessageEveryone".localized() + ), + state: .disabled, + accessibility: Accessibility(identifier: "Delete for everyone") ) - - /// User selects messages including only their own messages - case (.outgoingOnly, _): - return MessageViewModel.DeletionBehaviours( - title: "deleteMessage" - .putNumber(cellViewModels.count) - .localized(), - warning: nil, - body: "deleteMessageConfirm" - .putNumber(cellViewModels.count) - .localized(), - actions: [ - NamedAction( - title: "deleteMessageDeviceOnly".localized(), - state: .enabledAndDefaultSelected, - accessibility: Accessibility(identifier: "Delete for me"), - behaviours: [ - .cancelPendingSendJobs(cellViewModels.map { $0.id }), - .markAsDeleted( - ids: cellViewModels.map { $0.id }, - options: .local, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant - ) - ] - ), - NamedAction( - title: (threadData.threadIsNoteToSelf ? - "deleteMessageDevicesAll".localized() : - "deleteMessageEveryone".localized() - ), - state: .enabled, - accessibility: Accessibility(identifier: "Delete for everyone"), - behaviours: try deleteForEveryoneBehaviours( - db, - isAdmin: isAdmin, - threadData: threadData, - cellViewModels: cellViewModels, - using: dependencies - ) + ] + ) + + /// User selects messages including only their own messages + case (.outgoingOnly, _): + return MessageViewModel.DeletionBehaviours( + title: "deleteMessage" + .putNumber(cellViewModels.count) + .localized(), + warning: nil, + body: "deleteMessageConfirm" + .putNumber(cellViewModels.count) + .localized(), + actions: [ + NamedAction( + title: "deleteMessageDeviceOnly".localized(), + state: .enabledAndDefaultSelected, + accessibility: Accessibility(identifier: "Delete for me"), + behaviours: [ + .cancelPendingSendJobs(cellViewModels.map { $0.id }), + .markAsDeleted( + ids: cellViewModels.map { $0.id }, + options: .local, + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ] + ), + NamedAction( + title: (threadInfo.isNoteToSelf ? + "deleteMessageDevicesAll".localized() : + "deleteMessageEveryone".localized() + ), + state: .enabled, + accessibility: Accessibility(identifier: "Delete for everyone"), + behaviours: try deleteForEveryoneBehaviours( + isAdmin: isAdmin, + threadInfo: threadInfo, + authMethod: authMethod, + cellViewModels: cellViewModels, + using: dependencies + ) ) - - /// User selects messages including ones from other users - case (.containsIncoming, false): - return MessageViewModel.DeletionBehaviours( - title: "deleteMessage" - .putNumber(cellViewModels.count) - .localized(), - warning: "deleteMessageWarning" - .putNumber(cellViewModels.count) - .localized(), - body: "deleteMessageDescriptionDevice" - .putNumber(cellViewModels.count) - .localized(), - actions: [ - NamedAction( - title: "deleteMessageDeviceOnly".localized(), - state: .enabledAndDefaultSelected, - accessibility: Accessibility(identifier: "Delete for me"), - behaviours: [ - .cancelPendingSendJobs(cellViewModels.map { $0.id }), - .markAsDeleted( - ids: cellViewModels.map { $0.id }, - options: .local, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant - ) - ] - ), - NamedAction( - title: "deleteMessageEveryone".localized(), - state: .disabled, - accessibility: Accessibility(identifier: "Delete for everyone") + ] + ) + + /// User selects messages including ones from other users + case (.containsIncoming, false): + return MessageViewModel.DeletionBehaviours( + title: "deleteMessage" + .putNumber(cellViewModels.count) + .localized(), + warning: "deleteMessageWarning" + .putNumber(cellViewModels.count) + .localized(), + body: "deleteMessageDescriptionDevice" + .putNumber(cellViewModels.count) + .localized(), + actions: [ + NamedAction( + title: "deleteMessageDeviceOnly".localized(), + state: .enabledAndDefaultSelected, + accessibility: Accessibility(identifier: "Delete for me"), + behaviours: [ + .cancelPendingSendJobs(cellViewModels.map { $0.id }), + .markAsDeleted( + ids: cellViewModels.map { $0.id }, + options: .local, + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ] + ), + NamedAction( + title: "deleteMessageEveryone".localized(), + state: .disabled, + accessibility: Accessibility(identifier: "Delete for everyone") ) - - /// Admin can multi-select their own messages and messages from other users - case (.containsIncoming, true): - return MessageViewModel.DeletionBehaviours( - title: "deleteMessage" - .putNumber(cellViewModels.count) - .localized(), - warning: nil, - body: "deleteMessageConfirm" - .putNumber(cellViewModels.count) - .localized(), - actions: [ - NamedAction( - title: "deleteMessageDeviceOnly".localized(), - state: .enabled, - accessibility: Accessibility(identifier: "Delete for me"), - behaviours: [ - .cancelPendingSendJobs(cellViewModels.map { $0.id }), - .markAsDeleted( - ids: cellViewModels.map { $0.id }, - options: .local, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant - ) - ] - ), - NamedAction( - title: "deleteMessageEveryone".localized(), - state: .enabledAndDefaultSelected, - accessibility: Accessibility(identifier: "Delete for everyone"), - behaviours: try deleteForEveryoneBehaviours( - db, - isAdmin: isAdmin, - threadData: threadData, - cellViewModels: cellViewModels, - using: dependencies - ) + ] + ) + + /// Admin can multi-select their own messages and messages from other users + case (.containsIncoming, true): + return MessageViewModel.DeletionBehaviours( + title: "deleteMessage" + .putNumber(cellViewModels.count) + .localized(), + warning: nil, + body: "deleteMessageConfirm" + .putNumber(cellViewModels.count) + .localized(), + actions: [ + NamedAction( + title: "deleteMessageDeviceOnly".localized(), + state: .enabled, + accessibility: Accessibility(identifier: "Delete for me"), + behaviours: [ + .cancelPendingSendJobs(cellViewModels.map { $0.id }), + .markAsDeleted( + ids: cellViewModels.map { $0.id }, + options: .local, + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ] + ), + NamedAction( + title: "deleteMessageEveryone".localized(), + state: .enabledAndDefaultSelected, + accessibility: Accessibility(identifier: "Delete for everyone"), + behaviours: try deleteForEveryoneBehaviours( + isAdmin: isAdmin, + threadInfo: threadInfo, + authMethod: authMethod, + cellViewModels: cellViewModels, + using: dependencies + ) ) - } - }, - completion: { result in - deletionBehaviours = try? result.successOrThrow() - semaphore.signal() - } - ) - semaphore.wait() - - return deletionBehaviours + ] + ) + } } private static func deleteForEveryoneBehaviours( - _ db: ObservingDatabase, isAdmin: Bool, - threadData: SessionThreadViewModel, + threadInfo: ConversationInfoViewModel, + authMethod: AuthenticationMethod, cellViewModels: [MessageViewModel], using dependencies: Dependencies ) throws -> [Behaviour] { /// The non-local deletion behaviours differ depending on the type of conversation - switch (threadData.threadVariant, isAdmin) { + switch (threadInfo.variant, isAdmin) { /// **Note to Self or Contact Conversation** /// Delete from all participant devices via an `UnsendRequest` (these will trigger their own sync messages) /// Delete from the current users swarm (where possible) @@ -377,16 +352,16 @@ public extension MessageViewModel.DeletionBehaviours { case (.contact, _): /// Only include messages sent by the current user (can't delete incoming messages in contact conversations) let targetViewModels: [MessageViewModel] = cellViewModels - .filter { threadData.currentUserSessionId.contains($0.authorId) } + .filter { threadInfo.currentUserSessionIds.contains($0.authorId) } let serverHashes: Set = targetViewModels.compactMap { $0.serverHash }.asSet() .inserting(contentsOf: Set(targetViewModels.flatMap { message in - (message.reactionInfo ?? []).compactMap { $0.reaction.serverHash } + message.reactionInfo.compactMap { $0.reaction.serverHash } })) let unsendRequests: [Network.PreparedRequest] = try targetViewModels.map { model in try MessageSender.preparedSend( message: UnsendRequest( timestamp: UInt64(model.timestampMs), - author: threadData.currentUserSessionId + author: threadInfo.userSessionId.hexString ) .with( expiresInSeconds: model.expiresInSeconds, @@ -396,12 +371,7 @@ public extension MessageViewModel.DeletionBehaviours { namespace: .default, interactionId: nil, attachments: nil, - authMethod: try Authentication.with( - db, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - using: dependencies - ), + authMethod: authMethod, onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -419,7 +389,7 @@ public extension MessageViewModel.DeletionBehaviours { try Network.SnodeAPI.preparedBatch( requests: unsendRequestChunk, requireAllBatchResponses: false, - swarmPublicKey: threadData.threadId, + swarmPublicKey: threadInfo.id, using: dependencies ).map { _, _ in () } ) @@ -427,12 +397,12 @@ public extension MessageViewModel.DeletionBehaviours { ) .appending(serverHashes.isEmpty ? nil : .preparedRequest( + /// Need to delete the the current users swarm which needs it's own `authMethod` try Network.SnodeAPI.preparedDeleteMessages( serverHashes: Array(serverHashes), requireSuccessfulDeletion: false, authMethod: try Authentication.with( - db, - swarmPublicKey: threadData.currentUserSessionId, + swarmPublicKey: threadInfo.userSessionId.hexString, using: dependencies ), using: dependencies @@ -440,14 +410,14 @@ public extension MessageViewModel.DeletionBehaviours { .map { _, _ in () } ) ) - .appending(threadData.threadIsNoteToSelf ? + .appending(threadInfo.isNoteToSelf ? /// If it's the `Note to Self`conversation then we want to just delete the interaction .deleteFromDatabase(cellViewModels.map { $0.id }) : .markAsDeleted( ids: targetViewModels.map { $0.id }, options: [.local, .network], - threadId: threadData.threadId, - threadVariant: threadData.threadVariant + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ) @@ -459,13 +429,13 @@ public extension MessageViewModel.DeletionBehaviours { case (.legacyGroup, _): /// Only try to delete messages send by other users if the current user is an admin let targetViewModels: [MessageViewModel] = cellViewModels - .filter { isAdmin || (threadData.currentUserSessionIds ?? []).contains($0.authorId) } + .filter { isAdmin || threadInfo.currentUserSessionIds.contains($0.authorId) } let unsendRequests: [Network.PreparedRequest] = try targetViewModels.map { model in try MessageSender.preparedSend( message: UnsendRequest( timestamp: UInt64(model.timestampMs), author: (model.variant == .standardOutgoing ? - threadData.currentUserSessionId : + threadInfo.userSessionId.hexString : model.authorId ) ) @@ -473,16 +443,11 @@ public extension MessageViewModel.DeletionBehaviours { expiresInSeconds: model.expiresInSeconds, expiresStartedAtMs: model.expiresStartedAtMs ), - to: .closedGroup(groupPublicKey: model.threadId), + to: .group(publicKey: model.threadId), namespace: .legacyClosedGroup, interactionId: nil, attachments: nil, - authMethod: try Authentication.with( - db, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - using: dependencies - ), + authMethod: authMethod, onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -500,7 +465,7 @@ public extension MessageViewModel.DeletionBehaviours { try Network.SnodeAPI.preparedBatch( requests: unsendRequestChunk, requireAllBatchResponses: false, - swarmPublicKey: threadData.threadId, + swarmPublicKey: threadInfo.id, using: dependencies ).map { _, _ in () } ) @@ -510,8 +475,8 @@ public extension MessageViewModel.DeletionBehaviours { .markAsDeleted( ids: targetViewModels.map { $0.id }, options: [.local, .network], - threadId: threadData.threadId, - threadVariant: threadData.threadVariant + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ) @@ -523,10 +488,10 @@ public extension MessageViewModel.DeletionBehaviours { case (.group, false): /// Only include messages sent by the current user (non-admins can't delete incoming messages in group conversations) let targetViewModels: [MessageViewModel] = cellViewModels - .filter { (threadData.currentUserSessionIds ?? []).contains($0.authorId) } + .filter { threadInfo.currentUserSessionIds.contains($0.authorId) } let serverHashes: Set = targetViewModels.compactMap { $0.serverHash }.asSet() .inserting(contentsOf: Set(targetViewModels.flatMap { message in - (message.reactionInfo ?? []).compactMap { $0.reaction.serverHash } + message.reactionInfo.compactMap { $0.reaction.serverHash } })) return [.cancelPendingSendJobs(targetViewModels.map { $0.id })] @@ -541,16 +506,11 @@ public extension MessageViewModel.DeletionBehaviours { authMethod: nil, using: dependencies ), - to: .closedGroup(groupPublicKey: threadData.threadId), + to: .group(publicKey: threadInfo.id), namespace: .groupMessages, interactionId: nil, attachments: nil, - authMethod: try Authentication.with( - db, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - using: dependencies - ), + authMethod: authMethod, onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -561,8 +521,8 @@ public extension MessageViewModel.DeletionBehaviours { .markAsDeleted( ids: targetViewModels.map { $0.id }, options: [.local, .network], - threadId: threadData.threadId, - threadVariant: threadData.threadVariant + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ) @@ -573,7 +533,7 @@ public extension MessageViewModel.DeletionBehaviours { case (.group, true): guard let ed25519SecretKey: [UInt8] = dependencies.mutate(cache: .libSession, { cache in - cache.secretKey(groupSessionId: SessionId(.group, hex: threadData.threadId)) + cache.secretKey(groupSessionId: SessionId(.group, hex: threadInfo.id)) }) else { Log.error("[ConversationViewModel] Failed to retrieve groupIdentityPrivateKey when trying to delete messages from group.") @@ -583,7 +543,7 @@ public extension MessageViewModel.DeletionBehaviours { /// Only try to delete messages with server hashes (can't delete them otherwise) let serverHashes: Set = cellViewModels.compactMap { $0.serverHash }.asSet() .inserting(contentsOf: Set(cellViewModels.flatMap { message in - (message.reactionInfo ?? []).compactMap { $0.reaction.serverHash } + message.reactionInfo.compactMap { $0.reaction.serverHash } })) return [.cancelPendingSendJobs(cellViewModels.map { $0.id })] @@ -595,21 +555,16 @@ public extension MessageViewModel.DeletionBehaviours { messageHashes: Array(serverHashes), sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), authMethod: Authentication.groupAdmin( - groupSessionId: SessionId(.group, hex: threadData.threadId), + groupSessionId: SessionId(.group, hex: threadInfo.id), ed25519SecretKey: ed25519SecretKey ), using: dependencies ), - to: .closedGroup(groupPublicKey: threadData.threadId), + to: .group(publicKey: threadInfo.id), namespace: .groupMessages, interactionId: nil, attachments: nil, - authMethod: try Authentication.with( - db, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - using: dependencies - ), + authMethod: authMethod, onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -622,7 +577,7 @@ public extension MessageViewModel.DeletionBehaviours { serverHashes: Array(serverHashes), requireSuccessfulDeletion: false, authMethod: Authentication.groupAdmin( - groupSessionId: SessionId(.group, hex: threadData.threadId), + groupSessionId: SessionId(.group, hex: threadInfo.id), ed25519SecretKey: Array(ed25519SecretKey) ), using: dependencies @@ -633,8 +588,8 @@ public extension MessageViewModel.DeletionBehaviours { .markAsDeleted( ids: cellViewModels.map { $0.id }, options: [.local, .network], - threadId: threadData.threadId, - threadVariant: threadData.threadVariant + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ) @@ -645,17 +600,11 @@ public extension MessageViewModel.DeletionBehaviours { /// **Note:** To simplify the logic (since the sender is a blinded id) we don't bother doing admin/sender checks here /// and just rely on the UI state or the SOGS server (if the UI allows an invalid case) to prevent invalid behaviours case (.community, _): - guard let roomToken: String = threadData.openGroupRoomToken else { + guard let roomToken: String = threadInfo.communityInfo?.roomToken else { Log.error("[ConversationViewModel] Failed to retrieve community info when trying to delete messages.") throw StorageError.objectNotFound } - let authMethod: AuthenticationMethod = try Authentication.with( - db, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - using: dependencies - ) let deleteRequests: [Network.PreparedRequest] = try cellViewModels .compactMap { $0.openGroupServerMessageId } .map { messageId in diff --git a/SessionMessagingKit/Types/MessageViewModel.swift b/SessionMessagingKit/Types/MessageViewModel.swift new file mode 100644 index 0000000000..ecc13f425b --- /dev/null +++ b/SessionMessagingKit/Types/MessageViewModel.swift @@ -0,0 +1,1228 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import UniformTypeIdentifiers +import GRDB +import DifferenceKit +import SessionUIKit +import SessionUtilitiesKit + +public struct MessageViewModel: Sendable, Equatable, Hashable, Identifiable, Differentiable { + public enum Gesture { + case tap + case doubleTap + case longPress + } + + public enum CellType: Sendable, Equatable, Hashable { + case textOnlyMessage + case mediaMessage + case audio + case voiceMessage + case genericAttachment + case infoMessage + case call + case typingIndicator + case dateHeader + case unreadMarker + + public var supportedGestures: Set { + switch self { + case .typingIndicator, .dateHeader, .unreadMarker: return [] + case .voiceMessage: return [.tap, .doubleTap, .longPress] + case .textOnlyMessage, .mediaMessage, .audio, .genericAttachment, + .infoMessage, .call: + return [.tap, .longPress] + } + } + } + + public var differenceIdentifier: Int64 { id } + + /// This value will be used to populate the Context Menu and date header (if present) + public var dateForUI: Date { Date(timeIntervalSince1970: TimeInterval(Double(self.timestampMs) / 1000)) } + + /// This value will be used to populate the Message Info (if present) + public var receivedDateForUI: Date { + Date(timeIntervalSince1970: TimeInterval(Double(self.receivedAtTimestampMs) / 1000)) + } + + public var bodyTextColor: ThemeValue { MessageViewModel.bodyTextColor(isOutgoing: variant.isOutgoing) } + + /// This value defines what type of cell should appear and is generated based on the interaction variant + /// and associated attachment data + public let cellType: CellType + + /// This is a temporary id used before an outgoing message is persisted into the database + public let optimisticMessageId: Int64? + + // Thread Data + + public let threadId: String + public let threadVariant: SessionThread.Variant + public let threadIsTrusted: Bool + + // Interaction Data + + public let id: Int64 + public let variant: Interaction.Variant + public let serverHash: String? + public let openGroupServerMessageId: Int64? + public let authorId: String + + /// The value will be populated if the sender has a blinded id and we have resolved it to an unblinded id + public let authorUnblindedId: String? + public let bubbleBody: String? + public let rawBody: String? + public let bodyForCopying: String? + public let timestampMs: Int64 + public let receivedAtTimestampMs: Int64 + public let expiresStartedAtMs: Double? + public let expiresInSeconds: TimeInterval? + public let attachments: [Attachment] + public let reactionInfo: [ReactionInfo] + public let profile: Profile + public let quoteViewModel: QuoteViewModel? + public let linkPreview: LinkPreview? + public let linkPreviewAttachment: Attachment? + public let proMessageFeatures: SessionPro.MessageFeatures + public let proProfileFeatures: SessionPro.ProfileFeatures + + public let state: Interaction.State + public let hasBeenReadByRecipient: Bool + public let mostRecentFailureText: String? + public let isSenderModeratorOrAdmin: Bool + public let canFollowDisappearingMessagesSetting: Bool + + // Display Properties + + /// A flag indicating whether the author name should be displayed + public let shouldShowAuthorName: Bool + + /// A flag indicating whether the profile view can be displayed + public let canHaveProfile: Bool + + /// A flag indicating whether the display picture view should be displayed + public let shouldShowDisplayPicture: Bool + + /// A flag which controls whether the date header should be displayed + public let shouldShowDateHeader: Bool + + /// This value specifies whether the body contains only emoji characters + public let containsOnlyEmoji: Bool + + /// This value specifies the number of emoji characters the body contains + public let glyphCount: Int + + /// This value indicates the variant of the previous ViewModel item, if it's null then there is no previous item + public let previousVariant: Interaction.Variant? + + /// This value indicates the position of this message within a cluser of messages + public let positionInCluster: Position + + /// This value indicates whether this is the only message in a cluser of messages + public let isOnlyMessageInCluster: Bool + + /// This value indicates whether this is the last message in the thread + public let isLast: Bool + + /// This value indicates whether this is the last outgoing message in the thread + public let isLastOutgoing: Bool + + /// This contains all sessionId values for the current user (standard and any blinded variants) + public let currentUserSessionIds: Set + + /// This is the mention image for the current user + public let currentUserMentionImage: UIImage? +} + +public extension MessageViewModel { + private static let genericId: Int64 = -1 + private static let typingIndicatorId: Int64 = -2 + + static var typingIndicator: MessageViewModel = MessageViewModel( + cellType: .typingIndicator, + timestampMs: 0 + ) + + init( + cellType: CellType, + timestampMs: Int64, + variant: Interaction.Variant = .standardOutgoing, + body: String? = nil, + quoteViewModel: QuoteViewModel? = nil, + isLast: Bool = true + ) { + self.id = { + switch cellType { + case .typingIndicator: return MessageViewModel.typingIndicatorId + case .dateHeader: return -timestampMs + default: return MessageViewModel.genericId + } + }() + self.cellType = cellType + self.timestampMs = timestampMs + self.variant = variant + self.bubbleBody = body + self.rawBody = body + self.bodyForCopying = body + self.quoteViewModel = quoteViewModel + + /// These values shouldn't be used for the custom types + self.optimisticMessageId = nil + self.threadId = "INVALID_THREAD_ID" + self.threadVariant = .contact + self.threadIsTrusted = false + self.serverHash = "" + self.openGroupServerMessageId = nil + self.authorId = "" + self.authorUnblindedId = nil + self.receivedAtTimestampMs = 0 + self.expiresStartedAtMs = nil + self.expiresInSeconds = nil + self.attachments = [] + self.reactionInfo = [] + self.profile = Profile.with(id: "", name: "") + self.linkPreview = nil + self.linkPreviewAttachment = nil + self.proMessageFeatures = .none + self.proProfileFeatures = .none + + self.state = .localOnly + self.hasBeenReadByRecipient = false + self.mostRecentFailureText = nil + self.isSenderModeratorOrAdmin = false + self.canFollowDisappearingMessagesSetting = false + + self.shouldShowAuthorName = false + self.canHaveProfile = false + self.shouldShowDisplayPicture = false + self.shouldShowDateHeader = false + self.containsOnlyEmoji = false + self.glyphCount = 0 + self.previousVariant = nil + + self.positionInCluster = .individual + self.isOnlyMessageInCluster = true + self.isLast = false + self.isLastOutgoing = false + self.currentUserSessionIds = [] + self.currentUserMentionImage = nil + } + + init?( + optimisticMessageId: Int64? = nil, + interaction: Interaction, + reactionInfo: [MessageViewModel.ReactionInfo]?, + maybeUnresolvedQuotedInfo: MaybeUnresolvedQuotedInfo?, + userSessionId: SessionId, + threadInfo: ConversationInfoViewModel, + dataCache: ConversationDataCache, + previousInteraction: Interaction?, + nextInteraction: Interaction?, + isLast: Bool, + isLastOutgoing: Bool, + currentUserMentionImage: UIImage?, + using dependencies: Dependencies + ) { + let targetId: Int64 + + switch (optimisticMessageId, interaction.id) { + case (.some(let id), _): targetId = id + case (_, .some(let id)): targetId = id + case (.none, .none): return nil + } + + let currentUserSessionIds: Set = dataCache.currentUserSessionIds(for: threadInfo.id) + let targetProfile: Profile = { + /// If the sender is the current user then use the proper profile from the cache (instead of a random blinded one) + guard !currentUserSessionIds.contains(interaction.authorId) else { + return (dataCache.profile(for: userSessionId.hexString) ?? Profile.defaultFor(userSessionId.hexString)) + } + + if let unblindedProfile: Profile = dataCache.unblindedId(for: interaction.authorId).map({ dataCache.profile(for: $0) }) { + return unblindedProfile + } + + return (dataCache.profile(for: interaction.authorId) ?? Profile.defaultFor(interaction.authorId)) + }() + let contentBuilder: Interaction.ContentBuilder = Interaction.ContentBuilder( + interaction: interaction, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, + dataCache: dataCache + ) + let proMessageFeatures: SessionPro.MessageFeatures = { + guard dependencies[feature: .sessionProEnabled] else { return .none } + + if dependencies[feature: .forceMessageFeatureLongMessage] { + return interaction.proMessageFeatures.union(.largerCharacterLimit) + } + + return interaction.proMessageFeatures + }() + let proProfileFeatures: SessionPro.ProfileFeatures = { + guard dependencies[feature: .sessionProEnabled] else { return .none } + + var result: SessionPro.ProfileFeatures = interaction.proProfileFeatures + + if dependencies[feature: .forceMessageFeatureProBadge] { + result.insert(.proBadge) + } + + if dependencies[feature: .forceMessageFeatureAnimatedAvatar] { + result.insert(.animatedAvatar) + } + + return result + }() + + self.cellType = MessageViewModel.cellType( + interaction: interaction, + attachments: contentBuilder.attachments + ) + self.optimisticMessageId = optimisticMessageId + self.threadId = threadInfo.id + self.threadVariant = threadInfo.variant + self.threadIsTrusted = { + switch threadInfo.variant { + case .legacyGroup, .community, .group: return true /// Default to `true` for non-contact threads + case .contact: return (dataCache.contact(for: threadInfo.id)?.isTrusted == true) + } + }() + self.id = targetId + self.variant = interaction.variant + self.serverHash = interaction.serverHash + self.openGroupServerMessageId = interaction.openGroupServerMessageId + self.authorId = interaction.authorId + self.authorUnblindedId = dataCache.unblindedId(for: authorId) + self.bubbleBody = contentBuilder.makeBubbleBody() + self.rawBody = interaction.body + self.bodyForCopying = contentBuilder.makeBodyForCopying() + self.timestampMs = interaction.timestampMs + self.receivedAtTimestampMs = interaction.receivedAtTimestampMs + self.expiresStartedAtMs = interaction.expiresStartedAtMs + self.expiresInSeconds = interaction.expiresInSeconds + self.attachments = contentBuilder.attachments + self.reactionInfo = (reactionInfo ?? []) + self.profile = targetProfile.with( + proFeatures: .set(to: dependencies[singleton: .sessionProManager].profileFeatures(for: targetProfile)) + ) + self.quoteViewModel = maybeUnresolvedQuotedInfo.map { info -> QuoteViewModel? in + /// Should be `interaction` not `quotedInteraction` + let targetDirection: QuoteViewModel.Direction = (interaction.variant.isOutgoing ? + .outgoing : + .incoming + ) + + /// If the message contains a `Quote` but we couldn't resolve the original message then we still want to return a + /// `QuoteViewModel` so that it's rendered correctly (it'll just render that it couldn't resolve) + guard + let quotedInteractionId: Int64 = info.foundQuotedInteractionId, + let quotedInteraction: Interaction = info.resolvedQuotedInteraction + else { + return QuoteViewModel( + mode: .regular, + direction: targetDirection, + quotedInfo: nil, + showProBadge: false, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: { _, _ in nil }, + currentUserMentionImage: nil + ) + } + + let quotedAuthorProfile: Profile = { + /// If the sender is the current user then use the proper profile from the cache (instead of a random blinded one) + guard !currentUserSessionIds.contains(quotedInteraction.authorId) else { + return (dataCache.profile(for: userSessionId.hexString) ?? Profile.defaultFor(userSessionId.hexString)) + } + + if let unblindedProfile: Profile = dataCache.unblindedId(for: quotedInteraction.authorId).map({ dataCache.profile(for: $0) }) { + return unblindedProfile + } + + return ( + dataCache.profile(for: quotedInteraction.authorId) ?? + Profile.defaultFor(quotedInteraction.authorId) + ) + }() + let quotedContentBuilder: Interaction.ContentBuilder = Interaction.ContentBuilder( + interaction: quotedInteraction, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, + dataCache: dataCache + ) + let targetQuotedAttachment: Attachment? = ( + quotedContentBuilder.attachments.first ?? + quotedContentBuilder.linkPreviewAttachment + ) + + return QuoteViewModel( + mode: .regular, + direction: targetDirection, + quotedInfo: QuoteViewModel.QuotedInfo( + interactionId: quotedInteractionId, + authorId: quotedInteraction.authorId, + authorName: quotedContentBuilder.authorDisplayName, + timestampMs: quotedInteraction.timestampMs, + body: quotedContentBuilder.makeBubbleBody(), + attachmentInfo: targetQuotedAttachment.map { quotedAttachment in + let utType: UTType = (UTType(sessionMimeType: quotedAttachment.contentType) ?? .invalid) + + return QuoteViewModel.AttachmentInfo( + id: quotedAttachment.id, + utType: utType, + isVoiceMessage: (quotedAttachment.variant == .voiceMessage), + downloadUrl: quotedAttachment.downloadUrl, + sourceFilename: quotedAttachment.sourceFilename, + thumbnailSource: quotedAttachment.downloadUrl.map { downloadUrl -> ImageDataManager.DataSource? in + guard + let path: String = try? dependencies[singleton: .attachmentManager] + .path(for: downloadUrl) + else { return nil } + + return .thumbnailFrom( + utType: utType, + path: path, + sourceFilename: quotedAttachment.sourceFilename, + size: .small, + using: dependencies + ) + } + ) + } + ), + showProBadge: dependencies[singleton: .sessionProManager] + .profileFeatures(for: quotedAuthorProfile) + .contains(.proBadge), + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: dataCache.displayNameRetriever( + for: threadInfo.id, + includeSessionIdSuffixWhenInMessageBody: (threadInfo.variant == .community) + ), + currentUserMentionImage: currentUserMentionImage + ) + } + self.linkPreview = contentBuilder.linkPreview + self.linkPreviewAttachment = contentBuilder.linkPreviewAttachment + self.proMessageFeatures = proMessageFeatures + self.proProfileFeatures = proProfileFeatures + + self.state = interaction.state + self.hasBeenReadByRecipient = (interaction.recipientReadTimestampMs != nil) + self.mostRecentFailureText = interaction.mostRecentFailureText + self.isSenderModeratorOrAdmin = dataCache + .communityModAdminIds(for: threadInfo.id) + .contains(interaction.authorId) + self.canFollowDisappearingMessagesSetting = { + guard + threadInfo.variant == .contact && + interaction.variant == .infoDisappearingMessagesUpdate && + !currentUserSessionIds.contains(interaction.authorId) + else { return false } + + return ( + dataCache.disappearingMessageConfiguration(for: threadInfo.id) != DisappearingMessagesConfiguration + .defaultWith(threadInfo.id) + .with( + isEnabled: (interaction.expiresInSeconds ?? 0) > 0, + durationSeconds: interaction.expiresInSeconds, + type: (Int64(interaction.expiresStartedAtMs ?? 0) == interaction.timestampMs ? + .disappearAfterSend : + .disappearAfterRead + ) + ) + ) + }() + + let isGroupThread: Bool = ( + threadVariant == .community || + threadVariant == .legacyGroup || + threadVariant == .group + ) + let shouldShowDateBeforeThisModel: Bool = { + guard interaction.variant != .infoCall else { return true } /// Always show on calls + guard !interaction.variant.isInfoMessage else { return false } /// Never show on info messages + guard let previousInteraction: Interaction = previousInteraction else { return true } + + return MessageViewModel.shouldShowDateBreak( + between: previousInteraction.timestampMs, + and: interaction.timestampMs + ) + }() + let shouldShowDateBeforeNextModel: Bool = { + /// Should be nothing after a typing indicator + guard let nextInteraction: Interaction = nextInteraction else { return false } + + return MessageViewModel.shouldShowDateBreak( + between: interaction.timestampMs, + and: nextInteraction.timestampMs + ) + }() + self.shouldShowAuthorName = { + /// Only show for group threads + guard isGroupThread else { return false } + + /// Only show for incoming messages + guard interaction.variant.isIncoming else { return false } + + /// Only if there is a date header or the senders are different + guard + shouldShowDateBeforeThisModel || + interaction.authorId != previousInteraction?.authorId || + previousInteraction?.variant.isInfoMessage == true + else { return false } + + return true + }() + self.canHaveProfile = ( + /// Only group threads and incoming messages + isGroupThread && + interaction.variant.isIncoming + ) + self.shouldShowDisplayPicture = ( + /// Only group threads + isGroupThread && + + /// Only incoming messages + interaction.variant.isIncoming && + + /// Show if the next message has a different sender, isn't a standard message or has a "date break" + ( + interaction.authorId != nextInteraction?.authorId || + nextInteraction?.variant.isIncoming != true || + shouldShowDateBeforeNextModel + ) + ) + self.shouldShowDateHeader = shouldShowDateBeforeThisModel + self.containsOnlyEmoji = contentBuilder.containsOnlyEmoji + self.glyphCount = contentBuilder.glyphCount + self.previousVariant = previousInteraction?.variant + + let (positionInCluster, isOnlyMessageInCluster): (Position, Bool) = { + let isFirstInCluster: Bool = ( + interaction.variant.isInfoMessage || + previousInteraction == nil || + shouldShowDateBeforeThisModel || ( + interaction.variant.isOutgoing && + previousInteraction?.variant.isOutgoing != true + ) || ( + interaction.variant.isIncoming && + previousInteraction?.variant.isIncoming != true + ) || + interaction.authorId != previousInteraction?.authorId + ) + let isLastInCluster: Bool = ( + interaction.variant.isInfoMessage || + nextInteraction == nil || + shouldShowDateBeforeNextModel || ( + interaction.variant.isOutgoing && + nextInteraction?.variant.isOutgoing != true + ) || ( + interaction.variant.isIncoming && + nextInteraction?.variant.isIncoming != true + ) || + interaction.authorId != nextInteraction?.authorId + ) + + let isOnlyMessageInCluster: Bool = (isFirstInCluster && isLastInCluster) + + switch (isFirstInCluster, isLastInCluster) { + case (true, true), (false, false): return (.middle, isOnlyMessageInCluster) + case (true, false): return (.top, isOnlyMessageInCluster) + case (false, true): return (.bottom, isOnlyMessageInCluster) + } + }() + + self.positionInCluster = positionInCluster + self.isOnlyMessageInCluster = isOnlyMessageInCluster + self.isLast = isLast + self.isLastOutgoing = isLastOutgoing + self.currentUserSessionIds = currentUserSessionIds + self.currentUserMentionImage = currentUserMentionImage + } + + func with( + state: Update = .useExisting, // Optimistic outgoing messages + mostRecentFailureText: Update = .useExisting // Optimistic outgoing messages + ) -> MessageViewModel { + return MessageViewModel( + cellType: cellType, + optimisticMessageId: optimisticMessageId, + threadId: threadId, + threadVariant: threadVariant, + threadIsTrusted: threadIsTrusted, + id: id, + variant: variant, + serverHash: serverHash, + openGroupServerMessageId: openGroupServerMessageId, + authorId: authorId, + authorUnblindedId: authorUnblindedId, + bubbleBody: bubbleBody, + rawBody: rawBody, + bodyForCopying: bodyForCopying, + timestampMs: timestampMs, + receivedAtTimestampMs: receivedAtTimestampMs, + expiresStartedAtMs: expiresStartedAtMs, + expiresInSeconds: expiresInSeconds, + attachments: attachments, + reactionInfo: reactionInfo, + profile: profile, + quoteViewModel: quoteViewModel, + linkPreview: linkPreview, + linkPreviewAttachment: linkPreviewAttachment, + proMessageFeatures: proMessageFeatures, + proProfileFeatures: proProfileFeatures, + state: state.or(self.state), + hasBeenReadByRecipient: hasBeenReadByRecipient, + mostRecentFailureText: mostRecentFailureText.or(self.mostRecentFailureText), + isSenderModeratorOrAdmin: isSenderModeratorOrAdmin, + canFollowDisappearingMessagesSetting: canFollowDisappearingMessagesSetting, + shouldShowAuthorName: shouldShowAuthorName, + canHaveProfile: canHaveProfile, + shouldShowDisplayPicture: shouldShowDisplayPicture, + shouldShowDateHeader: shouldShowDateHeader, + containsOnlyEmoji: containsOnlyEmoji, + glyphCount: glyphCount, + previousVariant: previousVariant, + positionInCluster: positionInCluster, + isOnlyMessageInCluster: isOnlyMessageInCluster, + isLast: isLast, + isLastOutgoing: isLastOutgoing, + currentUserSessionIds: currentUserSessionIds, + currentUserMentionImage: currentUserMentionImage + ) + } + + func authorName( + ignoreNickname: Bool = false + ) -> String { + return profile.displayName( + ignoreNickname: ignoreNickname, + showYouForCurrentUser: true, + currentUserSessionIds: currentUserSessionIds + ) + } +} + +// MARK: - Observations + +extension MessageViewModel: ObservableKeyProvider { + public var observedKeys: Set { + var result: Set = [ + .messageUpdated(id: id, threadId: threadId), + .messageDeleted(id: id, threadId: threadId), + .reactionsChanged(messageId: id), + .attachmentCreated(messageId: id), + .profile(authorId) + ] + + if SessionId.Prefix.isCommunityBlinded(threadId) { + result.insert(.anyContactUnblinded) /// Author/Profile info could change + } + + attachments.forEach { attachment in + result.insert(.attachmentUpdated(id: attachment.id, messageId: id)) + result.insert(.attachmentDeleted(id: attachment.id, messageId: id)) + } + + if + let quoteViewModel: QuoteViewModel = quoteViewModel, + let quotedInfo: QuoteViewModel.QuotedInfo = quoteViewModel.quotedInfo + { + result.insert(.profile(quotedInfo.authorId)) + result.insert(.messageUpdated(id: quotedInfo.interactionId, threadId: threadId)) + result.insert(.messageDeleted(id: quotedInfo.interactionId, threadId: threadId)) + + if let attachmentInfo: QuoteViewModel.AttachmentInfo = quotedInfo.attachmentInfo { + result.insert(.attachmentUpdated(id: attachmentInfo.id, messageId: quotedInfo.interactionId)) + result.insert(.attachmentDeleted(id: attachmentInfo.id, messageId: quotedInfo.interactionId)) + } + } + + return result + } + + public static func handlingStrategy(for event: ObservedEvent) -> EventHandlingStrategy? { + return event.handlingStrategy + } +} + +// MARK: - DisappeaingMessagesUpdateControlMessage + +public extension MessageViewModel { + func messageDisappearingConfiguration() -> DisappearingMessagesConfiguration { + return DisappearingMessagesConfiguration + .defaultWith(self.threadId) + .with( + isEnabled: (self.expiresInSeconds ?? 0) > 0, + durationSeconds: self.expiresInSeconds, + type: (Int64(self.expiresStartedAtMs ?? 0) == self.timestampMs ? .disappearAfterSend : .disappearAfterRead ) + ) + } +} + +// MARK: - ReactionInfo + +public extension MessageViewModel { + struct ReactionInfo: Sendable, Equatable, Comparable, Hashable, Differentiable { + public let reaction: Reaction + public let profile: Profile? + + public init(reaction: Reaction, profile: Profile?) { + self.reaction = reaction + self.profile = profile + } + + // MARK: - Differentiable + + public var differenceIdentifier: String { + "\(reaction.emoji)-\(reaction.interactionId)-\(reaction.authorId)" + } + + // MARK: - Comparable + + public static func < (lhs: ReactionInfo, rhs: ReactionInfo) -> Bool { + return (lhs.reaction.sortId < rhs.reaction.sortId) + } + } +} + +// MARK: - MaybeUnresolvedQuotedInfo + +public extension MessageViewModel { + /// If the message contains a `Quote` but we couldn't resolve the original message then we should display the "original message + /// not found" UI (ie. show that there _was_ a quote there, even if we can't resolve it) - this type makes that possible + struct MaybeUnresolvedQuotedInfo: Sendable, Equatable, Hashable { + public let foundQuotedInteractionId: Int64? + public let resolvedQuotedInteraction: Interaction? + + public init( + foundQuotedInteractionId: Int64?, + resolvedQuotedInteraction: Interaction? = nil + ) { + self.foundQuotedInteractionId = foundQuotedInteractionId + self.resolvedQuotedInteraction = resolvedQuotedInteraction + } + } +} + +// MARK: - Convenience + +private extension ObservedEvent { + var handlingStrategy: EventHandlingStrategy? { + switch (key, key.generic) { + case (.anyContactUnblinded, _): return [.databaseQuery, .directCacheUpdate] + case (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery + case (_, .attachmentUpdated), (_, .attachmentDeleted): return .databaseQuery + case (_, .reactionsChanged): return .databaseQuery + case (_, .communityUpdated): return [.directCacheUpdate] + case (_, .contact): return [.directCacheUpdate] + case (_, .profile): return [.directCacheUpdate] + case (_, .typingIndicator): return .directCacheUpdate + default: return nil + } + } +} + +extension MessageViewModel { + public static func bodyTextColor(isOutgoing: Bool) -> ThemeValue { + return (isOutgoing ? + .messageBubble_outgoingText : + .messageBubble_incomingText + ) + } + + fileprivate static func shouldShowDateBreak(between timestamp1: Int64, and timestamp2: Int64) -> Bool { + let diff: Int64 = abs(timestamp2 - timestamp1) + let fiveMinutesInMs: Int64 = (5 * 60 * 1000) + + /// If there is more than 5 minutes between the timestamps then we should show a date break + if diff > fiveMinutesInMs { + return true + } + + /// If we crossed midnight then we want to show a date break regardless of how much time has passed - do this by shifting the + /// timestamps to local time (using the current timezone) and getting a "day number" to check if they are the same dat + let seconds1: Int = Int(timestamp1 / 1000) + let seconds2: Int = Int(timestamp2 / 1000) + let offset: Int = TimeZone.current.secondsFromGMT() + let day1: Int = ((seconds1 + offset) / 86400) + let day2: Int = ((seconds2 + offset) / 86400) + + return (day1 != day2) + } +} + +public extension MessageViewModel { + static func interactionFilterSQL(threadId: String) -> SQL { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("\(interaction[.threadId]) = \(threadId)") + } + + static let interactionOrderSQL: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("\(interaction[.timestampMs].desc)") + }() + + static func quotedInteractionIds( + for originalInteractionIds: Set, + currentUserSessionIds: Set + ) -> SQLRequest> { + let interaction: TypedTableAlias = TypedTableAlias() + let quote: TypedTableAlias = TypedTableAlias() + let quoteInteraction: TypedTableAlias = TypedTableAlias(name: "quoteInteraction") + + return """ + SELECT + \(interaction[.id]) AS \(FetchablePair.Columns.first), + \(quoteInteraction[.id]) AS \(FetchablePair.Columns.second) + FROM \(Interaction.self) + JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) + LEFT JOIN \(quoteInteraction) ON ( + \(quoteInteraction[.timestampMs]) = \(quote[.timestampMs]) AND ( + \(quoteInteraction[.authorId]) = \(quote[.authorId]) OR ( + -- A users outgoing message is stored in some cases using their standard id + -- but the quote will use their blinded id so handle that case + \(quoteInteraction[.authorId]) IN \(currentUserSessionIds) AND + \(quote[.authorId]) IN \(currentUserSessionIds) + ) + ) + ) + WHERE \(interaction[.id]) IN \(originalInteractionIds) + """ + } +} + +extension MessageViewModel { + public func createUserProfileModalInfo( + openGroupServer: String?, + openGroupPublicKey: String?, + onStartThread: (@MainActor () -> Void)?, + onProBadgeTapped: (@MainActor () -> Void)?, + using dependencies: Dependencies + ) async -> UserProfileModal.Info? { + let (info, _) = ProfilePictureView.Info.generateInfoFrom( + size: .hero, + publicKey: authorId, + threadVariant: .contact, /// Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: profile, + using: dependencies + ) + + guard let profileInfo: ProfilePictureView.Info = info else { return nil } + + let sessionId: String? = await { + if let unblindedId: String = authorUnblindedId { + return unblindedId + } + + switch try? SessionId.Prefix(from: authorId) { + case .standard: return authorId + case .none, .versionBlinded07, .group, .unblinded: return nil + case .blinded15, .blinded25: + /// If the sessionId is blinded then check if there is an existing un-blinded thread with the contact and use that, + /// otherwise just use the blinded id + guard let openGroupServer, let openGroupPublicKey else { return nil } + + let maybeLookup: BlindedIdLookup? = try? await dependencies[singleton: .storage].writeAsync { db in + try BlindedIdLookup.fetchOrCreate( + db, + blindedId: authorId, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, + isCheckingForOutbox: false, + using: dependencies + ) + } + + return maybeLookup?.sessionId + } + }() + let blindedId: String? = { + switch try? SessionId.Prefix(from: authorId) { + case .none, .standard, .versionBlinded07, .group, .unblinded: return nil + case .blinded15, .blinded25: return authorId + } + }() + let qrCodeImage: UIImage? = { + guard let sessionId else { return nil } + + return QRCode.generate( + for: sessionId, + hasBackground: false, + iconName: "SessionWhite40" // stringlint:ignore + ) + }() + + return UserProfileModal.Info( + sessionId: sessionId, + blindedId: blindedId, + qrCodeImage: qrCodeImage, + profileInfo: profileInfo, + displayName: authorName(), + contactDisplayName: authorName(ignoreNickname: true), + shouldShowProBadge: profile.proFeatures.contains(.proBadge), + areMessageRequestsEnabled: { + guard threadVariant == .community else { return true } + + return (profile.blocksCommunityMessageRequests != true) + }(), + onStartThread: onStartThread, + onProBadgeTapped: onProBadgeTapped + ) + } +} + +// MARK: - Construction + +private extension MessageViewModel { + static func cellType( + interaction: Interaction, + attachments: [Attachment]? + ) -> MessageViewModel.CellType { + guard !interaction.variant.isDeletedMessage else { return .textOnlyMessage } + guard let attachment: Attachment = attachments?.first else { + switch interaction.variant { + case .infoCall: return .call + case .infoLegacyGroupCreated, .infoLegacyGroupUpdated, .infoLegacyGroupCurrentUserLeft, + .infoGroupCurrentUserLeaving, .infoGroupCurrentUserErrorLeaving, + .infoDisappearingMessagesUpdate, .infoScreenshotNotification, + .infoMediaSavedNotification, .infoMessageRequestAccepted, .infoGroupInfoInvited, + .infoGroupInfoUpdated, .infoGroupMembersUpdated: + return .infoMessage + + case ._legacyStandardIncomingDeleted, .standardIncomingDeleted, .standardOutgoingDeleted, .standardIncomingDeletedLocally, .standardOutgoingDeletedLocally: + return .textOnlyMessage /// Should be handled above + + case .standardOutgoing, .standardIncoming: return .textOnlyMessage + } + } + + /// The only case which currently supports multiple attachments is a 'mediaMessage' (the album view) + guard attachments?.count == 1 else { return .mediaMessage } + + // Pending audio attachments won't have a duration + if + attachment.isAudio && ( + ((attachment.duration ?? 0) > 0) || + ( + attachment.state != .downloaded && + attachment.state != .uploaded + ) + ) + { + return (attachment.variant == .voiceMessage ? .voiceMessage : .audio) + } + + if attachment.isVisualMedia { + return .mediaMessage + } + + return .genericAttachment + } +} + +internal extension Interaction { + struct ContentBuilder { + public let interaction: Interaction? + private let searchText: String? + private let dataCache: ConversationDataCache + + private let threadId: String + private let threadVariant: SessionThread.Variant + private let currentUserSessionIds: Set + public let attachments: [Attachment] + public let hasAttachments: Bool + public let linkPreview: LinkPreview? + public let linkPreviewAttachment: Attachment? + + public var rawBody: String? { interaction?.body } + public let authorDisplayName: String + public let authorDisplayNameNoSuffix: String + public let threadContactDisplayName: String + public var containsOnlyEmoji: Bool { interaction?.body?.containsOnlyEmoji == true } + public var glyphCount: Int { interaction?.body?.glyphCount ?? 0 } + + init( + interaction: Interaction?, + threadId: String, + threadVariant: SessionThread.Variant, + searchText: String? = nil, + dataCache: ConversationDataCache + ) { + self.interaction = interaction + self.searchText = searchText + self.dataCache = dataCache + + let currentUserSessionIds: Set = dataCache.currentUserSessionIds(for: threadId) + let linkPreviewInfo = interaction.map { + ContentBuilder.resolveBestLinkPreview( + for: $0, + dataCache: dataCache + ) + } + self.threadId = threadId + self.threadVariant = threadVariant + self.currentUserSessionIds = currentUserSessionIds + self.attachments = (interaction?.id.map { dataCache.attachments(for: $0) } ?? []) + self.hasAttachments = (interaction?.id.map { dataCache.interactionAttachments(for: $0).isEmpty } == false) + self.linkPreview = linkPreviewInfo?.preview + self.linkPreviewAttachment = linkPreviewInfo?.attachment + + if let authorId: String = interaction?.authorId { + if currentUserSessionIds.contains(authorId) { + self.authorDisplayName = "you".localized() + self.authorDisplayNameNoSuffix = "you".localized() + } + else { + let profile: Profile = ( + dataCache.profile(for: authorId) ?? + Profile.defaultFor(authorId) + ) + + self.authorDisplayName = profile.displayName( + includeSessionIdSuffix: (threadVariant == .community) + ) + self.authorDisplayNameNoSuffix = profile.displayName(includeSessionIdSuffix: false) + } + } + else { + self.authorDisplayName = "" + self.authorDisplayNameNoSuffix = "" + } + + self.threadContactDisplayName = dataCache.contactDisplayName(for: threadId) + } + + func makeBubbleBody() -> String? { + guard let interaction else { return nil } + + if interaction.variant.isInfoMessage { + return makePreviewText() + } + + guard let rawBody: String = interaction.body, !rawBody.isEmpty else { + return nil + } + + /// No need to process mentions if the preview doesn't contain the mention prefix + guard rawBody.contains("@") else { return rawBody } + + let isOutgoing: Bool = (interaction.variant == .standardOutgoing) + + return MentionUtilities.taggingMentions( + in: rawBody, + location: (isOutgoing ? .outgoingMessage : .incomingMessage), + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: dataCache.displayNameRetriever( + for: interaction.threadId, + includeSessionIdSuffixWhenInMessageBody: (threadVariant == .community) + ) + ) + } + + func makeBodyForCopying() -> String? { + guard let interaction else { return nil } + + if interaction.variant.isInfoMessage { + return makePreviewText() + } + + return rawBody + } + + func makePreviewText() -> String? { + guard let interaction else { return nil } + + return Interaction.previewText( + variant: interaction.variant, + body: interaction.body, + threadContactDisplayName: threadContactDisplayName, + authorDisplayName: authorDisplayName, + attachmentDescriptionInfo: attachments.first.map { firstAttachment in + Attachment.DescriptionInfo( + id: firstAttachment.id, + variant: firstAttachment.variant, + contentType: firstAttachment.contentType, + sourceFilename: firstAttachment.sourceFilename + ) + }, + attachmentCount: attachments.count, + isOpenGroupInvitation: (linkPreview?.variant == .openGroupInvitation) + ) + } + + func makeSnippet(dateNow: Date) -> String? { + var result: String = "" + let isSearchResult: Bool = (searchText != nil) + let groupInfo: LibSession.GroupInfo? = dataCache.groupInfo(for: threadId) + let groupKicked: Bool = (groupInfo?.wasKickedFromGroup == true) + let groupDestroyed: Bool = (groupInfo?.wasGroupDestroyed == true) + let groupThreadTypes: Set = [.legacyGroup, .group, .community] + let groupSourceTypes: Set = [.conversationList, .searchResults] + let shouldIncludeAuthorPrefix: Bool = ( + interaction?.variant.isInfoMessage == false && + groupSourceTypes.contains(dataCache.context.source) && + groupThreadTypes.contains(threadVariant) + ) + let shouldHaveStatusIcon: Bool = { + guard !isSearchResult && !groupKicked && !groupDestroyed else { return false } + + /// Only the standard conversation list should have a status icon prefix + switch dataCache.context.source { + case .messageList, .conversationSettings, .searchResults: return false + case .conversationList: return true + } + }() + + /// Add status icon prefixes + if shouldHaveStatusIcon { + if let thread = dataCache.thread(for: threadId) { + let now: TimeInterval = dateNow.timeIntervalSince1970 + let mutedUntil: TimeInterval = (thread.mutedUntilTimestamp ?? 0) + + if now < mutedUntil { + result.append(NotificationsUI.mutePrefix.rawValue) + result.append(" ") + } + else if thread.onlyNotifyForMentions { + result.append(NotificationsUI.mentionPrefix.rawValue) + result.append(" ") /// Need a double space here + } + } + } + + /// If it's a group conversation then it might have a specia status + switch (groupInfo, groupDestroyed, groupKicked, interaction?.variant) { + case (.some(let groupInfo), true, _, _): + result.append( + "groupDeletedMemberDescription" + .put(key: "group_name", value: groupInfo.name) + .localizedDeformatted() + ) + + case (.some(let groupInfo), _, true, _): + result.append( + "groupRemovedYou" + .put(key: "group_name", value: groupInfo.name) + .localizedDeformatted() + ) + + case (.some(let groupInfo), _, _, .infoGroupCurrentUserErrorLeaving): + result.append( + "groupLeaveErrorFailed" + .put(key: "group_name", value: groupInfo.name) + .localizedDeformatted() + ) + + default: + if let previewText: String = makePreviewText() { + let finalPreviewText: String = (!previewText.contains("@") ? + previewText : + MentionUtilities.resolveMentions( + in: previewText, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: dataCache.displayNameRetriever( + for: threadId, + includeSessionIdSuffixWhenInMessageBody: (threadVariant == .community) + ) + ) + ) + + /// The search term highlighting logic will add the author directly (so it doesn't get highlighted) + if !isSearchResult && shouldIncludeAuthorPrefix { + result.append( + "messageSnippetGroup" + .put(key: "author", value: authorDisplayName) + .put(key: "message_snippet", value: finalPreviewText) + .localizedDeformatted() + ) + } + else { + result.append(finalPreviewText) + } + } + } + + guard !result.isEmpty else { return nil } + + /// If we don't have a search term then return the value (deformatted), otherwise highlight the search term tokens + guard let searchText: String = searchText else { + return result.deformatted() + } + + return GlobalSearch.highlightSearchText( + searchText: searchText, + content: result, + authorName: (shouldIncludeAuthorPrefix ? authorDisplayName : nil) + ) + } + + private static func resolveBestLinkPreview( + for interaction: Interaction, + dataCache: ConversationDataCache + ) -> (preview: LinkPreview, attachment: Attachment?)? { + guard let url: String = interaction.linkPreviewUrl else { return nil } + + /// Find all previews for the given url and sort by newest to oldest + let possiblePreviews: Set = dataCache.linkPreviews(for: url) + + guard !possiblePreviews.isEmpty else { return nil } + + /// Try get the link preview for the time the message was sent + let sentTimestamp: TimeInterval = (TimeInterval(interaction.timestampMs) / 1000) + let minTimestamp: TimeInterval = (sentTimestamp - LinkPreview.timstampResolution) + let maxTimestamp: TimeInterval = (sentTimestamp + LinkPreview.timstampResolution) + var bestFallback: LinkPreview? = nil + var bestInWindow: LinkPreview? = nil + + for preview in possiblePreviews { + /// Evaluate the `bestFallback` (used if we can't find a `bestInWindow`) + if let currentFallback: LinkPreview = bestFallback { + /// If the timestamps match then it's likely there is an optimistic link preview in the cache, so if one of the options + /// has an `attachmentId` then we should prioritise that one + switch (preview.attachmentId, currentFallback.attachmentId) { + case (.some, .none): bestFallback = preview + case (.none, .some): break + case (.some, .some), (.none, .none): + /// If this preview is newer than the `currentFallback` then use it instead + if preview.timestamp > currentFallback.timestamp { + bestFallback = preview + } + } + } + + /// Evaluate the `bestInWindow` + if preview.timestamp > minTimestamp && preview.timestamp < maxTimestamp { + if let currentInWindow: LinkPreview = bestInWindow { + /// If the timestamps match then it's likely there is an optimistic link preview in the cache, so if one of the options + /// has an `attachmentId` then we should prioritise that one + switch (preview.attachmentId, currentInWindow.attachmentId) { + case (.some, .none): bestInWindow = preview + case (.none, .some): break + case (.some, .some), (.none, .none): + /// If this preview is newer than the `currentInWindow` then use it instead + if preview.timestamp > currentInWindow.timestamp { + bestInWindow = preview + } + } + } + else { + bestInWindow = preview + } + } + } + + guard let finalPreview: LinkPreview = (bestInWindow ?? bestFallback) else { return nil } + + return (finalPreview, finalPreview.attachmentId.map { dataCache.attachment(for: $0) }) + } + } +} diff --git a/SessionMessagingKit/Shared Models/Position.swift b/SessionMessagingKit/Types/Position.swift similarity index 82% rename from SessionMessagingKit/Shared Models/Position.swift rename to SessionMessagingKit/Types/Position.swift index bfa148d8be..1f8378de61 100644 --- a/SessionMessagingKit/Shared Models/Position.swift +++ b/SessionMessagingKit/Types/Position.swift @@ -3,7 +3,7 @@ import Foundation import GRDB -public enum Position: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { +public enum Position: Int, Sendable, Decodable, Equatable, Hashable, DatabaseValueConvertible { case top case middle case bottom diff --git a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift index d51fb9fd78..d8332bef71 100644 --- a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift @@ -8,15 +8,37 @@ import SessionUtilitiesKit // MARK: - Authentication Types public extension Authentication { + static func standard(sessionId: SessionId, ed25519PublicKey: [UInt8], ed25519SecretKey: [UInt8]) -> AuthenticationMethod { + return Standard( + sessionId: sessionId, + ed25519PublicKey: ed25519PublicKey, + ed25519SecretKey: ed25519SecretKey + ) + } + + static func groupAdmin(groupSessionId: SessionId, ed25519SecretKey: [UInt8]) -> AuthenticationMethod { + return GroupAdmin( + groupSessionId: groupSessionId, + ed25519SecretKey: ed25519SecretKey + ) + } + + static func groupMember(groupSessionId: SessionId, authData: Data) -> AuthenticationMethod { + return GroupMember( + groupSessionId: groupSessionId, + authData: authData + ) + } + /// Used when interacting as the current user - struct standard: AuthenticationMethod { + struct Standard: AuthenticationMethod { public let sessionId: SessionId public let ed25519PublicKey: [UInt8] public let ed25519SecretKey: [UInt8] public var info: Info { .standard(sessionId: sessionId, ed25519PublicKey: ed25519PublicKey) } - public init(sessionId: SessionId, ed25519PublicKey: [UInt8], ed25519SecretKey: [UInt8]) { + fileprivate init(sessionId: SessionId, ed25519PublicKey: [UInt8], ed25519SecretKey: [UInt8]) { self.sessionId = sessionId self.ed25519PublicKey = ed25519PublicKey self.ed25519SecretKey = ed25519SecretKey @@ -32,13 +54,13 @@ public extension Authentication { } /// Used when interacting as a group admin - struct groupAdmin: AuthenticationMethod { + struct GroupAdmin: AuthenticationMethod { public let groupSessionId: SessionId public let ed25519SecretKey: [UInt8] public var info: Info { .groupAdmin(groupSessionId: groupSessionId, ed25519SecretKey: ed25519SecretKey) } - public init(groupSessionId: SessionId, ed25519SecretKey: [UInt8]) { + fileprivate init(groupSessionId: SessionId, ed25519SecretKey: [UInt8]) { self.groupSessionId = groupSessionId self.ed25519SecretKey = ed25519SecretKey } @@ -53,13 +75,13 @@ public extension Authentication { } /// Used when interacting as a group member - struct groupMember: AuthenticationMethod { + struct GroupMember: AuthenticationMethod { public let groupSessionId: SessionId public let authData: Data public var info: Info { .groupMember(groupSessionId: groupSessionId, authData: authData) } - public init(groupSessionId: SessionId, authData: Data) { + fileprivate init(groupSessionId: SessionId, authData: Data) { self.groupSessionId = groupSessionId self.authData = authData } @@ -82,12 +104,7 @@ public extension Authentication { // MARK: - Convenience -fileprivate struct GroupAuthData: Codable, FetchableRecord { - let groupIdentityPrivateKey: Data? - let authData: Data? -} - -public extension Authentication.community { +public extension Authentication.Community { init(info: LibSession.OpenGroupCapabilityInfo, forceBlinded: Bool = false) { self.init( roomToken: info.roomToken, @@ -104,17 +121,17 @@ public extension Authentication { static func with( _ db: ObservingDatabase, server: String, - activeOnly: Bool = true, + activelyPollingOnly: Bool = true, forceBlinded: Bool = false, using dependencies: Dependencies ) throws -> AuthenticationMethod { guard // TODO: [Database Relocation] Store capability info locally in libSession so we don't need the db here let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo - .fetchOne(db, server: server, activeOnly: activeOnly) + .fetchOne(db, server: server, activelyPollingOnly: activelyPollingOnly) else { throw CryptoError.invalidAuthentication } - return Authentication.community(info: info, forceBlinded: forceBlinded) + return Authentication.Community(info: info, forceBlinded: forceBlinded) } static func with( @@ -132,7 +149,7 @@ public extension Authentication { .fetchOne(db, id: threadId) else { throw CryptoError.invalidAuthentication } - return Authentication.community(info: info, forceBlinded: forceBlinded) + return Authentication.Community(info: info, forceBlinded: forceBlinded) case (.contact, .blinded15), (.contact, .blinded25): guard @@ -141,14 +158,13 @@ public extension Authentication { .fetchOne(db, server: lookup.openGroupServer) else { throw CryptoError.invalidAuthentication } - return Authentication.community(info: info, forceBlinded: forceBlinded) + return Authentication.Community(info: info, forceBlinded: forceBlinded) - default: return try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) + default: return try Authentication.with(swarmPublicKey: threadId, using: dependencies) } } static func with( - _ db: ObservingDatabase, swarmPublicKey: String, using dependencies: Dependencies ) throws -> AuthenticationMethod { @@ -167,13 +183,11 @@ public extension Authentication { ) case .some(let sessionId) where sessionId.prefix == .group: - let authData: GroupAuthData? = try? ClosedGroup - .filter(id: swarmPublicKey) - .select(.authData, .groupIdentityPrivateKey) - .asRequest(of: GroupAuthData.self) - .fetchOne(db) + let authData: GroupAuthData = dependencies.mutate(cache: .libSession) { libSession in + libSession.authData(groupSessionId: SessionId(.group, hex: swarmPublicKey)) + } - switch (authData?.groupIdentityPrivateKey, authData?.authData) { + switch (authData.groupIdentityPrivateKey, authData.authData) { case (.some(let privateKey), _): return Authentication.groupAdmin( groupSessionId: sessionId, diff --git a/SessionMessagingKit/Utilities/DeviceSleepManager.swift b/SessionMessagingKit/Utilities/DeviceSleepManager.swift index bb5539072a..1e92f85a77 100644 --- a/SessionMessagingKit/Utilities/DeviceSleepManager.swift +++ b/SessionMessagingKit/Utilities/DeviceSleepManager.swift @@ -45,7 +45,6 @@ public class DeviceSleepManager: NSObject { fileprivate init(using dependencies: Dependencies) { self.dependencies = dependencies - DeviceSleepManager_objc.dependencies = dependencies super.init() @@ -90,18 +89,3 @@ public class DeviceSleepManager: NSObject { dependencies[singleton: .appContext].ensureSleepBlocking(shouldBlock, blockingObjects: blocks) } } - -// MARK: - Objective-C Support - -@objc(DeviceSleepManager_objc) -public class DeviceSleepManager_objc: NSObject { - fileprivate static var dependencies: Dependencies! - - @objc public static func addBlock(blockObject: NSObject?) { - dependencies[singleton: .deviceSleepManager].addBlock(blockObject: blockObject) - } - - @objc public static func removeBlock(blockObject: NSObject?) { - dependencies[singleton: .deviceSleepManager].removeBlock(blockObject: blockObject) - } -} diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 151479c9ce..d02b312716 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -33,33 +33,40 @@ public class DisplayPictureManager { case none case contactRemove - case contactUpdateTo(url: String, key: Data, contactProProof: String?) + case contactUpdateTo(url: String, key: Data) case currentUserRemove - case currentUserUpdateTo(url: String, key: Data, sessionProProof: String?, isReupload: Bool) + case currentUserUpdateTo(url: String, key: Data, type: UpdateType) case groupRemove case groupUploadImage(source: ImageDataManager.DataSource, cropRect: CGRect?) case groupUpdateTo(url: String, key: Data) - static func from(_ profile: VisibleMessage.VMProfile, fallback: Update, using dependencies: Dependencies) -> Update { - return from(profile.profilePictureUrl, key: profile.profileKey, contactProProof: profile.sessionProProof, fallback: fallback, using: dependencies) + static func contactUpdateTo(_ profile: VisibleMessage.VMProfile, fallback: Update) -> Update { + return contactUpdateTo(profile.profilePictureUrl, key: profile.profileKey, fallback: fallback) } - public static func from(_ profile: Profile, fallback: Update, using dependencies: Dependencies) -> Update { - return from(profile.displayPictureUrl, key: profile.displayPictureEncryptionKey, contactProProof: profile.sessionProProof, fallback: fallback, using: dependencies) + public static func contactUpdateTo(_ profile: Profile, fallback: Update) -> Update { + return contactUpdateTo(profile.displayPictureUrl, key: profile.displayPictureEncryptionKey, fallback: fallback) } - static func from(_ url: String?, key: Data?, contactProProof: String?, fallback: Update, using dependencies: Dependencies) -> Update { + static func contactUpdateTo(_ url: String?, key: Data?, fallback: Update) -> Update { guard let url: String = url, let key: Data = key else { return fallback } - return .contactUpdateTo(url: url, key: key, contactProProof: contactProProof) + return .contactUpdateTo(url: url, key: key) } } + public enum UpdateType { + case staticImage + case animatedImage + case reupload + case config + } + public static let maxBytes: UInt = (5 * 1000 * 1000) public static let maxDimension: CGFloat = 600 public static var encryptionKeySize: Int { LibSession.attachmentEncryptionKeySize } @@ -153,20 +160,23 @@ public class DisplayPictureManager { .throttle(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true) .sink( receiveValue: { [dependencies] _ in - let pendingInfo: Set = dependencies.mutate(cache: .displayPicture) { cache in - let result: Set = cache.downloadsToSchedule + let pendingInfo: Set = dependencies.mutate(cache: .displayPicture) { cache in + let result: Set = cache.downloadsToSchedule cache.downloadsToSchedule.removeAll() return result } dependencies[singleton: .storage].writeAsync { db in - pendingInfo.forEach { owner in + pendingInfo.forEach { info in dependencies[singleton: .jobRunner].add( db, job: Job( variant: .displayPictureDownload, shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details(owner: owner) + details: DisplayPictureDownloadJob.Details( + target: info.target, + timestamp: info.timestamp + ) ), canStartJob: true ) @@ -176,11 +186,9 @@ public class DisplayPictureManager { ) } - public func scheduleDownload(for owner: Owner) { - guard owner.canDownloadImage else { return } - + public func scheduleDownload(for target: DisplayPictureDownloadJob.Target, timestamp: TimeInterval? = nil) { dependencies.mutate(cache: .displayPicture) { cache in - cache.downloadsToSchedule.insert(owner) + cache.downloadsToSchedule.insert(TargetWithTimestamp(target: target, timestamp: timestamp)) } scheduleDownloads.send(()) } @@ -421,29 +429,12 @@ public class DisplayPictureManager { } } -// MARK: - DisplayPictureManager.Owner +// MARK: - Convenience public extension DisplayPictureManager { - enum OwnerId: Hashable { - case user(String) - case group(String) - case community(String) - } - - enum Owner: Hashable { - case user(Profile) - case group(ClosedGroup) - case community(OpenGroup) - case file(String) - - var canDownloadImage: Bool { - switch self { - case .user(let profile): return (profile.displayPictureUrl?.isEmpty == false) - case .group(let group): return (group.displayPictureUrl?.isEmpty == false) - case .community(let openGroup): return (openGroup.imageId?.isEmpty == false) - case .file: return false - } - } + struct TargetWithTimestamp: Hashable { + let target: DisplayPictureDownloadJob.Target + let timestamp: TimeInterval? } } @@ -451,7 +442,7 @@ public extension DisplayPictureManager { public extension DisplayPictureManager { class Cache: DisplayPictureCacheType { - public var downloadsToSchedule: Set = [] + public var downloadsToSchedule: Set = [] } } @@ -468,9 +459,9 @@ public extension Cache { /// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way public protocol DisplayPictureImmutableCacheType: ImmutableCacheType { - var downloadsToSchedule: Set { get } + var downloadsToSchedule: Set { get } } public protocol DisplayPictureCacheType: DisplayPictureImmutableCacheType, MutableCacheType { - var downloadsToSchedule: Set { get set } + var downloadsToSchedule: Set { get set } } diff --git a/SessionMessagingKit/Utilities/ExtensionHelper.swift b/SessionMessagingKit/Utilities/ExtensionHelper.swift index dee60e867e..ee9c79072c 100644 --- a/SessionMessagingKit/Utilities/ExtensionHelper.swift +++ b/SessionMessagingKit/Utilities/ExtensionHelper.swift @@ -1032,7 +1032,7 @@ public class ExtensionHelper: ExtensionHelperType { if result.validMessageCount != result.rawMessageCount { failureStandardCount += (result.rawMessageCount - result.validMessageCount) - Log.error(.cat, "Discarding some standard messages due to error: \(MessageReceiverError.failedToProcess)") + Log.error(.cat, "Discarding \((result.rawMessageCount - result.validMessageCount)) standard message(s) as they could not be processed.") } } } @@ -1077,7 +1077,7 @@ public class ExtensionHelper: ExtensionHelperType { @discardableResult public func waitUntilMessagesAreLoaded(timeout: DispatchTimeInterval) async -> Bool { return await withThrowingTaskGroup(of: Bool.self) { [weak self] group in group.addTask { - guard await self?.messagesLoadedStream.currentValue != true else { return true } + guard await self?.messagesLoadedStream.getCurrent() != true else { return true } _ = await self?.messagesLoadedStream.stream.first { $0 == true } return true } diff --git a/Session/Utilities/ImageLoading+Convenience.swift b/SessionMessagingKit/Utilities/ImageLoading+Convenience.swift similarity index 91% rename from Session/Utilities/ImageLoading+Convenience.swift rename to SessionMessagingKit/Utilities/ImageLoading+Convenience.swift index 24d72be03b..63b8a50349 100644 --- a/Session/Utilities/ImageLoading+Convenience.swift +++ b/SessionMessagingKit/Utilities/ImageLoading+Convenience.swift @@ -4,7 +4,6 @@ import UIKit import SwiftUI import UniformTypeIdentifiers import SessionUIKit -import SessionMessagingKit import SessionUtilitiesKit // MARK: - ImageDataManager.DataSource Convenience @@ -51,25 +50,6 @@ public extension ImageDataManager.DataSource { ) } - static func thumbnailFrom( - quoteViewModel: QuoteViewModel, - using dependencies: Dependencies - ) -> ImageDataManager.DataSource? { - guard - let info: QuoteViewModel.AttachmentInfo = quoteViewModel.quotedAttachmentInfo, - let path: String = try? dependencies[singleton: .attachmentManager] - .path(for: info.downloadUrl) - else { return nil } - - return .thumbnailFrom( - utType: info.utType, - path: path, - sourceFilename: info.sourceFilename, - size: .small, - using: dependencies - ) - } - static func thumbnailFrom( utType: UTType, path: String, diff --git a/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift index e84cceeaac..913fddcf4a 100644 --- a/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift @@ -11,11 +11,9 @@ public extension MentionSelectionView.ViewModel { profiles: [Profile], threadVariant: SessionThread.Variant, currentUserSessionIds: Set, - adminModMembers: [GroupMember], + adminModIds: [String], using dependencies: Dependencies ) -> [MentionSelectionView.ViewModel] { - let adminModIds: Set = Set(adminModMembers.map { $0.profileId }) - return profiles.compactMap { profile -> MentionSelectionView.ViewModel? in guard let info: ProfilePictureView.Info = ProfilePictureView.Info.generateInfoFrom( size: MentionSelectionView.profilePictureViewSize, @@ -29,9 +27,10 @@ public extension MentionSelectionView.ViewModel { return MentionSelectionView.ViewModel( profileId: profile.id, - displayName: profile.displayNameForMention( - for: threadVariant, - currentUserSessionIds: currentUserSessionIds + displayName: profile.displayName( + showYouForCurrentUser: true, + currentUserSessionIds: currentUserSessionIds, + includeSessionIdSuffix: (threadVariant == .community) ), profilePictureInfo: info ) @@ -46,50 +45,55 @@ public extension MentionSelectionView.ViewModel { communityInfo: (server: String, roomToken: String)?, using dependencies: Dependencies ) async throws -> [MentionSelectionView.ViewModel] { - let (profiles, adminModMembers): ([Profile], [GroupMember]) = try await dependencies[singleton: .storage].readAsync { db in - let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self) - let capabilities: Set = (threadVariant != .community ? - nil : - try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == communityInfo?.server) - .asRequest(of: Capability.Variant.self) - .fetchSet(db) - ) - .defaulting(to: []) - let targetPrefixes: [SessionId.Prefix] = (capabilities.contains(.blind) ? - [.blinded15, .blinded25] : - [.standard] - ) - let profiles: [Profile] = try mentionsQuery( + let profiles: [Profile] = try await dependencies[singleton: .storage].readAsync { db in + let pattern: FTS5Pattern? = try? GlobalSearch.pattern(db, searchTerm: query, forTable: Profile.self) + let targetPrefixes: [SessionId.Prefix] = { + switch threadVariant { + case .contact, .legacyGroup, .group: return [.standard] + case .community: + let capabilities: Set = ((try? Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == communityInfo?.server) + .asRequest(of: Capability.Variant.self) + .fetchSet(db)) ?? []) + + guard capabilities.contains(.blind) else { + return [.standard] + } + + return [.blinded15, .blinded25] + } + }() + + return try mentionsQuery( threadId: threadId, threadVariant: threadVariant, targetPrefixes: targetPrefixes, currentUserSessionIds: currentUserSessionIds, pattern: pattern ).fetchAll(db) - - /// If it's not a community then no need to determine admin/moderator status - guard threadVariant == .community, let communityId: String = communityInfo.map({ OpenGroup.idFor(roomToken: $0.roomToken, server: $0.server) }) else { - return (profiles, []) - } - - let adminModMembers: [GroupMember] = try dependencies[singleton: .openGroupManager].membersWhere( - db, - currentUserSessionIds: currentUserSessionIds, - .groupIds([communityId]), - .publicKeys(profiles.map { $0.id }), - .roles([.moderator, .admin]) - ) - - return (profiles, adminModMembers) } + let adminModIds: [String] = await { + switch (threadVariant, communityInfo) { + case (.contact, _), (.group, _), (.legacyGroup, _), (.community, .none): return [] + case (.community, .some(let communityInfo)): + guard let server: CommunityManager.Server = await dependencies[singleton: .communityManager].server(communityInfo.server) else { + return [] + } + + return ( + (server.rooms[communityInfo.roomToken]?.admins ?? []) + + (server.rooms[communityInfo.roomToken]?.moderators ?? []) + ) + } + }() + return mentions( profiles: profiles, threadVariant: threadVariant, currentUserSessionIds: currentUserSessionIds, - adminModMembers: adminModMembers, + adminModIds: adminModIds, using: dependencies ) } diff --git a/SessionMessagingKit/Utilities/MessageWrapper.swift b/SessionMessagingKit/Utilities/MessageWrapper.swift index 2a16e12b19..6665b5d06d 100644 --- a/SessionMessagingKit/Utilities/MessageWrapper.swift +++ b/SessionMessagingKit/Utilities/MessageWrapper.swift @@ -5,16 +5,10 @@ import SessionNetworkingKit import SessionUtilitiesKit public enum MessageWrapperError: Error, CustomStringConvertible { - case failedToWrapData - case failedToWrapMessageInEnvelope - case failedToWrapEnvelopeInWebSocketMessage case failedToUnwrapData(Error, Network.SnodeAPI.Namespace) public var description: String { switch self { - case .failedToWrapData: return "Failed to wrap data." - case .failedToWrapMessageInEnvelope: return "Failed to wrap message in envelope." - case .failedToWrapEnvelopeInWebSocketMessage: return "Failed to wrap envelope in web socket message." case .failedToUnwrapData(let error, let namespace): return "Failed to unwrap data from '\(namespace)' namespace due to error: \(error)." } @@ -23,60 +17,6 @@ public enum MessageWrapperError: Error, CustomStringConvertible { public enum MessageWrapper { - /// Wraps the given parameters in an `SNProtoEnvelope` and then a `WebSocketProtoWebSocketMessage` to match the desktop application. - public static func wrap( - type: SNProtoEnvelope.SNProtoEnvelopeType, - timestampMs: UInt64, - senderPublicKey: String = "", // FIXME: Remove once legacy groups are deprecated - content: Data, - wrapInWebSocketMessage: Bool = true - ) throws -> Data { - do { - let envelope: SNProtoEnvelope = try createEnvelope( - type: type, - timestamp: timestampMs, - senderPublicKey: senderPublicKey, - content: content - ) - - // If we don't want to wrap the message within the `WebSocketProtoWebSocketMessage` type - // the just serialise and return here - guard wrapInWebSocketMessage else { return try envelope.serializedData() } - - // Otherwise add the additional wrapper - let webSocketMessage = try createWebSocketMessage(around: envelope) - return try webSocketMessage.serializedData() - } catch let error { - throw error as? MessageWrapperError ?? MessageWrapperError.failedToWrapData - } - } - - private static func createEnvelope(type: SNProtoEnvelope.SNProtoEnvelopeType, timestamp: UInt64, senderPublicKey: String, content: Data) throws -> SNProtoEnvelope { - do { - let builder = SNProtoEnvelope.builder(type: type, timestamp: timestamp) - builder.setSource(senderPublicKey) - builder.setSourceDevice(1) - builder.setContent(content) - return try builder.build() - } catch let error { - Log.error(.messageSender, "Failed to wrap message in envelope: \(error).") - throw MessageWrapperError.failedToWrapMessageInEnvelope - } - } - - private static func createWebSocketMessage(around envelope: SNProtoEnvelope) throws -> WebSocketProtoWebSocketMessage { - do { - let requestBuilder = WebSocketProtoWebSocketRequestMessage.builder(verb: "", path: "", requestID: 0) - requestBuilder.setBody(try envelope.serializedData()) - let messageBuilder = WebSocketProtoWebSocketMessage.builder(type: .request) - messageBuilder.setRequest(try requestBuilder.build()) - return try messageBuilder.build() - } catch let error { - Log.error(.messageSender, "Failed to wrap envelope in web socket message: \(error).") - throw MessageWrapperError.failedToWrapEnvelopeInWebSocketMessage - } - } - /// - Note: `data` shouldn't be base 64 encoded. public static func unwrap( data: Data, diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.h b/SessionMessagingKit/Utilities/OWSAudioPlayer.h deleted file mode 100644 index 759086b5e3..0000000000 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.h +++ /dev/null @@ -1,58 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSAudioPlayer; - -typedef NS_ENUM(NSInteger, AudioPlaybackState) { - AudioPlaybackState_Stopped, - AudioPlaybackState_Playing, - AudioPlaybackState_Paused, -}; - -@protocol OWSAudioPlayerDelegate - -- (AudioPlaybackState)audioPlaybackState; -- (void)setAudioPlaybackState:(AudioPlaybackState)state; -- (void)setAudioProgress:(CGFloat)progress duration:(CGFloat)duration; -- (void)showInvalidAudioFileAlert; -- (void)audioPlayerDidFinishPlaying:(OWSAudioPlayer *)player successfully:(BOOL)flag; - -@end - -#pragma mark - - -typedef NS_ENUM(NSUInteger, OWSAudioBehavior) { - OWSAudioBehavior_Unknown, - OWSAudioBehavior_Playback, - OWSAudioBehavior_AudioMessagePlayback, - OWSAudioBehavior_PlayAndRecord, - OWSAudioBehavior_Call, -}; - -@interface OWSAudioPlayer : NSObject - -@property (nonatomic, weak) id delegate; -// This property can be used to associate instances of the player with view or model objects. -@property (nonatomic, weak) id owner; -@property (nonatomic) BOOL isLooping; -@property (nonatomic) BOOL isPlaying; -@property (nonatomic) float playbackRate; -@property (nonatomic) NSTimeInterval duration; - -- (instancetype)initWithMediaUrl:(NSURL *)mediaUrl audioBehavior:(OWSAudioBehavior)audioBehavior; -- (instancetype)initWithMediaUrl:(NSURL *)mediaUrl audioBehavior:(OWSAudioBehavior)audioBehavior delegate:(nullable id)delegate; -- (void)play; -- (void)setCurrentTime:(NSTimeInterval)currentTime; -- (void)pause; -- (void)stop; -- (void)togglePlayState; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.m b/SessionMessagingKit/Utilities/OWSAudioPlayer.m deleted file mode 100644 index 4e785aeb16..0000000000 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.m +++ /dev/null @@ -1,233 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSAudioPlayer.h" -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -// A no-op delegate implementation to be used when we don't need a delegate. -@interface OWSAudioPlayerDelegateStub : NSObject - -@property (nonatomic) AudioPlaybackState audioPlaybackState; - -@end - -#pragma mark - - -@implementation OWSAudioPlayerDelegateStub - -- (void)setAudioProgress:(CGFloat)progress duration:(CGFloat)duration -{ - // Do nothing -} - -- (void)showInvalidAudioFileAlert -{ - // Do nothing -} - -- (void)audioPlayerDidFinishPlaying:(OWSAudioPlayer *)player successfully:(BOOL)flag -{ - // Do nothing -} - -@end - -#pragma mark - - -@interface OWSAudioPlayer () - -@property (nonatomic, readonly) NSURL *mediaUrl; -@property (nonatomic, nullable) AVAudioPlayer *audioPlayer; -@property (nonatomic, nullable) NSTimer *audioPlayerPoller; -@property (nonatomic, readonly) OWSAudioActivity *audioActivity; - -@end - -#pragma mark - - -@implementation OWSAudioPlayer - -- (instancetype)initWithMediaUrl:(NSURL *)mediaUrl - audioBehavior:(OWSAudioBehavior)audioBehavior -{ - return [self initWithMediaUrl:mediaUrl audioBehavior:audioBehavior delegate:[OWSAudioPlayerDelegateStub new]]; -} - -- (instancetype)initWithMediaUrl:(NSURL *)mediaUrl - audioBehavior:(OWSAudioBehavior)audioBehavior - delegate:(nullable id)delegate -{ - self = [super init]; - if (!self) { - return self; - } - - _mediaUrl = mediaUrl; - _delegate = delegate; - - // stringlint:ignore_start - NSString *audioActivityDescription = [NSString stringWithFormat:@"%@ %@", @"OWSAudioPlayer", self.mediaUrl]; - _audioActivity = [[OWSAudioActivity alloc] initWithAudioDescription:audioActivityDescription behavior:audioBehavior]; - // stringlint:ignore_stop - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidEnterBackground:) - name:NSNotification.sessionDidEnterBackground - object:nil]; - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; - - [DeviceSleepManager_objc removeBlockWithBlockObject:self]; - - [self stop]; -} - -#pragma mark - Dependencies - -- (OWSAudioSession *)audioSession -{ - return SMKEnvironment.shared.audioSession; -} - -#pragma mark - -- (void)applicationDidEnterBackground:(NSNotification *)notification -{ - [self stop]; -} - -#pragma mark - Methods - -- (BOOL)isPlaying -{ - return (self.delegate.audioPlaybackState == AudioPlaybackState_Playing); -} - -- (void)play -{ - // get current audio activity - [self playWithAudioActivity:self.audioActivity]; -} - -- (void)playWithAudioActivity:(OWSAudioActivity *)audioActivity -{ - [self.audioPlayerPoller invalidate]; - - self.delegate.audioPlaybackState = AudioPlaybackState_Playing; - - [[AVAudioSession sharedInstance] setCategory: AVAudioSessionCategoryPlayback error: nil]; - - if (!self.audioPlayer) { - NSError *error; - self.audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:self.mediaUrl error:&error]; - self.audioPlayer.enableRate = YES; - if (error) { - [self stop]; - - if ([error.domain isEqualToString:NSOSStatusErrorDomain] - && (error.code == kAudioFileInvalidFileError || error.code == kAudioFileStreamError_InvalidFile)) { - [self.delegate showInvalidAudioFileAlert]; - } - - return; - } - self.audioPlayer.delegate = self; - if (self.isLooping) { - self.audioPlayer.numberOfLoops = -1; - } - } - - [self.audioPlayer play]; - [self.audioPlayerPoller invalidate]; - - __weak OWSAudioPlayer *weakSelf = self; - self.audioPlayerPoller = [NSTimer weakScheduledTimerWithTimeInterval:.05f repeats:YES onFire:^(NSTimer * _Nonnull timer) { - [weakSelf audioPlayerUpdated:timer]; - }]; - - // Prevent device from sleeping while playing audio. - [DeviceSleepManager_objc addBlockWithBlockObject:self]; -} - -- (void)setCurrentTime:(NSTimeInterval)currentTime -{ - [self.audioPlayer setCurrentTime:currentTime]; -} - -- (float)getPlaybackRate -{ - return self.audioPlayer.rate; -} - -- (NSTimeInterval)duration -{ - return [self.audioPlayer duration]; -} - -- (void)setPlaybackRate:(float)rate -{ - [self.audioPlayer setRate:rate]; -} - -- (void)pause -{ - self.delegate.audioPlaybackState = AudioPlaybackState_Paused; - [self.audioPlayer pause]; - [self.audioPlayerPoller invalidate]; - [self.delegate setAudioProgress:(CGFloat)[self.audioPlayer currentTime] duration:(CGFloat)[self.audioPlayer duration]]; - - [self endAudioActivities]; - [DeviceSleepManager_objc removeBlockWithBlockObject:self]; -} - -- (void)stop -{ - self.delegate.audioPlaybackState = AudioPlaybackState_Stopped; - [self.audioPlayer pause]; - [self.audioPlayerPoller invalidate]; - [self.delegate setAudioProgress:0 duration:0]; - - [self endAudioActivities]; - [DeviceSleepManager_objc removeBlockWithBlockObject:self]; -} - -- (void)endAudioActivities -{ - [self.audioSession endAudioActivity:self.audioActivity]; -} - -- (void)togglePlayState -{ - if (self.isPlaying) { - [self pause]; - } else { - [self playWithAudioActivity:self.audioActivity]; - } -} - -#pragma mark - Events - -- (void)audioPlayerUpdated:(NSTimer *)timer -{ - [self.delegate setAudioProgress:(CGFloat)[self.audioPlayer currentTime] duration:(CGFloat)[self.audioPlayer duration]]; -} - -- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag -{ - [self stop]; - [self.delegate audioPlayerDidFinishPlaying:self successfully:flag]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.swift b/SessionMessagingKit/Utilities/OWSAudioPlayer.swift new file mode 100644 index 0000000000..7af1f50ee8 --- /dev/null +++ b/SessionMessagingKit/Utilities/OWSAudioPlayer.swift @@ -0,0 +1,257 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import AVFoundation +import SessionUtilitiesKit + +// MARK: - AudioPlaybackState + +public enum AudioPlaybackState: Int { + case stopped + case playing + case paused +} + +// MARK: - OWSAudioBehavior + +public enum OWSAudioBehavior: UInt, Equatable { + case unknown + case playback + case audioMessagePlayback + case playAndRecord + case call +} + +// MARK: - OWSAudioPlayerDelegate Protocol + +public protocol OWSAudioPlayerDelegate: AnyObject { + @MainActor var audioPlaybackState: AudioPlaybackState { get set } + + @MainActor func setAudioProgress(_ progress: CGFloat, duration: CGFloat) + @MainActor func showInvalidAudioFileAlert() + @MainActor func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully flag: Bool) +} + +// MARK: - OWSAudioPlayerDelegateStub + +/// A no-op delegate implementation to be used when we don't need a delegate. +class OWSAudioPlayerDelegateStub: OWSAudioPlayerDelegate { + var audioPlaybackState: AudioPlaybackState = .stopped + + func setAudioProgress(_ progress: CGFloat, duration: CGFloat) { + // Do nothing + } + + func showInvalidAudioFileAlert() { + // Do nothing + } + + func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully flag: Bool) { + // Do nothing + } +} + +// MARK: - OWSAudioPlayer + +public class OWSAudioPlayer: NSObject { + + // MARK: - Properties + + private let dependencies: Dependencies + private let mediaUrl: URL + @MainActor private var audioPlayer: AVAudioPlayer? + private var audioPlayerPoller: Timer? + private let audioActivity: AudioActivity + + public weak var delegate: OWSAudioPlayerDelegate? + @MainActor public var isLooping: Bool = false + + @MainActor public var isPlaying: Bool { + return delegate?.audioPlaybackState == .playing + } + + @MainActor public var currentTime: TimeInterval { + get { audioPlayer?.currentTime ?? 0 } + set { audioPlayer?.currentTime = newValue } + } + + @MainActor public var playbackRate: Float { + get { audioPlayer?.rate ?? 1.0 } + set { audioPlayer?.rate = newValue } + } + + @MainActor public var duration: TimeInterval { + return audioPlayer?.duration ?? 0 + } + + // MARK: - Initialization + + public convenience init( + mediaUrl: URL, + audioBehavior: OWSAudioBehavior, + using dependencies: Dependencies + ) { + self.init( + mediaUrl: mediaUrl, + audioBehavior: audioBehavior, + delegate: OWSAudioPlayerDelegateStub(), + using: dependencies + ) + } + + public init( + mediaUrl: URL, + audioBehavior: OWSAudioBehavior, + delegate: OWSAudioPlayerDelegate?, + using dependencies: Dependencies + ) { + self.dependencies = dependencies + self.mediaUrl = mediaUrl + self.delegate = delegate + self.audioActivity = AudioActivity( + audioDescription: "OWSAudioPlayer \(mediaUrl)", // stringlint:ignore + behavior: audioBehavior, + using: dependencies + ) + + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidEnterBackground(_:)), + name: .sessionDidEnterBackground, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + dependencies[singleton: .deviceSleepManager].removeBlock(blockObject: self) + + Task { @MainActor [delegate, audioPlayer, audioPlayerPoller, audioActivity, dependencies] in + delegate?.audioPlaybackState = .stopped + audioPlayer?.pause() + audioPlayerPoller?.invalidate() + delegate?.setAudioProgress(0, duration: 0) + dependencies[singleton: .audioSession].endAudioActivity(audioActivity) + } + } + + // MARK: - Notification Handlers + + @objc private func applicationDidEnterBackground(_ notification: Notification) { + Task { @MainActor in + stop() + } + } + + // MARK: - Public Methods + + @MainActor public func play() { + playWithAudioActivity(audioActivity) + } + + @MainActor public func playWithAudioActivity(_ audioActivity: AudioActivity) { + audioPlayerPoller?.invalidate() + + delegate?.audioPlaybackState = .playing + + try? AVAudioSession.sharedInstance().setCategory(.playback) + + if audioPlayer == nil { + do { + let player = try AVAudioPlayer(contentsOf: mediaUrl as URL) + player.enableRate = true + player.delegate = self + + if isLooping { + player.numberOfLoops = -1 + } + + self.audioPlayer = player + } catch { + stop() + + let nsError = error as NSError + if nsError.domain == NSOSStatusErrorDomain && + (nsError.code == Int(kAudioFileInvalidFileError) || + nsError.code == Int(kAudioFileStreamError_InvalidFile)) { + delegate?.showInvalidAudioFileAlert() + } + + return + } + } + + audioPlayer?.play() + audioPlayerPoller?.invalidate() + + + audioPlayerPoller = Timer.scheduledTimerOnMainThread( + withTimeInterval: 0.05, + repeats: true, + using: dependencies + ) { [weak self] timer in + self?.audioPlayerUpdated(timer) + } + + // Prevent device from sleeping while playing audio + dependencies[singleton: .deviceSleepManager].addBlock(blockObject: self) + } + + @MainActor public func pause() { + delegate?.audioPlaybackState = .paused + audioPlayer?.pause() + audioPlayerPoller?.invalidate() + + if let player = audioPlayer { + delegate?.setAudioProgress(CGFloat(player.currentTime), duration: CGFloat(player.duration)) + } + + endAudioActivities() + dependencies[singleton: .deviceSleepManager].removeBlock(blockObject: self) + } + + @MainActor public func stop() { + delegate?.audioPlaybackState = .stopped + audioPlayer?.pause() + audioPlayerPoller?.invalidate() + delegate?.setAudioProgress(0, duration: 0) + + endAudioActivities() + dependencies[singleton: .deviceSleepManager].removeBlock(blockObject: self) + } + + @MainActor public func togglePlayState() { + if isPlaying { + pause() + } else { + playWithAudioActivity(audioActivity) + } + } + + // MARK: - Private Methods + + private func endAudioActivities() { + dependencies[singleton: .audioSession].endAudioActivity(audioActivity) + } + + @objc private func audioPlayerUpdated(_ timer: Timer) { + Task { @MainActor in + if let player = audioPlayer { + delegate?.setAudioProgress(CGFloat(player.currentTime), duration: CGFloat(player.duration)) + } + } + } +} + +// MARK: - AVAudioPlayerDelegate + +extension OWSAudioPlayer: AVAudioPlayerDelegate { + public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + Task { @MainActor in + stop() + delegate?.audioPlayerDidFinishPlaying(self, successfully: flag) + } + } +} diff --git a/SessionMessagingKit/Utilities/OWSAudioSession.swift b/SessionMessagingKit/Utilities/OWSAudioSession.swift index dcc6c3eea3..4e25efe90f 100644 --- a/SessionMessagingKit/Utilities/OWSAudioSession.swift +++ b/SessionMessagingKit/Utilities/OWSAudioSession.swift @@ -4,33 +4,50 @@ import Foundation import AVFoundation import SessionUtilitiesKit -@objc(OWSAudioActivity) -public class AudioActivity: NSObject { - let audioDescription: String +// MARK: - Singleton + +public extension Singleton { + static let audioSession: SingletonConfig = Dependencies.create( + identifier: "audioSession", + createInstance: { _ in OWSAudioSession() } + ) +} +// MARK: - AudioActivity + +public class AudioActivity: Equatable, CustomStringConvertible { + let dependencies: Dependencies + let audioDescription: String let behavior: OWSAudioBehavior - @objc - public init(audioDescription: String, behavior: OWSAudioBehavior) { + public init(audioDescription: String, behavior: OWSAudioBehavior, using dependencies: Dependencies) { self.audioDescription = audioDescription self.behavior = behavior + self.dependencies = dependencies } deinit { - SessionEnvironment.shared?.audioSession.ensureAudioSessionActivationStateAfterDelay() + dependencies[singleton: .audioSession].ensureAudioSessionActivationStateAfterDelay() } // MARK: - override public var description: String { + public var description: String { return "<[AudioActivity] audioDescription: \"\(audioDescription)\">" // stringlint:ignore } + + public static func ==(lhs: AudioActivity, rhs: AudioActivity) -> Bool { + return ( + lhs.audioDescription == rhs.audioDescription && + lhs.behavior == rhs.behavior + ) + } } -@objc -public class OWSAudioSession: NSObject { +// MARK: - OWSAudioSession + +public class OWSAudioSession { - @objc public func setup() { NotificationCenter.default.addObserver(self, selector: #selector(proximitySensorStateDidChange(notification:)), name: UIDevice.proximityStateDidChangeNotification, object: nil) } @@ -48,7 +65,6 @@ public class OWSAudioSession: NSObject { return Set(self.currentActivities.compactMap { $0.value?.behavior }) } - @objc public func startAudioActivity(_ audioActivity: AudioActivity) -> Bool { Log.debug("[AudioActivity] startAudioActivity called with \(audioActivity)") @@ -66,7 +82,6 @@ public class OWSAudioSession: NSObject { } } - @objc public func endAudioActivity(_ audioActivity: AudioActivity) { Log.debug("[AudioActivity] endAudioActivity called with: \(audioActivity)") diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index 614db65dbb..7f3c49cbc7 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -42,12 +42,18 @@ public extension ObservableKey { ObservableKey("contact-\(id)", .contact) } - static let anyContactBlockedStatusChanged: ObservableKey = "anyContactBlockedStatusChanged" + static let anyContactBlockedStatusChanged: ObservableKey = { + ObservableKey("anyContactBlockedStatusChanged", .anyContactBlockedStatusChanged) + }() + static let anyContactUnblinded: ObservableKey = ObservableKey("anyContactUnblinded", .anyContactUnblinded) // MARK: - Conversations - static let conversationCreated: ObservableKey = "conversationCreated" - static let anyConversationPinnedPriorityChanged: ObservableKey = "anyConversationPinnedPriorityChanged" + static let conversationCreated: ObservableKey = ObservableKey("conversationCreated", .conversationCreated) + static let anyConversationPinnedPriorityChanged: ObservableKey = { + ObservableKey("anyConversationPinnedPriorityChanged", .anyConversationPinnedPriorityChanged) + }() + static func conversationUpdated(_ id: String) -> ObservableKey { ObservableKey("conversationUpdated-\(id)", .conversationUpdated) } @@ -79,12 +85,37 @@ public extension ObservableKey { ObservableKey("attachmentDeleted-\(id)-\(messageId.map { "\($0)" } ?? "NULL")", .attachmentDeleted) } + static let recentReactionsUpdated: ObservableKey = "recentReactionsUpdated" + static func reactionsChanged(messageId: Int64) -> ObservableKey { + ObservableKey("reactionsChanged-\(messageId)", .reactionsChanged) + } + // MARK: - Message Requests static let messageRequestAccepted: ObservableKey = "messageRequestAccepted" static let messageRequestDeleted: ObservableKey = "messageRequestDeleted" static let messageRequestMessageRead: ObservableKey = "messageRequestMessageRead" static let messageRequestUnreadMessageReceived: ObservableKey = "messageRequestUnreadMessageReceived" + + // MARK: - Groups + + static func groupInfo(groupId: String) -> ObservableKey { + ObservableKey("groupInfo-\(groupId)", .groupInfo) + } + + static func groupMemberCreated(threadId: String) -> ObservableKey { + ObservableKey("groupMemberCreated-\(threadId)", .groupMemberCreated) + } + static func groupMemberUpdated(profileId: String, threadId: String) -> ObservableKey { + ObservableKey("groupMemberUpdated-\(threadId)-\(profileId)", .groupMemberUpdated) + } + + static func anyGroupMemberDeleted(threadId: String) -> ObservableKey { + ObservableKey("anyGroupMemberDeleted-\(threadId)", .anyGroupMemberDeleted) + } + static func groupMemberDeleted(profileId: String, threadId: String) -> ObservableKey { + ObservableKey("groupMemberDeleted-\(threadId)-\(profileId)", .groupMemberDeleted) + } } public extension GenericObservableKey { @@ -96,7 +127,11 @@ public extension GenericObservableKey { static let typingIndicator: GenericObservableKey = "typingIndicator" static let profile: GenericObservableKey = "profile" static let contact: GenericObservableKey = "contact" + static let anyContactBlockedStatusChanged: GenericObservableKey = "anyContactBlockedStatusChanged" + static let anyContactUnblinded: GenericObservableKey = "anyContactUnblinded" + static let conversationCreated: GenericObservableKey = "conversationCreated" + static let anyConversationPinnedPriorityChanged: GenericObservableKey = "anyConversationPinnedPriorityChanged" static let conversationUpdated: GenericObservableKey = "conversationUpdated" static let conversationDeleted: GenericObservableKey = "conversationDeleted" static let messageCreated: GenericObservableKey = "messageCreated" @@ -105,6 +140,13 @@ public extension GenericObservableKey { static let attachmentCreated: GenericObservableKey = "attachmentCreated" static let attachmentUpdated: GenericObservableKey = "attachmentUpdated" static let attachmentDeleted: GenericObservableKey = "attachmentDeleted" + static let reactionsChanged: GenericObservableKey = "reactionsChanged" + + static let groupInfo: GenericObservableKey = "groupInfo" + static let groupMemberCreated: GenericObservableKey = "groupMemberCreated" + static let groupMemberUpdated: GenericObservableKey = "groupMemberUpdated" + static let anyGroupMemberDeleted: GenericObservableKey = "anyGroupMemberDeleted" + static let groupMemberDeleted: GenericObservableKey = "groupMemberDeleted" } // MARK: - Event Payloads - General @@ -127,12 +169,18 @@ public struct LoadPageEvent: Hashable { public enum Target: Hashable { case initial + case initialPageAround(AnyHashable) case previousPage(Int) case nextPage(Int) + case jumpTo(AnyHashable, Int) } public static var initial: LoadPageEvent { LoadPageEvent(target: .initial) } + public static func initialPageAround(id: ID) -> LoadPageEvent { + LoadPageEvent(target: .initialPageAround(id)) + } + public static func previousPage(firstIndex: Int) -> LoadPageEvent { LoadPageEvent(target: .previousPage(firstIndex)) } @@ -140,6 +188,10 @@ public struct LoadPageEvent: Hashable { public static func nextPage(lastIndex: Int) -> LoadPageEvent { LoadPageEvent(target: .nextPage(lastIndex)) } + + public static func jumpTo(id: ID, padding: Int) -> LoadPageEvent { + LoadPageEvent(target: .jumpTo(id, padding)) + } } public struct UpdateSelectionEvent: Hashable { @@ -162,15 +214,6 @@ public struct TypingIndicatorEvent: Hashable { } } -public extension ObservingDatabase { - func addTypingIndicatorEvent(threadId: String, change: TypingIndicatorEvent.Change) { - self.addEvent(ObservedEvent( - key: .typingIndicator(threadId), - value: TypingIndicatorEvent(threadId: threadId, change: change) - )) - } -} - // MARK: - Event Payloads - Contacts public struct ProfileEvent: Hashable { @@ -181,6 +224,12 @@ public struct ProfileEvent: Hashable { case name(String) case nickname(String?) case displayPictureUrl(String?) + case proStatus( + isPro: Bool, + profileFeatures: SessionPro.ProfileFeatures, + expiryUnixTimestampMs: UInt64, + genIndexHashHex: String? + ) } } @@ -199,6 +248,7 @@ public struct ContactEvent: Hashable { case isApproved(Bool) case isBlocked(Bool) case didApproveMe(Bool) + case unblinded(blindedId: String, unblindedId: String) } } @@ -210,6 +260,7 @@ public extension ObservingDatabase { /// window includes the record, so we need to emit generic "any" events for these cases switch change { case .isBlocked: addEvent(ObservedEvent(key: .anyContactBlockedStatusChanged, value: event)) + case .unblinded: addEvent(ObservedEvent(key: .anyContactUnblinded, value: event)) default: break } @@ -221,6 +272,7 @@ public extension ObservingDatabase { public struct ConversationEvent: Hashable { public let id: String + public let variant: SessionThread.Variant public let change: Change? public enum Change: Hashable { @@ -232,13 +284,20 @@ public struct ConversationEvent: Hashable { case mutedUntilTimestamp(TimeInterval?) case onlyNotifyForMentions(Bool) case markedAsUnread(Bool) - case unreadCountChanged + case isDraft(Bool) + + case messageDraft(String?) + case disappearingMessageConfiguration(DisappearingMessagesConfiguration?) + case unreadCount + + case markedAsDestroyed + case markedAsKicked } } public extension ObservingDatabase { - func addConversationEvent(id: String, type: CRUDEvent) { - let event: ConversationEvent = ConversationEvent(id: id, change: type.change) + func addConversationEvent(id: String, variant: SessionThread.Variant, type: CRUDEvent) { + let event: ConversationEvent = ConversationEvent(id: id, variant: variant, change: type.change) switch type { case .created: addEvent(ObservedEvent(key: .conversationCreated, value: event)) @@ -263,6 +322,8 @@ public struct MessageEvent: Hashable { case wasRead(Bool) case state(Interaction.State) case recipientReadTimestampMs(Int64) + case markedAsDeleted + case expirationTimerStarted(TimeInterval, Double) } } @@ -305,3 +366,48 @@ public extension ObservingDatabase { } } } + +public struct ReactionEvent: Hashable { + public let id: Int64 + public let messageId: Int64 + public let change: Change + + public enum Change: Hashable { + case added(String) + case removed(String) + } +} + +public extension ObservingDatabase { + func addReactionEvent(id: Int64, messageId: Int64, change: ReactionEvent.Change) { + let event: ReactionEvent = ReactionEvent(id: id, messageId: messageId, change: change) + + addEvent(ObservedEvent(key: .reactionsChanged(messageId: messageId), value: event)) + } +} + +public struct GroupMemberEvent: Hashable { + public let profileId: String + public let threadId: String + public let change: Change? + + public enum Change: Hashable { + case role(role: GroupMember.Role, status: GroupMember.RoleStatus) + } +} + +public extension ObservingDatabase { + func addGroupMemberEvent(profileId: String, threadId: String, type: CRUDEvent) { + let event: GroupMemberEvent = GroupMemberEvent(profileId: profileId, threadId: threadId, change: type.change) + + switch type { + case .created: addEvent(ObservedEvent(key: .groupMemberCreated(threadId: threadId), value: event)) + case .updated: addEvent(ObservedEvent(key: .groupMemberUpdated(profileId: profileId, threadId: threadId), value: event)) + case .deleted: + /// When a group member is deleted we need to emit both a profile+thread-specific event and a thread-specific event + /// as the message list screen will only observe the thread-specific one to update user count metadata + addEvent(ObservedEvent(key: .anyGroupMemberDeleted(threadId: threadId), value: event)) + addEvent(ObservedEvent(key: .groupMemberDeleted(profileId: profileId, threadId: threadId), value: event)) + } + } +} diff --git a/SessionMessagingKit/Utilities/ObservableKeyEvent+Utilities.swift b/SessionMessagingKit/Utilities/ObservableKeyEvent+Utilities.swift index 9099cfe529..ffc13ba689 100644 --- a/SessionMessagingKit/Utilities/ObservableKeyEvent+Utilities.swift +++ b/SessionMessagingKit/Utilities/ObservableKeyEvent+Utilities.swift @@ -7,6 +7,11 @@ public extension LoadPageEvent { func target(with current: PagedData.LoadResult) -> PagedData.Target? { switch target { case .initial: return .initial + case .initialPageAround(let erasedId): + guard let id: ID = erasedId as? ID else { return .initial } + + return .initialPageAround(id: id) + case .nextPage(let lastIndex): guard lastIndex == current.info.lastIndex else { return nil } @@ -16,6 +21,14 @@ public extension LoadPageEvent { guard firstIndex == current.info.firstPageOffset else { return nil } return .pageBefore + + case .jumpTo(let erasedId, let padding): + guard + let id: ID = erasedId as? ID, + !current.info.currentIds.contains(id) + else { return nil } + + return .jumpTo(id: id, padding: padding) } } } diff --git a/SessionMessagingKit/Utilities/Preferences+Sound.swift b/SessionMessagingKit/Utilities/Preferences+Sound.swift index 566d82414d..5a4dcd3e38 100644 --- a/SessionMessagingKit/Utilities/Preferences+Sound.swift +++ b/SessionMessagingKit/Utilities/Preferences+Sound.swift @@ -200,10 +200,18 @@ public extension Preferences { // MARK: - AudioPlayer - public static func audioPlayer(for sound: Sound, behavior: OWSAudioBehavior) -> OWSAudioPlayer? { + @MainActor public static func audioPlayer( + for sound: Sound, + behavior: OWSAudioBehavior, + using dependencies: Dependencies + ) -> OWSAudioPlayer? { guard let soundUrl: URL = sound.soundUrl(quiet: false) else { return nil } - let player = OWSAudioPlayer(mediaUrl: soundUrl, audioBehavior: behavior) + let player: OWSAudioPlayer = OWSAudioPlayer( + mediaUrl: soundUrl, + audioBehavior: behavior, + using: dependencies + ) // These two cases should loop if sound == .callConnecting || sound == .callOutboundRinging { diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 792b6ddc65..fb0ed33abe 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -80,9 +80,6 @@ public extension Setting.BoolKey { /// There is no native api to get local network permission, so we need to modify the state and store in database to update UI accordingly. /// Remove this in the future if Apple provides native api static let lastSeenHasLocalNetworkPermission: Setting.BoolKey = "lastSeenHasLocalNetworkPermission" - - /// Controls whether sending pro badges bitmask - static let isProBadgeEnabled: Setting.BoolKey = "isProBagesEnabled" } // stringlint:ignore_contents @@ -107,6 +104,9 @@ public extension KeyValueStore.IntKey { /// This is the number of times the app has successfully become active, it's not actually used for anything but allows us to make /// a database change on launch so the database will output an error if it fails to write static let activeCounter: KeyValueStore.IntKey = "activeCounter" + + /// This is the ticket number for the pro revocations request (it's used to to track the version of pro revocations the current device has) + static let proRevocationsTicket: KeyValueStore.IntKey = "proRevocationsTicket" } public enum Preferences { diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index a762fe8b86..ea3aea4e25 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -3,6 +3,7 @@ import Foundation import Combine import GRDB +import SessionNetworkingKit import SessionUtilitiesKit // MARK: - Log.Category @@ -14,10 +15,10 @@ private extension Log.Category { // MARK: - Profile Updates public extension Profile { - enum DisplayNameUpdate { + enum TargetUserUpdate { case none - case contactUpdate(String?) - case currentUserUpdate(String?) + case contactUpdate(T) + case currentUserUpdate(T) } indirect enum CacheSource { @@ -77,6 +78,43 @@ public extension Profile { } } + struct ProState: Equatable { + public static let nonPro: ProState = ProState( + profileFeatures: .none, + expiryUnixTimestampMs: 0, + genIndexHashHex: nil + ) + + let profileFeatures: SessionPro.ProfileFeatures + let expiryUnixTimestampMs: UInt64 + let genIndexHashHex: String? + + var isPro: Bool { + expiryUnixTimestampMs > 0 && + genIndexHashHex != nil + } + + init( + profileFeatures: SessionPro.ProfileFeatures, + expiryUnixTimestampMs: UInt64, + genIndexHashHex: String? + ) { + self.profileFeatures = profileFeatures + self.expiryUnixTimestampMs = expiryUnixTimestampMs + self.genIndexHashHex = genIndexHashHex + } + + init?(_ decodedPro: SessionPro.DecodedProForMessage?) { + guard let decodedPro: SessionPro.DecodedProForMessage = decodedPro else { + return nil + } + + self.profileFeatures = decodedPro.profileFeatures + self.expiryUnixTimestampMs = decodedPro.proProof.expiryUnixTimestampMs + self.genIndexHashHex = decodedPro.proProof.genIndexHash.toHexString() + } + } + static func isTooLong(profileName: String) -> Bool { /// String.utf8CString will include the null terminator (Int8)0 as the end of string buffer. /// When the string is exactly 100 bytes String.utf8CString.count will be 101. @@ -88,8 +126,9 @@ public extension Profile { } static func updateLocal( - displayNameUpdate: DisplayNameUpdate = .none, + displayNameUpdate: TargetUserUpdate = .none, displayPictureUpdate: DisplayPictureManager.Update = .none, + proFeatures: SessionPro.ProfileFeatures? = nil, using dependencies: Dependencies ) async throws { /// Perform any non-database related changes for the update @@ -124,6 +163,22 @@ public extension Profile { do { let userSessionId: SessionId = dependencies[cache: .general].sessionId let profileUpdateTimestamp: TimeInterval = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + let proUpdate: TargetUserUpdate = { + guard + let targetFeatures: SessionPro.ProfileFeatures = proFeatures, + let proof: Network.SessionPro.ProProof = dependencies[singleton: .sessionProManager] + .currentUserCurrentProState + .proof + else { return .none } + + return .currentUserUpdate( + ProState( + profileFeatures: targetFeatures, + expiryUnixTimestampMs: proof.expiryUnixTimestampMs, + genIndexHashHex: proof.genIndexHash.toHexString() + ) + ) + }() try await dependencies[singleton: .storage].writeAsync { db in try Profile.updateIfNeeded( @@ -131,7 +186,9 @@ public extension Profile { publicKey: userSessionId.hexString, displayNameUpdate: displayNameUpdate, displayPictureUpdate: displayPictureUpdate, + proUpdate: proUpdate, profileUpdateTimestamp: profileUpdateTimestamp, + currentUserSessionIds: [userSessionId.hexString], using: dependencies ) } @@ -142,23 +199,30 @@ public extension Profile { static func updateIfNeeded( _ db: ObservingDatabase, publicKey: String, - displayNameUpdate: DisplayNameUpdate = .none, + displayNameUpdate: TargetUserUpdate = .none, displayPictureUpdate: DisplayPictureManager.Update = .none, nicknameUpdate: Update = .useExisting, blocksCommunityMessageRequests: Update = .useExisting, + proUpdate: TargetUserUpdate = .none, profileUpdateTimestamp: TimeInterval?, cacheSource: CacheSource = .libSession(fallback: .database), suppressUserProfileConfigUpdate: Bool = false, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let isCurrentUser = (publicKey == userSessionId.hexString) + let isCurrentUser = currentUserSessionIds.contains(publicKey) let profile: Profile = cacheSource.resolve(db, publicKey: publicKey, using: dependencies) + let proState: ProState = ProState( + profileFeatures: profile.proFeatures, + expiryUnixTimestampMs: profile.proExpiryUnixTimestampMs, + genIndexHashHex: profile.proGenIndexHashHex + ) let updateStatus: UpdateStatus = UpdateStatus( updateTimestamp: profileUpdateTimestamp, cachedProfile: profile ) var updatedProfile: Profile = profile + var updatedProState: ProState = proState var profileChanges: [ConfigColumnAssignment] = [] /// We should only update profile info controled by other users if `updateStatus` is `shouldUpdate` @@ -205,8 +269,8 @@ public extension Profile { db.addProfileEvent(id: publicKey, change: .displayPictureUrl(nil)) } - case (.contactUpdateTo(let url, let key, let proProof), false), - (.currentUserUpdateTo(let url, let key, let proProof, _), true): + case (.contactUpdateTo(let url, let key), false), + (.currentUserUpdateTo(let url, let key, _), true): /// If we have already downloaded the image then we can just directly update the stored profile data (it normally /// wouldn't be updated until after the download completes) let fileExists: Bool = ((try? dependencies[singleton: .displayPictureManager] @@ -239,12 +303,74 @@ public extension Profile { profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: key)) } } - - // TODO: Handle Pro Proof update /// Don't want profiles in messages to modify the current users profile info so ignore those cases default: break } + + /// Session Pro Information (if it's not the current user) + switch (proUpdate, isCurrentUser) { + case (.none, _): break + case (.contactUpdate(let value), false), (.currentUserUpdate(let value), true): + updatedProState = (value ?? .nonPro) + + /// Don't want profiles in messages to modify the current users profile info so ignore those cases + default: break + } + + /// Update the pro state based on whether the updated display picture is animated or not + if isCurrentUser, case .currentUserUpdateTo(_, _, let type) = displayPictureUpdate { + switch type { + case .reupload, .config: break /// Don't modify the current state + case .staticImage: + updatedProState = ProState( + profileFeatures: updatedProState.profileFeatures.removing(.animatedAvatar), + expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, + genIndexHashHex: updatedProState.genIndexHashHex + ) + + case .animatedImage: + updatedProState = ProState( + profileFeatures: updatedProState.profileFeatures.inserting(.animatedAvatar), + expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, + genIndexHashHex: updatedProState.genIndexHashHex + ) + } + } + + /// If the pro state no longer matches then we need to emit an event + if updatedProState != proState { + if updatedProState.profileFeatures != proState.profileFeatures { + updatedProfile = updatedProfile.with(proFeatures: .set(to: updatedProState.profileFeatures)) + profileChanges.append(Profile.Columns.proFeatures.set(to: updatedProState.profileFeatures.rawValue)) + } + + if updatedProState.expiryUnixTimestampMs != proState.expiryUnixTimestampMs { + updatedProfile = updatedProfile.with( + proExpiryUnixTimestampMs: .set(to: updatedProState.expiryUnixTimestampMs) + ) + profileChanges.append(Profile.Columns.proExpiryUnixTimestampMs + .set(to: updatedProState.expiryUnixTimestampMs)) + } + + if updatedProState.genIndexHashHex != proState.genIndexHashHex { + updatedProfile = updatedProfile.with( + proGenIndexHashHex: .set(to: updatedProState.genIndexHashHex) + ) + profileChanges.append(Profile.Columns.proGenIndexHashHex + .set(to: updatedProState.genIndexHashHex)) + } + + db.addProfileEvent( + id: publicKey, + change: .proStatus( + isPro: updatedProState.isPro, + profileFeatures: updatedProState.profileFeatures, + expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, + genIndexHashHex: updatedProState.genIndexHashHex + ) + ) + } } /// Nickname - this is controlled by the current user so should always be used @@ -285,7 +411,11 @@ public extension Profile { let newDisplayName: String = effectiveDisplayName, newDisplayName != (isCurrentUser ? profile.name : (profile.nickname ?? profile.name)) { - db.addConversationEvent(id: publicKey, type: .updated(.displayName(newDisplayName))) + db.addConversationEvent( + id: publicKey, + variant: .contact, + type: .updated(.displayName(newDisplayName)) + ) } /// If the profile was either updated or matches the current (latest) state then we should check if we have the display picture on @@ -295,7 +425,7 @@ public extension Profile { var targetKey: Data? = profile.displayPictureEncryptionKey switch displayPictureUpdate { - case .contactUpdateTo(let url, let key, _), .currentUserUpdateTo(let url, let key, _, _): + case .contactUpdateTo(let url, let key), .currentUserUpdateTo(let url, let key, _): targetUrl = url targetKey = key @@ -337,6 +467,9 @@ public extension Profile { case .nickname(let nickname): return (nickname != nil ? "nickname updated" : "nickname removed") // stringlint:ignore + + case .proStatus(let isPro, let features, _, _): + return "pro state - \(isPro ? "enabled: \(features)" : "disabled")" // stringlint:ignore } } .joined(separator: ", ") @@ -356,21 +489,31 @@ public extension Profile { /// We don't automatically update the current users profile data when changed in the database so need to manually /// trigger the update if !suppressUserProfileConfigUpdate, isCurrentUser { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userProfile, sessionId: userSessionId) { _ in try cache.updateProfile( displayName: .set(to: updatedProfile.name), displayPictureUrl: .set(to: updatedProfile.displayPictureUrl), displayPictureEncryptionKey: .set(to: updatedProfile.displayPictureEncryptionKey), + proProfileFeatures: .set(to: updatedProState.profileFeatures), isReuploadProfilePicture: { switch displayPictureUpdate { - case .currentUserUpdateTo(_, _, _, let isReupload): return isReupload + case .currentUserUpdateTo(_, _, let type): return (type == .reupload) default: return false } }() ) } } + + /// After the commit completes we need to update the SessionProManager to ensure it has the latest state + db.afterCommit { + Task.detached(priority: .userInitiated) { + await dependencies[singleton: .sessionProManager].updateWithLatestFromUserConfig() + } + } } Log.custom(isCurrentUser ? .info : .debug, [.profile], "Successfully updated \(isCurrentUser ? "user profile" : "profile for \(publicKey)")) (\(changeString)).") diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index 4f6a74d4aa..159293c4b7 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -51,7 +51,7 @@ public extension ProfilePictureView.Info { let explicitPathFileExists: Bool = (explicitPath.map { dependencies[singleton: .fileManager].fileExists(atPath: $0) } ?? false) switch (explicitPath, explicitPathFileExists, publicKey.isEmpty, threadVariant) { - // TODO: Deal with this case later when implement group related Pro features + // TODO: [PRO] Deal with this case later when implement group related Pro features case (.some(let path), true, _, .legacyGroup), (.some(let path), true, _, .group): fallthrough case (.some(let path), true, _, .community): /// If we are given an explicit `displayPictureUrl` then only use that @@ -118,8 +118,7 @@ public extension ProfilePictureView.Info { else { return .placeholderIcon( seed: (profile?.id ?? publicKey), - text: (profile?.displayName(for: threadVariant)) - .defaulting(to: publicKey), + text: (profile?.displayName() ?? publicKey), size: (additionalProfile != nil ? size.multiImageSize : size.viewSize @@ -146,7 +145,7 @@ public extension ProfilePictureView.Info { else { return .placeholderIcon( seed: other.id, - text: other.displayName(for: threadVariant), + text: other.displayName(), size: size.multiImageSize ) } @@ -186,8 +185,7 @@ public extension ProfilePictureView.Info { else { return .placeholderIcon( seed: publicKey, - text: (profile?.displayName(for: threadVariant)) - .defaulting(to: publicKey), + text: (profile?.displayName() ?? publicKey), size: size.viewSize ) } @@ -207,7 +205,8 @@ public extension ProfilePictureView.Info { } public extension ProfilePictureView { - // TODO: [PRO] Need to properly wire this up (it won't observe the changes, the parent screen will be responsible for updating the profile data and reloading the UI if the pro state changes) + /// This will made a decision based on the current state of the profile data, it's up to the parent screen to observer changes and trigger + /// a UI refresh to update this state static func canProfileAnimate(_ profile: Profile?, using dependencies: Dependencies) -> Bool { guard dependencies[feature: .sessionProEnabled] else { return true } @@ -215,14 +214,12 @@ public extension ProfilePictureView { case .none: return false case .some(let profile) where profile.id == dependencies[cache: .general].sessionId.hexString: - if case .active = dependencies[singleton: .sessionProState].sessionProStateSubject.value { - return true - } else { - return false - } + return dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro case .some(let profile): - return dependencies.mutate(cache: .libSession, { $0.validateProProof(for: profile) }) + return dependencies[singleton: .sessionProManager] + .profileFeatures(for: profile) + .contains(.animatedAvatar) } } } diff --git a/SessionMessagingKit/Utilities/SessionEnvironment.swift b/SessionMessagingKit/Utilities/SessionEnvironment.swift index 0ed21dd9d4..d29fd4f44b 100644 --- a/SessionMessagingKit/Utilities/SessionEnvironment.swift +++ b/SessionMessagingKit/Utilities/SessionEnvironment.swift @@ -6,7 +6,6 @@ import SessionUtilitiesKit public class SessionEnvironment { public static var shared: SessionEnvironment? - public let audioSession: OWSAudioSession public let proximityMonitoringManager: OWSProximityMonitoringManager public let windowManager: OWSWindowManager public var isRequestingPermission: Bool @@ -14,11 +13,9 @@ public class SessionEnvironment { // MARK: - Initialization public init( - audioSession: OWSAudioSession, proximityMonitoringManager: OWSProximityMonitoringManager, windowManager: OWSWindowManager ) { - self.audioSession = audioSession self.proximityMonitoringManager = proximityMonitoringManager self.windowManager = windowManager self.isRequestingPermission = false @@ -41,6 +38,5 @@ public class SessionEnvironment { public class SMKEnvironment: NSObject { @objc public static let shared: SMKEnvironment = SMKEnvironment() - @objc public var audioSession: OWSAudioSession? { SessionEnvironment.shared?.audioSession } @objc public var windowManager: OWSWindowManager? { SessionEnvironment.shared?.windowManager } } diff --git a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift index 7de5f146de..f0abc5255b 100644 --- a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift +++ b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift @@ -66,20 +66,25 @@ class CryptoSMKSpec: QuickSpec { } } - // MARK: -- when encrypting with the session protocol - context("when encrypting with the session protocol") { + // MARK: -- when encoding messages + context("when encoding messages") { + @TestState var result: Data? + // MARK: ---- can encrypt correctly it("can encrypt correctly") { - let result: Data? = try? crypto.tryGenerate( - .ciphertextWithSessionProtocol( + result = try? crypto.tryGenerate( + .encodedMessage( plaintext: "TestMessage".data(using: .utf8)!, - destination: .contact(publicKey: "05\(TestConstants.publicKey)") + proMessageFeatures: .none, + proProfileFeatures: .none, + destination: .contact(publicKey: "05\(TestConstants.publicKey)"), + sentTimestampMs: 1234567890 ) ) // Note: A Nonce is used for this so we can't compare the exact value when not mocked expect(result).toNot(beNil()) - expect(result?.count).to(equal(155)) + expect(result?.count).to(equal(397)) } // MARK: ---- throws an error if there is no ed25519 keyPair @@ -87,33 +92,51 @@ class CryptoSMKSpec: QuickSpec { mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { - try crypto.tryGenerate( - .ciphertextWithSessionProtocol( + result = try crypto.tryGenerate( + .encodedMessage( plaintext: "TestMessage".data(using: .utf8)!, - destination: .contact(publicKey: "05\(TestConstants.publicKey)") + proMessageFeatures: .none, + proProfileFeatures: .none, + destination: .contact(publicKey: "05\(TestConstants.publicKey)"), + sentTimestampMs: 1234567890 ) ) } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + .to(throwError(CryptoError.missingUserSecretKey)) } } // MARK: -- when decrypting with the session protocol context("when decrypting with the session protocol") { + @TestState var result: DecodedMessage? + @TestState var encodedMessage: Data! = Data( + base64Encoded: "CAESvwEKABIAGrYBCAYSACjQiOyP9yM4AUKmAfjX/WXVFs+QE5Eh54Esw9/N" + + "lYza3k8MOvcRAI7y8k0JzLsm/KpXxKP7Zx7+5YyII9sCRXzFK2U4/X9SSMN088YEr/5wKoDfL5q" + + "PQbN70aa59WS8YE+yWcniQO0KXfAzr6Acn40fsa9BMr9tnQLfvxY8vD7qBz9iEOV9jTxPzxUoD+" + + "JelIbsv2qlkOl9vs166NC/Y772NZmUAR5u1ewL4SYEWkqX5R4gAA==" + ) + // MARK: ---- successfully decrypts a message it("successfully decrypts a message") { - let result = crypto.generate( - .plaintextWithSessionProtocol( - ciphertext: Data( - base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + - "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + - "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" - )! + try require { + result = try crypto.tryGenerate( + .decodedMessage( + encodedMessage: encodedMessage, + origin: .swarm( + publicKey: "05\(TestConstants.publicKey)", + namespace: .default, + serverHash: "12345", + serverTimestampMs: 1234567890, + serverExpirationTimestamp: 1234567890 + ) + ) ) - ) - - expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) - expect(result?.senderSessionIdHex) + }.toNot(throwError()) + + let proto: SNProtoContent! = try require { try result!.decodeProtoContent() } + .toNot(throwError()) + expect(proto.dataMessage?.body).to(equal("TestMessage")) + expect(result!.sender.hexString) .to(equal("0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) } @@ -122,29 +145,39 @@ class CryptoSMKSpec: QuickSpec { mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { - try crypto.tryGenerate( - .plaintextWithSessionProtocol( - ciphertext: Data( - base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + - "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + - "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" - )! + result = try crypto.tryGenerate( + .decodedMessage( + encodedMessage: encodedMessage, + origin: .swarm( + publicKey: "05\(TestConstants.publicKey)", + namespace: .default, + serverHash: "12345", + serverTimestampMs: 1234567890, + serverExpirationTimestamp: 1234567890 + ) ) ) } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + .to(throwError(CryptoError.missingUserSecretKey)) } // MARK: ---- throws an error if the ciphertext is too short it("throws an error if the ciphertext is too short") { expect { - try crypto.tryGenerate( - .plaintextWithSessionProtocol( - ciphertext: Data([1, 2, 3]) + result = try crypto.tryGenerate( + .decodedMessage( + encodedMessage: Data([1, 2, 3]), + origin: .swarm( + publicKey: "05\(TestConstants.publicKey)", + namespace: .default, + serverHash: "12345", + serverTimestampMs: 1234567890, + serverExpirationTimestamp: 1234567890 + ) ) ) } - .to(throwError(MessageReceiverError.decryptionFailed)) + .to(throwError(MessageError.decodingFailed)) } } } diff --git a/SessionMessagingKitTests/Database/Models/GroupMemberSpec.swift b/SessionMessagingKitTests/Database/Models/GroupMemberSpec.swift index 93215ea73c..28fab154e0 100644 --- a/SessionMessagingKitTests/Database/Models/GroupMemberSpec.swift +++ b/SessionMessagingKitTests/Database/Models/GroupMemberSpec.swift @@ -27,7 +27,15 @@ class GroupMemberSpec: QuickSpec { ), profile: Profile( id: "05_(Id\(index < 10 ? "0" : "")\(index))", - name: "Name\(index < 10 ? "0" : "")\(index)" + name: "Name\(index < 10 ? "0" : "")\(index)", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ), currentUserSessionId: userSessionId ) @@ -276,7 +284,15 @@ private extension Array where Element == WithProfile { current.profile.map { currentProfile in Profile( id: (profileId ?? current.profileId), - name: (name ?? currentProfile.name) + name: (name ?? currentProfile.name), + nickname: currentProfile.nickname, + displayPictureUrl: currentProfile.displayPictureUrl, + displayPictureEncryptionKey: currentProfile.displayPictureEncryptionKey, + profileLastUpdated: currentProfile.profileLastUpdated, + blocksCommunityMessageRequests: currentProfile.blocksCommunityMessageRequests, + proFeatures: currentProfile.proFeatures, + proExpiryUnixTimestampMs: currentProfile.proExpiryUnixTimestampMs, + proGenIndexHashHex: currentProfile.proGenIndexHashHex ) } ), diff --git a/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift index 22fb802b43..75f840f399 100644 --- a/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift +++ b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift @@ -294,13 +294,17 @@ class MessageDeduplicationSpec: AsyncSpec { processedMessage: .standard( threadId: "testThreadId", threadVariant: .contact, - proto: try! SNProtoContent.builder().build(), messageInfo: MessageReceiveJob.Details.MessageInfo( message: mockMessage, variant: .readReceipt, threadVariant: .contact, serverExpirationTimestamp: nil, - proto: try! SNProtoContent.builder().build() + decodedMessage: DecodedMessage( + content: Data(), + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890 + ) ), uniqueIdentifier: "testId" ), @@ -367,7 +371,7 @@ class MessageDeduplicationSpec: AsyncSpec { ignoreDedupeFiles: false, using: dependencies ) - }.to(throwError(MessageReceiverError.duplicateMessage)) + }.to(throwError(MessageError.duplicateMessage)) } } @@ -403,7 +407,7 @@ class MessageDeduplicationSpec: AsyncSpec { ignoreDedupeFiles: false, using: dependencies ) - }.to(throwError(MessageReceiverError.duplicateMessage)) + }.to(throwError(MessageError.duplicateMessage)) } } @@ -806,13 +810,17 @@ class MessageDeduplicationSpec: AsyncSpec { .standard( threadId: "testThreadId", threadVariant: .contact, - proto: try! SNProtoContent.builder().build(), messageInfo: MessageReceiveJob.Details.MessageInfo( message: Message(), variant: .visibleMessage, threadVariant: .contact, serverExpirationTimestamp: nil, - proto: try! SNProtoContent.builder().build() + decodedMessage: DecodedMessage( + content: Data(), + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890 + ) ), uniqueIdentifier: "testId" ), @@ -1041,13 +1049,17 @@ class MessageDeduplicationSpec: AsyncSpec { .standard( threadId: "testThreadId", threadVariant: .contact, - proto: try! SNProtoContent.builder().build(), messageInfo: MessageReceiveJob.Details.MessageInfo( message: Message(), variant: .visibleMessage, threadVariant: .contact, serverExpirationTimestamp: nil, - proto: try! SNProtoContent.builder().build() + decodedMessage: DecodedMessage( + content: Data(), + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890 + ) ), uniqueIdentifier: "testId" ), @@ -1068,7 +1080,7 @@ class MessageDeduplicationSpec: AsyncSpec { uniqueIdentifier: "testId", using: dependencies ) - }.to(throwError(MessageReceiverError.duplicateMessage)) + }.to(throwError(MessageError.duplicateMessage)) } // MARK: ---- throws when the message is a legacy duplicate @@ -1097,7 +1109,7 @@ class MessageDeduplicationSpec: AsyncSpec { legacyIdentifier: "testLegacyId", using: dependencies ) - }.to(throwError(MessageReceiverError.duplicateMessage)) + }.to(throwError(MessageError.duplicateMessage)) expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testId") }) @@ -1153,7 +1165,7 @@ class MessageDeduplicationSpec: AsyncSpec { ), using: dependencies ) - }.to(throwError(MessageReceiverError.duplicatedCall)) + }.to(throwError(MessageError.duplicatedCall)) } } } diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index 705ce13d6d..b4090bbdfb 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -196,57 +196,6 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { ).toNot(beNil()) } } - - // MARK: ------ with an owner - context("with an owner") { - // MARK: -------- returns nil when given a null url - it("returns nil when given a null url") { - expect( - DisplayPictureDownloadJob.Details( - owner: .user( - Profile( - id: "1234", - name: "test", - displayPictureUrl: nil, - displayPictureEncryptionKey: encryptionKey - ) - ) - ) - ).to(beNil()) - } - - // MARK: -------- returns nil when given a null encryption key - it("returns nil when given a null encryption key") { - expect( - DisplayPictureDownloadJob.Details( - owner: .user( - Profile( - id: "1234", - name: "test", - displayPictureUrl: "http://oxen.io/1234/", - displayPictureEncryptionKey: nil - ) - ) - ) - ).to(beNil()) - } - - // MARK: -------- returns a value when given valid data - it("returns a value when given valid data") { - expect( - DisplayPictureDownloadJob.Details( - owner: .user( - Profile( - id: "1234", - name: "test", - displayPictureUrl: "http://oxen.io/1234/", - displayPictureEncryptionKey: encryptionKey - ) - ) - ) - ).toNot(beNil()) - } - } } // MARK: ---- for a group @@ -307,66 +256,6 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { ).toNot(beNil()) } } - - // MARK: ------ with an owner - context("with an owner") { - // MARK: -------- returns nil when given a null url - it("returns nil when given a null url") { - expect( - DisplayPictureDownloadJob.Details( - owner: .group( - ClosedGroup( - threadId: "1234", - name: "test", - formationTimestamp: 0, - displayPictureUrl: nil, - displayPictureEncryptionKey: encryptionKey, - shouldPoll: nil, - invited: nil - ) - ) - ) - ).to(beNil()) - } - - // MARK: -------- returns nil when given a null encryption key - it("returns nil when given a null encryption key") { - expect( - DisplayPictureDownloadJob.Details( - owner: .group( - ClosedGroup( - threadId: "1234", - name: "test", - formationTimestamp: 0, - displayPictureUrl: "http://oxen.io/1234/", - displayPictureEncryptionKey: nil, - shouldPoll: nil, - invited: nil - ) - ) - ) - ).to(beNil()) - } - - // MARK: -------- returns a value when given valid data - it("returns a value when given valid data") { - expect( - DisplayPictureDownloadJob.Details( - owner: .group( - ClosedGroup( - threadId: "1234", - name: "test", - formationTimestamp: 0, - displayPictureUrl: "http://oxen.io/1234/", - displayPictureEncryptionKey: encryptionKey, - shouldPoll: nil, - invited: nil - ) - ) - ) - ).toNot(beNil()) - } - } } // MARK: ---- for a community @@ -393,49 +282,6 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { ).toNot(beNil()) } } - - // MARK: ------ with an owner - context("with an owner") { - // MARK: -------- returns nil when given an empty imageId - it("returns nil when given an empty imageId") { - expect( - DisplayPictureDownloadJob.Details( - owner: .community( - OpenGroup( - server: "testServer", - roomToken: "testRoom", - publicKey: "1234", - isActive: false, - name: "test", - imageId: nil, - userCount: 0, - infoUpdates: 0 - ) - ) - ) - ).to(beNil()) - } - - // MARK: -------- returns a value when given valid data - it("returns a value when given valid data") { - expect( - DisplayPictureDownloadJob.Details( - owner: .community( - OpenGroup( - server: "testServer", - roomToken: "testRoom", - publicKey: "1234", - isActive: false, - name: "test", - imageId: "12", - userCount: 0, - infoUpdates: 0 - ) - ) - ) - ).toNot(beNil()) - } - } } } @@ -492,9 +338,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { profile = Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - profileLastUpdated: nil + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) mockStorage.write { db in try profile.insert(db) } job = Job( @@ -546,7 +397,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: TestConstants.serverPublicKey, - isActive: false, + shouldPoll: false, name: "test", imageId: "12", userCount: 0, @@ -570,7 +421,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { try Network.SOGS.preparedDownload( fileId: "12", roomToken: "testRoom", - authMethod: Authentication.community( + authMethod: Authentication.Community( info: LibSession.OpenGroupCapabilityInfo( roomToken: "", server: "testserver", @@ -614,9 +465,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { profile = Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - profileLastUpdated: nil + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) mockStorage.write { db in try profile.insert(db) } job = Job( @@ -734,9 +590,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { profile = Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - profileLastUpdated: 1234567890 + profileLastUpdated: 1234567890, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) mockStorage.write { db in _ = try Profile.deleteAll(db) @@ -808,9 +669,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - profileLastUpdated: 1234567891 + profileLastUpdated: 1234567891, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )) } @@ -845,9 +711,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - profileLastUpdated: 1234567891 + profileLastUpdated: 1234567891, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )) } @@ -890,9 +761,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - profileLastUpdated: 1234567891 + profileLastUpdated: 1234567891, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )) } @@ -905,9 +781,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - profileLastUpdated: 1234567891 + profileLastUpdated: 1234567891, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )) } @@ -1086,7 +967,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece", - isActive: true, + shouldPoll: true, name: "name", imageId: "100", userCount: 1, @@ -1172,7 +1053,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece", - isActive: true, + shouldPoll: true, name: "name", imageId: "100", userCount: 1, @@ -1215,7 +1096,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece", - isActive: true, + shouldPoll: true, name: "name", imageId: "100", userCount: 1, @@ -1234,7 +1115,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece", - isActive: true, + shouldPoll: true, name: "name", imageId: "100", userCount: 1, diff --git a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift index 693a81bc63..c48072f91f 100644 --- a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift @@ -187,7 +187,8 @@ class MessageSendJobSpec: AsyncSpec { state: .sending, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ) job = Job( variant: .messageSend, diff --git a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift index 24b8185e74..e96482230a 100644 --- a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift @@ -10,7 +10,7 @@ import Nimble @testable import SessionMessagingKit @testable import SessionUtilitiesKit -class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { +class RetrieveDefaultOpenGroupRoomsJobSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -91,9 +91,14 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { .thenReturn([:]) } ) - @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( - initialSetup: { cache in - cache.when { $0.setDefaultRoomInfo(.any) }.thenReturn(()) + @TestState(singleton: .communityManager, in: dependencies) var mockCommunityManager: MockCommunityManager! = MockCommunityManager( + initialSetup: { manager in + manager + .when { await $0.updateRooms(rooms: .any, server: .any, publicKey: .any, areDefaultRooms: .any) } + .thenReturn(()) + manager + .when { $0.handleCapabilities(.any, capabilities: .any, server: .any, publicKey: .any) } + .thenReturn(()) } ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( @@ -187,100 +192,11 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { expect(wasDeferred).to(beFalse()) } - // MARK: -- creates an inactive entry in the database if one does not exist - it("creates an inactive entry in the database if one does not exist") { - mockNetwork - .when { - $0.send( - endpoint: MockEndpoint.any, - destination: .any, - body: .any, - requestTimeout: .any, - requestAndPathBuildTimeout: .any - ) - } - .thenReturn(MockNetwork.errorResponse()) - - RetrieveDefaultOpenGroupRoomsJob.run( - job, - scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, - using: dependencies - ) - - let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } - expect(openGroups?.count).to(equal(1)) - expect(openGroups?.map { $0.server }).to(equal([Network.SOGS.defaultServer])) - expect(openGroups?.map { $0.roomToken }).to(equal([""])) - expect(openGroups?.map { $0.publicKey }).to(equal([Network.SOGS.defaultServerPublicKey])) - expect(openGroups?.map { $0.isActive }).to(equal([false])) - expect(openGroups?.map { $0.name }).to(equal([""])) - } - - // MARK: -- does not create a new entry if one already exists - it("does not create a new entry if one already exists") { - mockNetwork - .when { - $0.send( - endpoint: MockEndpoint.any, - destination: .any, - body: .any, - requestTimeout: .any, - requestAndPathBuildTimeout: .any - ) - } - .thenReturn(MockNetwork.errorResponse()) - - mockStorage.write { db in - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "", - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: "TestExisting", - userCount: 0, - infoUpdates: 0 - ) - .insert(db) - } - - RetrieveDefaultOpenGroupRoomsJob.run( - job, - scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, - using: dependencies - ) - - let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } - expect(openGroups?.count).to(equal(1)) - expect(openGroups?.map { $0.server }).to(equal([Network.SOGS.defaultServer])) - expect(openGroups?.map { $0.roomToken }).to(equal([""])) - expect(openGroups?.map { $0.publicKey }).to(equal([Network.SOGS.defaultServerPublicKey])) - expect(openGroups?.map { $0.isActive }).to(equal([false])) - expect(openGroups?.map { $0.name }).to(equal(["TestExisting"])) - } - // MARK: -- sends the correct request it("sends the correct request") { - mockStorage.write { db in - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "", - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: "TestExisting", - userCount: 0, - infoUpdates: 0 - ) - .insert(db) - } let expectedRequest: Network.PreparedRequest! = mockStorage.read { db in try Network.SOGS.preparedCapabilitiesAndRooms( - authMethod: Authentication.community( + authMethod: Authentication.Community( info: LibSession.OpenGroupCapabilityInfo( roomToken: "", server: Network.SOGS.defaultServer, @@ -302,8 +218,8 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - expect(mockNetwork) - .to(call { network in + await expect(mockNetwork) + .toEventually(call { network in network.send( endpoint: Network.SOGS.Endpoint.sequence, destination: expectedRequest.destination, @@ -316,47 +232,8 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { expect(expectedRequest?.headers).to(beEmpty()) } - // MARK: -- will retry 8 times before it fails - it("will retry 8 times before it fails") { - mockNetwork - .when { - $0.send( - endpoint: MockEndpoint.any, - destination: .any, - body: .any, - requestTimeout: .any, - requestAndPathBuildTimeout: .any - ) - } - .thenReturn(MockNetwork.nullResponse()) - - RetrieveDefaultOpenGroupRoomsJob.run( - job, - scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, error_, permanentFailure_ in - error = error_ - permanentFailure = permanentFailure_ - }, - deferred: { _ in }, - using: dependencies - ) - - expect(error).to(matchError(NetworkError.parsingFailed)) - expect(mockNetwork) // First attempt + 8 retries - .to(call(.exactly(times: 9)) { network in - network.send( - endpoint: MockEndpoint.any, - destination: .any, - body: .any, - requestTimeout: .any, - requestAndPathBuildTimeout: .any - ) - }) - } - - // MARK: -- stores the updated capabilities - it("stores the updated capabilities") { + // MARK: -- sends the updated capabilities to the CommunityManager for storage + it("sends the updated capabilities to the CommunityManager for storage") { RetrieveDefaultOpenGroupRoomsJob.run( job, scheduler: DispatchQueue.main, @@ -366,16 +243,24 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - let capabilities: [Capability]? = mockStorage.read { db in try Capability.fetchAll(db) } - expect(capabilities?.count).to(equal(2)) - expect(capabilities?.map { $0.openGroupServer }) - .to(equal([Network.SOGS.defaultServer, Network.SOGS.defaultServer])) - expect(capabilities?.map { $0.variant }).to(equal([.blind, .reactions])) - expect(capabilities?.map { $0.isMissing }).to(equal([false, false])) + await expect(mockCommunityManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + $0.handleCapabilities( + .any, + capabilities: Network.SOGS.CapabilitiesResponse( + capabilities: [ + Capability.Variant.blind.rawValue, + Capability.Variant.reactions.rawValue + ], + missing: nil + ), + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey + ) + }) } - // MARK: -- inserts the returned rooms - it("inserts the returned rooms") { + // MARK: -- stores the returned rooms in the CommunityManager + it("stores the returned rooms in the CommunityManager") { RetrieveDefaultOpenGroupRoomsJob.run( job, scheduler: DispatchQueue.main, @@ -385,87 +270,25 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } - expect(openGroups?.count).to(equal(3)) // 1 for the entry used to fetch the default rooms - expect(openGroups?.map { $0.server }) - .to(equal([Network.SOGS.defaultServer, Network.SOGS.defaultServer, Network.SOGS.defaultServer])) - expect(openGroups?.map { $0.roomToken }).to(equal(["", "testRoom", "testRoom2"])) - expect(openGroups?.map { $0.publicKey }) - .to(equal([ - Network.SOGS.defaultServerPublicKey, - Network.SOGS.defaultServerPublicKey, - Network.SOGS.defaultServerPublicKey - ])) - expect(openGroups?.map { $0.isActive }).to(equal([false, false, false])) - expect(openGroups?.map { $0.name }).to(equal(["", "TestRoomName", "TestRoomName2"])) - } - - // MARK: -- does not override existing rooms that were returned - it("does not override existing rooms that were returned") { - mockStorage.write { db in - try OpenGroup( + await expect(mockCommunityManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + await $0.updateRooms( + rooms: [ + Network.SOGS.Room.mock.with( + token: "testRoom", + name: "TestRoomName" + ), + Network.SOGS.Room.mock.with( + token: "testRoom2", + name: "TestRoomName2", + infoUpdates: 12, + imageId: "12" + ) + ], server: Network.SOGS.defaultServer, - roomToken: "testRoom", publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: "TestExisting", - userCount: 0, - infoUpdates: 0 + areDefaultRooms: true ) - .insert(db) - } - mockNetwork - .when { - $0.send( - endpoint: MockEndpoint.any, - destination: .any, - body: .any, - requestTimeout: .any, - requestAndPathBuildTimeout: .any - ) - } - .thenReturn( - MockNetwork.batchResponseData( - with: [ - (Network.SOGS.Endpoint.capabilities, Network.SOGS.CapabilitiesResponse.mockBatchSubResponse()), - ( - Network.SOGS.Endpoint.rooms, - try! JSONEncoder().with(outputFormatting: .sortedKeys).encode( - Network.BatchSubResponse( - code: 200, - headers: [:], - body: [ - Network.SOGS.Room.mock.with( - token: "testRoom", - name: "TestReplacementName" - ) - ], - failedToParseBody: false - ) - ) - ) - ] - ) - ) - - RetrieveDefaultOpenGroupRoomsJob.run( - job, - scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, - using: dependencies - ) - - let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } - expect(openGroups?.count).to(equal(2)) // 1 for the entry used to fetch the default rooms - expect(openGroups?.map { $0.server }) - .to(equal([Network.SOGS.defaultServer, Network.SOGS.defaultServer])) - expect(openGroups?.map { $0.roomToken }.sorted()).to(equal(["", "testRoom"])) - expect(openGroups?.map { $0.publicKey }) - .to(equal([Network.SOGS.defaultServerPublicKey, Network.SOGS.defaultServerPublicKey])) - expect(openGroups?.map { $0.isActive }).to(equal([false, false])) - expect(openGroups?.map { $0.name }.sorted()).to(equal(["", "TestExisting"])) + }) } // MARK: -- schedules a display picture download @@ -479,75 +302,26 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - expect(mockJobRunner) - .to(call(matchingParameters: .all) { - $0.add( - .any, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .community( - imageId: "12", - roomToken: "testRoom2", - server: Network.SOGS.defaultServer, - skipAuthentication: true - ), - timestamp: 1234567890 - ) - ), - dependantJob: nil, - canStartJob: true - ) - }) - } - - // MARK: -- schedules a display picture download if the imageId has changed - it("schedules a display picture download if the imageId has changed") { - mockStorage.write { db in - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "testRoom2", - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: "TestExisting", - imageId: "10", - userCount: 0, - infoUpdates: 10 + await expect(mockJobRunner).toEventually(call(matchingParameters: .all) { + $0.add( + .any, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .community( + imageId: "12", + roomToken: "testRoom2", + server: Network.SOGS.defaultServer, + skipAuthentication: true + ), + timestamp: 1234567890 + ) + ), + dependantJob: nil, + canStartJob: true ) - .insert(db) - } - - RetrieveDefaultOpenGroupRoomsJob.run( - job, - scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, - using: dependencies - ) - - expect(mockJobRunner) - .to(call(matchingParameters: .all) { - $0.add( - .any, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .community( - imageId: "12", - roomToken: "testRoom2", - server: Network.SOGS.defaultServer, - skipAuthentication: true - ), - timestamp: 1234567890 - ) - ), - dependantJob: nil, - canStartJob: true - ) - }) + }) } // MARK: -- does not schedule a display picture download if there is no imageId @@ -604,36 +378,6 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { .toNot(call { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) }) } - // MARK: -- does not schedule a display picture download if the imageId matches and the image has already been downloaded - it("does not schedule a display picture download if the imageId matches and the image has already been downloaded") { - mockStorage.write { db in - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "testRoom2", - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: "TestExisting", - imageId: "12", - userCount: 0, - infoUpdates: 12, - displayPictureOriginalUrl: "TestUrl" - ) - .insert(db) - } - - RetrieveDefaultOpenGroupRoomsJob.run( - job, - scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, - using: dependencies - ) - - expect(mockJobRunner) - .toNot(call { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) }) - } - // MARK: -- updates the cache with the default rooms it("updates the cache with the default rooms") { RetrieveDefaultOpenGroupRoomsJob.run( @@ -645,43 +389,25 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - expect(mockOGMCache) + expect(mockCommunityManager) .toNot(call(matchingParameters: .all) { - $0.setDefaultRoomInfo([ - ( - room: Network.SOGS.Room.mock.with( + await $0.updateRooms( + rooms: [ + Network.SOGS.Room.mock.with( token: "testRoom", name: "TestRoomName" ), - openGroup: OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "testRoom", - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: "TestRoomName", - userCount: 0, - infoUpdates: 0 - ) - ), - ( - room: Network.SOGS.Room.mock.with( + Network.SOGS.Room.mock.with( token: "testRoom2", name: "TestRoomName2", infoUpdates: 12, imageId: "12" - ), - openGroup: OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "testRoom2", - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: "TestRoomName2", - imageId: "12", - userCount: 0, - infoUpdates: 12 ) - ) - ]) + ], + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey, + areDefaultRooms: true + ) }) } } diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift index 180d585389..e1289d57a1 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift @@ -95,6 +95,14 @@ class LibSessionGroupInfoSpec: QuickSpec { cache.when { $0.configNeedsDump(.any) }.thenReturn(true) } ) + @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( + initialSetup: { crypto in + crypto.when { $0.generate(.hash(message: .any, length: .any)) }.thenReturn("TestHash".bytes) + crypto + .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } + .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) + } + ) // MARK: - LibSessionGroupInfo describe("LibSessionGroupInfo") { @@ -134,8 +142,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -153,8 +160,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupMembers]!, - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } .to(throwError()) @@ -169,8 +175,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -192,8 +197,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -215,8 +219,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -242,8 +245,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -271,8 +273,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -296,8 +297,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -320,7 +320,7 @@ class LibSessionGroupInfoSpec: QuickSpec { count: DisplayPictureManager.encryptionKeySize ) ), - timestamp: 1234567891 + timestamp: 1234567890 ) ), canStartJob: true @@ -337,8 +337,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -389,7 +388,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) } @@ -399,8 +399,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -445,7 +444,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Interaction( serverHash: "1235", @@ -468,7 +468,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) } @@ -478,8 +479,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -529,7 +529,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Attachment( id: "AttachmentId", @@ -552,8 +553,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -598,7 +598,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Attachment( id: "AttachmentId", @@ -621,8 +622,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -679,7 +679,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) let interaction2: Interaction = try Interaction( serverHash: "1235", @@ -702,7 +703,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Attachment( id: "AttachmentId", @@ -736,8 +738,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -782,7 +783,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Interaction( serverHash: "1235", @@ -805,7 +807,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Attachment( id: "AttachmentId", @@ -828,8 +831,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -875,9 +877,14 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) } + mockLibSessionCache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) + mockLibSessionCache + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData(groupIdentityPrivateKey: Data([1, 2, 3]), authData: nil)) createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_delete_before($0, 123456) } @@ -885,8 +892,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -900,7 +906,7 @@ class LibSessionGroupInfoSpec: QuickSpec { using: dependencies ) expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in + .toEventually(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( endpoint: Network.SnodeAPI.Endpoint.deleteMessages, destination: expectedRequest.destination, @@ -945,7 +951,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) } @@ -955,8 +962,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } diff --git a/SessionMessagingKitTests/LibSession/LibSessionSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionSpec.swift index cb8ff17dd8..19d9f6cce4 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionSpec.swift @@ -50,7 +50,7 @@ class LibSessionSpec: QuickSpec { ) ) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece")), @@ -339,7 +339,7 @@ class LibSessionSpec: QuickSpec { catch { resultError = error } } - expect(resultError).to(matchError(MessageSenderError.noKeyPair)) + expect(resultError).to(matchError(CryptoError.missingUserSecretKey)) } // MARK: ---- throws when it fails to generate a new identity ed25519 keyPair @@ -363,7 +363,7 @@ class LibSessionSpec: QuickSpec { catch { resultError = error } } - expect(resultError).to(matchError(MessageSenderError.noKeyPair)) + expect(resultError).to(matchError(CryptoError.missingUserSecretKey)) } // MARK: ---- throws when given an invalid member id @@ -382,7 +382,15 @@ class LibSessionSpec: QuickSpec { id: "123456", profile: Profile( id: "123456", - name: "" + name: "", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )], using: dependencies @@ -460,8 +468,14 @@ class LibSessionSpec: QuickSpec { profile: Profile( id: "051111111111111111111111111111111111111111111111111111111111111111", name: "TestName", + nickname: nil, displayPictureUrl: "testUrl", - displayPictureEncryptionKey: Data([1, 2, 3]) + displayPictureEncryptionKey: Data([1, 2, 3]), + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )], using: dependencies @@ -504,7 +518,15 @@ class LibSessionSpec: QuickSpec { id: "051111111111111111111111111111111111111111111111111111111111111111", profile: Profile( id: "051111111111111111111111111111111111111111111111111111111111111111", - name: "TestName" + name: "TestName", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )], using: dependencies diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift similarity index 71% rename from SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift rename to SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift index 6fed99f9ed..cb9c4aff7a 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift @@ -12,7 +12,7 @@ import Nimble @testable import SessionMessagingKit @testable import SessionNetworkingKit -class OpenGroupManagerSpec: QuickSpec { +class CommunityManagerSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -42,7 +42,8 @@ class OpenGroupManagerSpec: QuickSpec { state: .sending, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ) @TestState var testGroupThread: SessionThread! = SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), @@ -53,7 +54,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "http://127.0.0.1", roomToken: "testRoom", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test", roomDescription: nil, imageId: nil, @@ -66,37 +67,35 @@ class OpenGroupManagerSpec: QuickSpec { activeUsers: 10, details: .mock ) - @TestState var testMessage: Network.SOGS.Message! = Network.SOGS.Message( - id: 127, - sender: "05\(TestConstants.publicKey)", - posted: 123, - edited: nil, - deleted: nil, - seqNo: 124, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: [ - "Cg0KC1Rlc3RNZXNzYWdlg", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AA" - ].joined(), - base64EncodedSignature: nil, - reactions: nil - ) + @TestState var testMessage: Network.SOGS.Message! = { + let proto = SNProtoContent.builder() + let protoDataBuilder = SNProtoDataMessage.builder() + proto.setSigTimestamp(1234567890000) + protoDataBuilder.setBody("TestMessage") + protoDataBuilder.setTimestamp(1234567890000) + proto.setDataMessage(try! protoDataBuilder.build()) + + return Network.SOGS.Message( + id: 127, + sender: "05\(TestConstants.publicKey)", + posted: 1234567890, + edited: nil, + deleted: nil, + seqNo: 124, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: try! proto.build().serializedData().base64EncodedString(), + base64EncodedSignature: nil, + reactions: nil + ) + }() @TestState var testDirectMessage: Network.SOGS.DirectMessage! = { let proto = SNProtoContent.builder() let protoDataBuilder = SNProtoDataMessage.builder() proto.setSigTimestamp(1234567890000) protoDataBuilder.setBody("TestMessage") + protoDataBuilder.setTimestamp(1234567890000) proto.setDataMessage(try! protoDataBuilder.build()) return Network.SOGS.DirectMessage( @@ -183,7 +182,7 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.generate(.randomBytes(24)) } .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), @@ -219,12 +218,16 @@ class OpenGroupManagerSpec: QuickSpec { @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( initialSetup: { $0.defaultInitialSetup() } ) - @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( - initialSetup: { cache in - cache.when { $0.pendingChanges }.thenReturn([]) - cache.when { $0.pendingChanges = .any }.thenReturn(()) - cache.when { $0.getLastSuccessfulCommunityPollTimestamp() }.thenReturn(0) - cache.when { $0.setDefaultRoomInfo(.any) }.thenReturn(()) + @TestState(singleton: .communityManager, in: dependencies) var mockCommunityManager: MockCommunityManager! = MockCommunityManager( + initialSetup: { manager in + manager.when { await $0.pendingChanges }.thenReturn([]) + manager.when { await $0.setPendingChanges(.any) }.thenReturn(()) + manager.when { await $0.updatePendingChange(.any, seqNo: .any) }.thenReturn(()) + manager.when { await $0.removePendingChange(.any) }.thenReturn(()) + manager.when { await $0.getLastSuccessfulCommunityPollTimestamp() }.thenReturn(0) + manager + .when { await $0.updateRooms(rooms: .any, server: .any, publicKey: .any, areDefaultRooms: .any) } + .thenReturn(()) } ) @TestState var mockPoller: MockCommunityPoller! = MockCommunityPoller( @@ -268,11 +271,10 @@ class OpenGroupManagerSpec: QuickSpec { }() @TestState var disposables: [AnyCancellable]! = [] - @TestState var cache: OpenGroupManager.Cache! = OpenGroupManager.Cache(using: dependencies) - @TestState var openGroupManager: OpenGroupManager! = OpenGroupManager(using: dependencies) + @TestState var communityManager: CommunityManager! = CommunityManager(using: dependencies) - // MARK: - an OpenGroupManager - describe("an OpenGroupManager") { + // MARK: - a CommunityManager + describe("a CommunityManager") { beforeEach { _ = userGroupsInitResult } @@ -287,7 +289,9 @@ class OpenGroupManagerSpec: QuickSpec { } .thenReturn(nil) - expect(cache.getLastSuccessfulCommunityPollTimestamp()).to(equal(0)) + await expect { + await communityManager.getLastSuccessfulCommunityPollTimestamp() + }.toEventually(equal(0)) } // MARK: ---- returns the time since the last poll @@ -299,8 +303,9 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567880)) dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) - expect(cache.getLastSuccessfulCommunityPollTimestamp()) - .to(equal(1234567880)) + await expect { + await communityManager.getLastSuccessfulCommunityPollTimestamp() + }.toEventually(equal(1234567880)) } // MARK: ---- caches the time since the last poll in memory @@ -312,8 +317,9 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567770)) dependencies.dateNow = Date(timeIntervalSince1970: 1234567780) - expect(cache.getLastSuccessfulCommunityPollTimestamp()) - .to(equal(1234567770)) + await expect { + await communityManager.getLastSuccessfulCommunityPollTimestamp() + }.toEventually(equal(1234567770)) mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in @@ -322,13 +328,14 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567890)) // Cached value shouldn't have been updated - expect(cache.getLastSuccessfulCommunityPollTimestamp()) - .to(equal(1234567770)) + await expect { + await communityManager.getLastSuccessfulCommunityPollTimestamp() + }.toEventually(equal(1234567770)) } // MARK: ---- updates the time since the last poll in user defaults it("updates the time since the last poll in user defaults") { - cache.setLastSuccessfulCommunityPollTimestamp(12345) + await communityManager.setLastSuccessfulCommunityPollTimestamp(12345) expect(mockUserDefaults) .to(call(matchingParameters: .all) { @@ -340,294 +347,256 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: -- when checking if an open group is run by session - context("when checking if an open group is run by session") { + // MARK: -- when checking if an community is run by session + context("when checking if an community is run by session") { // MARK: ---- returns false when it does not match one of Sessions servers with no scheme it("returns false when it does not match one of Sessions servers with no scheme") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "test.test")) + expect(CommunityManager.isSessionRunCommunity(server: "test.test")) .to(beFalse()) } // MARK: ---- returns false when it does not match one of Sessions servers in http it("returns false when it does not match one of Sessions servers in http") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://test.test")) + expect(CommunityManager.isSessionRunCommunity(server: "http://test.test")) .to(beFalse()) } // MARK: ---- returns false when it does not match one of Sessions servers in https it("returns false when it does not match one of Sessions servers in https") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://test.test")) + expect(CommunityManager.isSessionRunCommunity(server: "https://test.test")) .to(beFalse()) } // MARK: ---- returns true when it matches Sessions SOGS IP it("returns true when it matches Sessions SOGS IP") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "116.203.70.33")) + expect(CommunityManager.isSessionRunCommunity(server: "116.203.70.33")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS IP with http it("returns true when it matches Sessions SOGS IP with http") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://116.203.70.33")) + expect(CommunityManager.isSessionRunCommunity(server: "http://116.203.70.33")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS IP with https it("returns true when it matches Sessions SOGS IP with https") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://116.203.70.33")) + expect(CommunityManager.isSessionRunCommunity(server: "https://116.203.70.33")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS IP with a port it("returns true when it matches Sessions SOGS IP with a port") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "116.203.70.33:80")) + expect(CommunityManager.isSessionRunCommunity(server: "116.203.70.33:80")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS domain it("returns true when it matches Sessions SOGS domain") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "open.getsession.org")) + expect(CommunityManager.isSessionRunCommunity(server: "open.getsession.org")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS domain with http it("returns true when it matches Sessions SOGS domain with http") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://open.getsession.org")) + expect(CommunityManager.isSessionRunCommunity(server: "http://open.getsession.org")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS domain with https it("returns true when it matches Sessions SOGS domain with https") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://open.getsession.org")) + expect(CommunityManager.isSessionRunCommunity(server: "https://open.getsession.org")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS domain with a port it("returns true when it matches Sessions SOGS domain with a port") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "open.getsession.org:80")) + expect(CommunityManager.isSessionRunCommunity(server: "open.getsession.org:80")) .to(beTrue()) } } - // MARK: -- when checking it has an existing open group - context("when checking it has an existing open group") { - // MARK: ---- when there is a thread for the room and the cache has a poller - context("when there is a thread for the room and the cache has a poller") { + // MARK: -- when checking it has an existing community + context("when checking it has an existing community") { + // MARK: ---- for the no-scheme variant + context("for the no-scheme variant") { beforeEach { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn(["http://127.0.0.1"]) + await communityManager.updateServer(server: CommunityManager.Server( + server: "127.0.0.1", + publicKey: TestConstants.serverPublicKey, + openGroups: [testOpenGroup], + using: dependencies + )) } - // MARK: ------ for the no-scheme variant - context("for the no-scheme variant") { - // MARK: -------- returns true when no scheme is provided - it("returns true when no scheme is provided") { - expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } - ).to(beTrue()) - } - - // MARK: -------- returns true when a http scheme is provided - it("returns true when a http scheme is provided") { - expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } - ).to(beTrue()) - } - - // MARK: -------- returns true when a https scheme is provided - it("returns true when a https scheme is provided") { - expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } - ).to(beTrue()) - } + // MARK: ------ returns true when no scheme is provided + it("returns true when no scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) } - // MARK: ------ for the http variant - context("for the http variant") { - // MARK: -------- returns true when no scheme is provided - it("returns true when no scheme is provided") { - expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } - ).to(beTrue()) - } - - // MARK: -------- returns true when a http scheme is provided - it("returns true when a http scheme is provided") { - expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } - ).to(beTrue()) - } - - // MARK: -------- returns true when a https scheme is provided - it("returns true when a https scheme is provided") { - expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } - ).to(beTrue()) - } + // MARK: ------ returns true when a http scheme is provided + it("returns true when a http scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) } - // MARK: ------ for the https variant - context("for the https variant") { - // MARK: -------- returns true when no scheme is provided - it("returns true when no scheme is provided") { - expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } - ).to(beTrue()) - } - - // MARK: -------- returns true when a http scheme is provided - it("returns true when a http scheme is provided") { - expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } - ).to(beTrue()) - } - - // MARK: -------- returns true when a https scheme is provided - it("returns true when a https scheme is provided") { - expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } - ).to(beTrue()) - } + // MARK: ------ returns true when a https scheme is provided + it("returns true when a https scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "https://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) } } - // MARK: ---- when given the legacy DNS host and there is a cached poller for the default server - context("when given the legacy DNS host and there is a cached poller for the default server") { - // MARK: ------ returns true - it("returns true") { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn(["http://116.203.70.33"]) - mockStorage.write { db in - try SessionThread( - id: OpenGroup.idFor(roomToken: "testRoom", server: "http://116.203.70.33"), - variant: .community, - creationDateTimestamp: 0, - shouldBeVisible: true, - isPinned: false, - messageDraft: nil, - notificationSound: nil, - mutedUntilTimestamp: nil, - onlyNotifyForMentions: false - ).insert(db) - } - + // MARK: ---- for the http variant + context("for the http variant") { + beforeEach { + await communityManager.updateServer(server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey, + openGroups: [testOpenGroup], + using: dependencies + )) + } + + // MARK: ------ returns true when no scheme is provided + it("returns true when no scheme is provided") { expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://open.getsession.org", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) + } + + // MARK: ------ returns true when a http scheme is provided + it("returns true when a http scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) + } + + // MARK: ------ returns true when a https scheme is provided + it("returns true when a https scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "https://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } } - // MARK: ---- when given the default server and there is a cached poller for the legacy DNS host - context("when given the default server and there is a cached poller for the legacy DNS host") { - // MARK: ------ returns true - it("returns true") { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn(["http://open.getsession.org"]) - mockStorage.write { db in - try SessionThread( - id: OpenGroup.idFor(roomToken: "testRoom", server: "http://open.getsession.org"), - variant: .community, - creationDateTimestamp: 0, - shouldBeVisible: true, - isPinned: false, - messageDraft: nil, - notificationSound: nil, - mutedUntilTimestamp: nil, - onlyNotifyForMentions: false - ).insert(db) - } - + // MARK: ---- for the https variant + context("for the https variant") { + beforeEach { + await communityManager.updateServer(server: CommunityManager.Server( + server: "https://127.0.0.1", + publicKey: TestConstants.serverPublicKey, + openGroups: [testOpenGroup], + using: dependencies + )) + } + + // MARK: ------ returns true when no scheme is provided + it("returns true when no scheme is provided") { expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://116.203.70.33", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } + + // MARK: ------ returns true when a http scheme is provided + it("returns true when a http scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) + } + + // MARK: ------ returns true when a https scheme is provided + it("returns true when a https scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "https://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) + } + } + + // MARK: ---- returns true when given the legacy DNS host and the cache includes the default server + it("returns true when given the legacy DNS host and the cache includes the default server") { + await communityManager.updateServer(server: CommunityManager.Server( + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey, + openGroups: [testOpenGroup], + using: dependencies + )) + + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://116.203.70.33", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) + } + + // MARK: ---- returns true when given the default server and the legacy DNS host is cached + it("returns true when given the default server and the legacy DNS host is cached") { + await communityManager.updateServer(server: CommunityManager.Server( + server: Network.SOGS.legacyDefaultServerIP, + publicKey: Network.SOGS.defaultServerPublicKey, + openGroups: [testOpenGroup], + using: dependencies + )) + + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://open.getsession.org", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) } // MARK: ---- returns false when given an invalid server it("returns false when given an invalid server") { expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "%%%", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "%%%", + publicKey: TestConstants.serverPublicKey + ) ).to(beFalse()) } @@ -636,14 +605,11 @@ class OpenGroupManagerSpec: QuickSpec { mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn([]) expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beFalse()) } @@ -654,21 +620,18 @@ class OpenGroupManagerSpec: QuickSpec { } expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beFalse()) } } } - // MARK: - an OpenGroupManager - describe("an OpenGroupManager") { + // MARK: - a CommunityManager + describe("a CommunityManager") { // MARK: -- when adding context("when adding") { beforeEach { @@ -695,11 +658,11 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567890)) } - // MARK: ---- stores the open group server - it("stores the open group server") { + // MARK: ---- stores the community server + it("stores the community server") { mockStorage .writePublisher { db -> Bool in - openGroupManager.add( + communityManager.add( db, roomToken: "testRoom", server: "http://127.0.0.1", @@ -709,7 +672,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .flatMap { successfullyAddedGroup in - openGroupManager.performInitialRequestsAfterAdd( + communityManager.performInitialRequestsAfterAdd( queue: DispatchQueue.main, successfullyAddedGroup: successfullyAddedGroup, roomToken: "testRoom", @@ -734,7 +697,7 @@ class OpenGroupManagerSpec: QuickSpec { it("adds a poller") { mockStorage .writePublisher { db -> Bool in - openGroupManager.add( + communityManager.add( db, roomToken: "testRoom", server: "http://127.0.0.1", @@ -744,7 +707,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .flatMap { successfullyAddedGroup in - openGroupManager.performInitialRequestsAfterAdd( + communityManager.performInitialRequestsAfterAdd( queue: DispatchQueue.main, successfullyAddedGroup: successfullyAddedGroup, roomToken: "testRoom", @@ -769,7 +732,13 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- an existing room context("an existing room") { beforeEach { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn(["http://127.0.0.1"]) + await communityManager.updateServer(server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey, + openGroups: [testOpenGroup], + using: dependencies + )) + mockStorage.write { db in try testOpenGroup.insert(db) } @@ -779,7 +748,7 @@ class OpenGroupManagerSpec: QuickSpec { it("does not reset the sequence number or update the public key") { mockStorage .writePublisher { db -> Bool in - openGroupManager.add( + communityManager.add( db, roomToken: "testRoom", server: "http://127.0.0.1", @@ -791,7 +760,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .flatMap { successfullyAddedGroup in - openGroupManager.performInitialRequestsAfterAdd( + communityManager.performInitialRequestsAfterAdd( queue: DispatchQueue.main, successfullyAddedGroup: successfullyAddedGroup, roomToken: "testRoom", @@ -850,7 +819,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage .writePublisher { db -> Bool in - openGroupManager.add( + communityManager.add( db, roomToken: "testRoom", server: "http://127.0.0.1", @@ -860,7 +829,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .flatMap { successfullyAddedGroup in - openGroupManager.performInitialRequestsAfterAdd( + communityManager.performInitialRequestsAfterAdd( queue: DispatchQueue.main, successfullyAddedGroup: successfullyAddedGroup, roomToken: "testRoom", @@ -898,7 +867,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- removes all interactions for the thread it("removes all interactions for the thread") { mockStorage.write { db in - try openGroupManager.delete( + try communityManager.delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), skipLibSessionUpdate: true @@ -912,7 +881,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- removes the given thread it("removes the given thread") { mockStorage.write { db in - try openGroupManager.delete( + try communityManager.delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), skipLibSessionUpdate: true @@ -923,12 +892,12 @@ class OpenGroupManagerSpec: QuickSpec { .to(equal(0)) } - // MARK: ---- and there is only one open group for this server - context("and there is only one open group for this server") { + // MARK: ---- and there is only one community for this server + context("and there is only one community for this server") { // MARK: ------ stops the poller it("stops the poller") { mockStorage.write { db in - try openGroupManager.delete( + try communityManager.delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), skipLibSessionUpdate: true @@ -939,10 +908,10 @@ class OpenGroupManagerSpec: QuickSpec { .to(call(matchingParameters: .all) { $0.stopAndRemovePoller(for: "http://127.0.0.1") }) } - // MARK: ------ removes the open group - it("removes the open group") { + // MARK: ------ removes the community + it("removes the community") { mockStorage.write { db in - try openGroupManager.delete( + try communityManager.delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), skipLibSessionUpdate: true @@ -954,8 +923,8 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: ---- and the are multiple open groups for this server - context("and the are multiple open groups for this server") { + // MARK: ---- and the are multiple communities for this server + context("and the are multiple communities for this server") { beforeEach { mockStorage.write { db in try OpenGroup.deleteAll(db) @@ -964,7 +933,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "http://127.0.0.1", roomToken: "testRoom1", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test1", roomDescription: nil, imageId: nil, @@ -977,10 +946,10 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: ------ removes the open group - it("removes the open group") { + // MARK: ------ removes the community + it("removes the community") { mockStorage.write { db in - try openGroupManager.delete( + try communityManager.delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), skipLibSessionUpdate: true @@ -991,93 +960,21 @@ class OpenGroupManagerSpec: QuickSpec { .to(equal(1)) } } - - // MARK: ---- and it is the default server - context("and it is the default server") { - beforeEach { - mockStorage.write { db in - try OpenGroup.deleteAll(db) - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "testRoom", - publicKey: TestConstants.publicKey, - isActive: true, - name: "Test1", - roomDescription: nil, - imageId: nil, - userCount: 0, - infoUpdates: 0, - sequenceNumber: 0, - inboxLatestMessageId: 0, - outboxLatestMessageId: 0 - ).insert(db) - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "testRoom1", - publicKey: TestConstants.publicKey, - isActive: true, - name: "Test1", - roomDescription: nil, - imageId: nil, - userCount: 0, - infoUpdates: 0, - sequenceNumber: 0, - inboxLatestMessageId: 0, - outboxLatestMessageId: 0 - ).insert(db) - } - } - - // MARK: ------ does not remove the open group - it("does not remove the open group") { - mockStorage.write { db in - try openGroupManager.delete( - db, - openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: Network.SOGS.defaultServer), - skipLibSessionUpdate: true - ) - } - - expect(mockStorage.read { db in try OpenGroup.fetchCount(db) }) - .to(equal(2)) - } - - // MARK: ------ deactivates the open group - it("deactivates the open group") { - mockStorage.write { db in - try openGroupManager.delete( - db, - openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: Network.SOGS.defaultServer), - skipLibSessionUpdate: true - ) - } - - expect( - mockStorage.read { db in - try OpenGroup - .select(.isActive) - .filter(id: OpenGroup.idFor(roomToken: "testRoom", server: Network.SOGS.defaultServer)) - .asRequest(of: Bool.self) - .fetchOne(db) - } - ).to(beFalse()) - } - } } // MARK: -- when handling capabilities context("when handling capabilities") { beforeEach { mockStorage.write { db in - OpenGroupManager - .handleCapabilities( - db, - capabilities: Network.SOGS.CapabilitiesResponse( - capabilities: ["sogs"], - missing: [] - ), - on: "http://127.0.0.1" - ) + communityManager.handleCapabilities( + db, + capabilities: Network.SOGS.CapabilitiesResponse( + capabilities: ["sogs"], + missing: [] + ), + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey + ) } } @@ -1089,8 +986,8 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - an OpenGroupManager - describe("an OpenGroupManager") { + // MARK: - a CommunityManager + describe("a CommunityManager") { // MARK: -- when handling room poll info context("when handling room poll info") { beforeEach { @@ -1101,16 +998,15 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: ---- saves the updated open group - it("saves the updated open group") { + // MARK: ---- saves the updated community + it("saves the updated community") { mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1127,13 +1023,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- does not schedule the displayPictureDownload job if there is no image it("does not schedule the displayPictureDownload job if there is no image") { mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1167,7 +1062,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "http://127.0.0.1", roomToken: "testRoom", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test", imageId: "12", userCount: 0, @@ -1176,13 +1071,12 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1224,13 +1118,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1271,13 +1164,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1312,13 +1204,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1343,13 +1234,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1390,13 +1280,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1432,13 +1321,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1447,8 +1335,8 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: ---- when it cannot get the open group - context("when it cannot get the open group") { + // MARK: ---- when it cannot get the community + context("when it cannot get the community") { // MARK: ------ does not save the thread it("does not save the thread") { mockStorage.write { db in @@ -1456,13 +1344,12 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1470,32 +1357,6 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: ---- when not given a public key - context("when not given a public key") { - // MARK: ------ saves the open group with the existing public key - it("saves the open group with the existing public key") { - mockStorage.write { db in - try OpenGroupManager.handlePollInfo( - db, - pollInfo: testPollInfo, - publicKey: nil, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies - ) - } - - expect( - mockStorage.read { db -> String? in - try OpenGroup - .select(.publicKey) - .asRequest(of: String.self) - .fetchOne(db) - } - ).to(equal(TestConstants.publicKey)) - } - } - // MARK: ---- when trying to get the room image context("when trying to get the room image") { beforeEach { @@ -1518,13 +1379,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1566,7 +1426,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "http://127.0.0.1", roomToken: "testRoom", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test", imageId: "12", userCount: 0, @@ -1582,13 +1442,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1619,7 +1478,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "http://127.0.0.1", roomToken: "testRoom", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test", imageId: "12", userCount: 0, @@ -1640,13 +1499,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1691,13 +1549,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ does nothing if there is no room image it("does nothing if there is no room image") { mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1714,11 +1571,34 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - an OpenGroupManager - describe("an OpenGroupManager") { + // MARK: - a CommunityManager + describe("a CommunityManager") { // MARK: -- when handling messages context("when handling messages") { beforeEach { + mockCrypto + .when { + try $0.generate( + .decodedMessage( + encodedMessage: Data.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) + ) + ) + } + .thenReturn( + DecodedMessage( + content: Data(base64Encoded: testMessage.base64EncodedData!)!, + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890000 + ) + ) mockStorage.write { db in try testGroupThread.insert(db) try testOpenGroup.insert(db) @@ -1729,7 +1609,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- updates the sequence number when there are messages it("updates the sequence number when there are messages") { mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1747,9 +1627,9 @@ class OpenGroupManagerSpec: QuickSpec { reactions: nil ) ], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1766,12 +1646,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- does not update the sequence number if there are no messages it("does not update the sequence number if there are no messages") { mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1792,7 +1672,7 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1810,23 +1690,39 @@ class OpenGroupManagerSpec: QuickSpec { reactions: nil ) ], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } - // MARK: ---- ignores a message with invalid data - it("ignores a message with invalid data") { + // MARK: ---- ignores a message which fails to decode + it("ignores a message which fails to decode") { + mockCrypto + .when { + try $0.generate( + .decodedMessage( + encodedMessage: Data.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) + ) + ) + } + .thenThrow(MessageError.invalidMessage("Test")) mockStorage.write { db in try Interaction.deleteWhere(db, .deleteAll) } mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1844,9 +1740,9 @@ class OpenGroupManagerSpec: QuickSpec { reactions: nil ) ], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1856,12 +1752,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [testMessage], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1871,7 +1767,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1890,9 +1786,9 @@ class OpenGroupManagerSpec: QuickSpec { ), testMessage, ], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1912,7 +1808,7 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1930,9 +1826,9 @@ class OpenGroupManagerSpec: QuickSpec { reactions: nil ) ], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1942,7 +1838,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ does nothing if we do not have the message it("does nothing if we do not have the message") { mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1960,9 +1856,9 @@ class OpenGroupManagerSpec: QuickSpec { reactions: nil ) ], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1970,27 +1866,36 @@ class OpenGroupManagerSpec: QuickSpec { } } } - + } + + // MARK: - a CommunityManager + describe("a CommunityManager") { // MARK: -- when handling direct messages context("when handling direct messages") { beforeEach { mockCrypto .when { - $0.generate( - .plaintextWithSessionBlindingProtocol( - ciphertext: .any, - senderId: .any, - recipientId: .any, - serverPublicKey: .any + try $0.generate( + .decodedMessage( + encodedMessage: Data.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) ) ) } - .thenReturn(( - plaintext: Data(base64Encoded:"Cg0KC1Rlc3RNZXNzYWdlcNCI7I/3Iw==")! + - Data([0x80]) + - Data([UInt8](repeating: 0, count: 32)), - senderSessionIdHex: "05\(TestConstants.publicKey)" - )) + .thenReturn( + DecodedMessage( + content: Data(base64Encoded: testDirectMessage.base64EncodedMessage)!, + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890000 + ) + ) mockCrypto .when { $0.generate(.x25519(ed25519Pubkey: .any)) } .thenReturn(Data(hex: TestConstants.publicKey).bytes) @@ -1999,12 +1904,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- does nothing if there are no messages it("does nothing if there are no messages") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2026,19 +1931,19 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(0)) } - // MARK: ---- does nothing if it cannot get the open group - it("does nothing if it cannot get the open group") { + // MARK: ---- does nothing if it cannot get the community + it("does nothing if it cannot get the community") { mockStorage.write { db in try OpenGroup.deleteAll(db) } mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2060,8 +1965,8 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beNil()) } - // MARK: ---- ignores messages with non base64 encoded data - it("ignores messages with non base64 encoded data") { + // MARK: ---- ignores messages which fail to decode + it("ignores messages which fail to decode") { testDirectMessage = Network.SOGS.DirectMessage( id: testDirectMessage.id, sender: testDirectMessage.sender.replacingOccurrences(of: "8", with: "9"), @@ -2070,14 +1975,30 @@ class OpenGroupManagerSpec: QuickSpec { expires: testDirectMessage.expires, base64EncodedMessage: "TestMessage%%%" ) + mockCrypto + .when { + try $0.generate( + .decodedMessage( + encodedMessage: Data.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) + ) + ) + } + .thenThrow(MessageError.invalidMessage("Test")) mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2095,12 +2016,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ updates the inbox latest message id it("updates the inbox latest message id") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2118,27 +2039,35 @@ class OpenGroupManagerSpec: QuickSpec { it("ignores a message with invalid data") { mockCrypto .when { - $0.generate( - .plaintextWithSessionBlindingProtocol( - ciphertext: .any, - senderId: .any, - recipientId: .any, - serverPublicKey: .any + try $0.generate( + .decodedMessage( + encodedMessage: Data.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) ) ) } - .thenReturn(( - plaintext: Data("TestInvalid".bytes), - senderSessionIdHex: "05\(TestConstants.publicKey)" - )) + .thenReturn( + DecodedMessage( + content: Data("TestInvalid".bytes), + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890000 + ) + ) mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2148,12 +2077,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2163,7 +2092,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [ Network.SOGS.DirectMessage( @@ -2177,8 +2106,8 @@ class OpenGroupManagerSpec: QuickSpec { testDirectMessage ], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2197,12 +2126,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ updates the outbox latest message id it("updates the outbox latest message id") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2228,12 +2157,12 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2244,12 +2173,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ falls back to using the blinded id if no lookup is found it("falls back to using the blinded id if no lookup is found") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2270,31 +2199,32 @@ class OpenGroupManagerSpec: QuickSpec { ).toNot(beNil()) } - // MARK: ------ ignores a message with invalid data - it("ignores a message with invalid data") { + // MARK: ------ ignores a messages which fail to decode + it("ignores a messages which fail to decode") { mockCrypto .when { - $0.generate( - .plaintextWithSessionBlindingProtocol( - ciphertext: .any, - senderId: .any, - recipientId: .any, - serverPublicKey: .any + try $0.generate( + .decodedMessage( + encodedMessage: Data.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) ) ) } - .thenReturn(( - plaintext: Data("TestInvalid".bytes), - senderSessionIdHex: "05\(TestConstants.publicKey)" - )) + .thenThrow(MessageError.invalidMessage("Test")) mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2304,12 +2234,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2319,7 +2249,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [ Network.SOGS.DirectMessage( @@ -2333,8 +2263,8 @@ class OpenGroupManagerSpec: QuickSpec { testDirectMessage ], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2344,207 +2274,233 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - an OpenGroupManager - describe("an OpenGroupManager") { + // MARK: - a CommunityManager + describe("a CommunityManager") { // MARK: -- when determining if a user is a moderator or an admin context("when determining if a user is a moderator or an admin") { beforeEach { - mockStorage.write { db in - _ = try GroupMember.deleteAll(db) - } + await communityManager.updateServer( + server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey, + openGroups: [testOpenGroup], + capabilities: nil, + roomMembers: nil, + using: dependencies + ) + ) } // MARK: ---- has no moderators by default it("has no moderators by default") { - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beFalse()) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey)", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beFalse()) } // MARK: ----has no admins by default it("has no admins by default") { - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beFalse()) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey)", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beFalse()) } // MARK: ---- returns true if the key is in the moderator set it("returns true if the key is in the moderator set") { - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - role: .moderator, - roleStatus: .accepted, - isHidden: false - ).insert(db) - } + await communityManager.updateServer( + server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey, + openGroups: [testOpenGroup], + capabilities: nil, + roomMembers: [ + "testRoom": [ + GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + role: .moderator, + roleStatus: .accepted, + isHidden: false + ) + ] + ], + using: dependencies + ) + ) - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beTrue()) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beTrue()) } // MARK: ---- returns true if the key is in the admin set it("returns true if the key is in the admin set") { - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - role: .admin, - roleStatus: .accepted, - isHidden: false - ).insert(db) - } + await communityManager.updateServer( + server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey, + openGroups: [testOpenGroup], + capabilities: nil, + roomMembers: [ + "testRoom": [ + GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + role: .admin, + roleStatus: .accepted, + isHidden: false + ) + ] + ], + using: dependencies + ) + ) - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beTrue()) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beTrue()) } // MARK: ---- returns true if the moderator is hidden it("returns true if the moderator is hidden") { - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - role: .moderator, - roleStatus: .accepted, - isHidden: true - ).insert(db) - } + await communityManager.updateServer( + server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey, + openGroups: [testOpenGroup], + capabilities: nil, + roomMembers: [ + "testRoom": [ + GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + role: .moderator, + roleStatus: .accepted, + isHidden: true + ) + ] + ], + using: dependencies + ) + ) - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beTrue()) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beTrue()) } // MARK: ---- returns true if the admin is hidden it("returns true if the admin is hidden") { - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - role: .admin, - roleStatus: .accepted, - isHidden: true - ).insert(db) - } + await communityManager.updateServer( + server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey, + openGroups: [testOpenGroup], + capabilities: nil, + roomMembers: [ + "testRoom": [ + GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + role: .admin, + roleStatus: .accepted, + isHidden: true + ) + ] + ], + using: dependencies + ) + ) - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beTrue()) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beTrue()) } - // MARK: ---- returns false if the key is not a valid session id - it("returns false if the key is not a valid session id") { - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "InvalidValue", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beFalse()) + // MARK: ---- returns false if the key is not an admin or moderator + it("returns false if the key is not an admin or moderator") { + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "InvalidValue", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beFalse()) } // MARK: ---- and the key belongs to the current user context("and the key belongs to the current user") { // MARK: ------ matches a blinded key - it("matches a blinded key ") { - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "15\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - role: .admin, - roleStatus: .accepted, - isHidden: true - ).insert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: [ - "05\(TestConstants.publicKey)", - "15\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))" - ] + it("matches a blinded key") { + mockCrypto.removeMocksFor { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } + mockCrypto + .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))), + secretKey: Array(Data(hex: TestConstants.edSecretKey.replacingOccurrences(of: "1", with: "2"))) ) - } - ).to(beTrue()) - } - - // MARK: ------ generates and unblinded key if the key belongs to the current user - it("generates and unblinded key if the key belongs to the current user") { - mockGeneralCache.when { $0.ed25519Seed }.thenReturn([4, 5, 6]) - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) - } + await communityManager.updateServer( + server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey, + openGroups: [testOpenGroup], + capabilities: [.blind], + roomMembers: [ + "testRoom": [ + GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), + profileId: "15\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + role: .admin, + roleStatus: .accepted, + isHidden: true + ) + ] + ], + using: dependencies + ) + ) - expect(mockCrypto).to(call(.exactly(times: 2), matchingParameters: .all) { - $0.generate(.ed25519KeyPair(seed: [4, 5, 6])) - }) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey)", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beTrue()) } } } @@ -2561,7 +2517,7 @@ class OpenGroupManagerSpec: QuickSpec { server: Network.SOGS.defaultServer, roomToken: "", publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, + shouldPoll: false, name: "TestExisting", userCount: 0, infoUpdates: 0 @@ -2570,7 +2526,7 @@ class OpenGroupManagerSpec: QuickSpec { } let expectedRequest: Network.PreparedRequest! = mockStorage.read { db in try Network.SOGS.preparedCapabilitiesAndRooms( - authMethod: Authentication.community( + authMethod: Authentication.Community( info: LibSession.OpenGroupCapabilityInfo( roomToken: "", server: Network.SOGS.defaultServer, @@ -2582,10 +2538,10 @@ class OpenGroupManagerSpec: QuickSpec { using: dependencies ) } - cache.defaultRoomsPublisher.sinkUntilComplete() + await communityManager.fetchDefaultRoomsIfNeeded() - expect(mockNetwork) - .to(call { network in + await expect(mockNetwork) + .toEventually(call { network in network.send( endpoint: Network.SOGS.Endpoint.sequence, destination: expectedRequest.destination, @@ -2599,8 +2555,13 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- does not start a job to retrieve the default rooms if we already have rooms it("does not start a job to retrieve the default rooms if we already have rooms") { mockAppGroupDefaults.when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) }.thenReturn(true) - cache.setDefaultRoomInfo([(room: Network.SOGS.Room.mock, openGroup: OpenGroup.mock)]) - cache.defaultRoomsPublisher.sinkUntilComplete() + await communityManager.updateRooms( + rooms: [Network.SOGS.Room.mock], + server: "http://127.0.0.1", + publicKey: Network.SOGS.defaultServerPublicKey, + areDefaultRooms: true + ) + await communityManager.fetchDefaultRoomsIfNeeded() expect(mockNetwork) .toNot(call { @@ -2693,7 +2654,7 @@ extension OpenGroup: Mocked { server: "testserver", roomToken: "testRoom", publicKey: TestConstants.serverPublicKey, - isActive: true, + shouldPoll: true, name: "testRoom", userCount: 0, infoUpdates: 0 diff --git a/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift b/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift index 7b04fa35a1..a819d7f98a 100644 --- a/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift @@ -23,82 +23,6 @@ class CryptoOpenGroupSpec: QuickSpec { // MARK: - Crypto for Open Group describe("Crypto for Open Group") { - // MARK: -- when encrypting with the session blinding protocol - context("when encrypting with the session blinding protocol") { - // MARK: ---- can encrypt for a blind15 recipient correctly - it("can encrypt for a blind15 recipient correctly") { - let result: Data? = try? crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey - ) - ) - - // Note: A Nonce is used for this so we can't compare the exact value when not mocked - expect(result).toNot(beNil()) - expect(result?.count).to(equal(84)) - } - - // MARK: ---- can encrypt for a blind25 recipient correctly - it("can encrypt for a blind25 recipient correctly") { - let result: Data? = try? crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "25\(TestConstants.blind25PublicKey)", - serverPublicKey: TestConstants.serverPublicKey - ) - ) - - // Note: A Nonce is used for this so we can't compare the exact value when not mocked - expect(result).toNot(beNil()) - expect(result?.count).to(equal(84)) - } - - // MARK: ---- includes a version at the start of the encrypted value - it("includes a version at the start of the encrypted value") { - let result: Data? = try? crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey - ) - ) - - expect(result?.toHexString().prefix(2)).to(equal("00")) - } - - // MARK: ---- throws an error if the recipient isn't a blinded id - it("throws an error if the recipient isn't a blinded id") { - expect { - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "05\(TestConstants.publicKey)", - serverPublicKey: TestConstants.serverPublicKey - ) - ) - } - .to(throwError(MessageSenderError.encryptionFailed)) - } - - // MARK: ---- throws an error if there is no ed25519 keyPair - it("throws an error if there is no ed25519 keyPair") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - - expect { - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey - ) - ) - } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) - } - } - // MARK: -- when decrypting with the session blinding protocol context("when decrypting with the session blinding protocol") { // MARK: ---- can decrypt a blind15 message correctly @@ -155,7 +79,7 @@ class CryptoOpenGroupSpec: QuickSpec { ) ) } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + .to(throwError(CryptoError.missingUserSecretKey)) } // MARK: ---- throws an error if the data is too short @@ -170,7 +94,7 @@ class CryptoOpenGroupSpec: QuickSpec { ) ) } - .to(throwError(MessageReceiverError.decryptionFailed)) + .to(throwError(MessageError.decodingFailed)) } // MARK: ---- throws an error if the data version is not 0 @@ -189,7 +113,7 @@ class CryptoOpenGroupSpec: QuickSpec { ) ) } - .to(throwError(MessageReceiverError.decryptionFailed)) + .to(throwError(MessageError.decodingFailed)) } // MARK: ---- throws an error if it cannot decrypt the data @@ -204,7 +128,7 @@ class CryptoOpenGroupSpec: QuickSpec { ) ) } - .to(throwError(MessageReceiverError.decryptionFailed)) + .to(throwError(MessageError.decodingFailed)) } // MARK: ---- throws an error if the inner bytes are too short @@ -223,7 +147,7 @@ class CryptoOpenGroupSpec: QuickSpec { ) ) } - .to(throwError(MessageReceiverError.decryptionFailed)) + .to(throwError(MessageError.decodingFailed)) } } } diff --git a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift index 325689d553..9473cd9e4b 100644 --- a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift @@ -19,7 +19,7 @@ class OpenGroupSpec: QuickSpec { server: "server", roomToken: "room", publicKey: "1234", - isActive: true, + shouldPoll: true, name: "name", roomDescription: nil, imageId: nil, @@ -42,7 +42,7 @@ class OpenGroupSpec: QuickSpec { server: "server", roomToken: "room", publicKey: "1234", - isActive: true, + shouldPoll: true, name: "name", roomDescription: nil, imageId: nil, @@ -66,7 +66,7 @@ class OpenGroupSpec: QuickSpec { server: "server", roomToken: "room", publicKey: "1234", - isActive: true, + shouldPoll: true, name: "name", roomDescription: nil, imageId: nil, @@ -84,7 +84,7 @@ class OpenGroupSpec: QuickSpec { roomToken: \"room\", id: \"server.room\", publicKey: \"1234\", - isActive: true, + shouldPoll: true, name: \"name\", roomDescription: null, imageId: null, diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index 25902e1a88..c200dfea5e 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -39,7 +39,15 @@ class MessageReceiverGroupsSpec: QuickSpec { try Profile( id: "05\(TestConstants.publicKey)", - name: "TestCurrentUser" + name: "TestCurrentUser", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ).insert(db) } ) @@ -113,7 +121,7 @@ class MessageReceiverGroupsSpec: QuickSpec { crypto .when { $0.verify(.signature(message: .any, publicKey: .any, signature: .any)) } .thenReturn(true) - crypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(groupKeyPair) + crypto.when { $0.generate(.ed25519KeyPair(seed: Array.any)) }.thenReturn(groupKeyPair) crypto .when { $0.verify(.memberAuthData(groupSessionId: .any, ed25519SecretKey: .any, memberAuthData: .any)) } .thenReturn(true) @@ -248,6 +256,17 @@ class MessageReceiverGroupsSpec: QuickSpec { ) // MARK: -- Messages + @TestState var decodedMessage: DecodedMessage! = DecodedMessage( + content: Data( + base64Encoded: "CAESvwEKABIAGrYBCAYSACjQiOyP9yM4AUKmAfjX/WXVFs+QE5Eh54Esw9/N" + + "lYza3k8MOvcRAI7y8k0JzLsm/KpXxKP7Zx7+5YyII9sCRXzFK2U4/X9SSMN088YEr/5wKoDfL5q" + + "PQbN70aa59WS8YE+yWcniQO0KXfAzr6Acn40fsa9BMr9tnQLfvxY8vD7qBz9iEOV9jTxPzxUoD+" + + "JelIbsv2qlkOl9vs166NC/Y772NZmUAR5u1ewL4SYEWkqX5R4gAA==" + )!, + sender: SessionId(.standard, hex: "1111111111111111111111111111111111111111111111111111111111111111"), + decodedEnvelope: nil, + sentTimestampMs: 1234567890000 + ) @TestState var inviteMessage: GroupUpdateInviteMessage! = { let result: GroupUpdateInviteMessage = GroupUpdateInviteMessage( inviteeSessionIdHexString: "TestId", @@ -370,8 +389,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -392,8 +413,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -422,8 +445,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -466,8 +491,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -504,8 +531,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -523,8 +552,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -543,8 +574,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -575,8 +608,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -595,8 +630,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -616,8 +653,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -642,8 +681,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -697,8 +738,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -720,8 +763,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -748,8 +793,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -769,8 +816,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -794,8 +843,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -898,8 +949,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -930,6 +983,9 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -------- subscribes for push notifications it("subscribes for push notifications") { + mockLibSessionCache + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData(groupIdentityPrivateKey: nil, authData: Data([1, 2, 3]))) let expectedRequest: Network.PreparedRequest = mockStorage.write { db in _ = try SessionThread.upsert( db, @@ -976,8 +1032,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1004,8 +1062,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1037,8 +1097,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1095,29 +1157,31 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- fails if it cannot convert the group seed to a groupIdentityKeyPair it("fails if it cannot convert the group seed to a groupIdentityKeyPair") { - mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(nil) + mockCrypto + .when { try $0.tryGenerate(.ed25519KeyPair(seed: Array.any)) } + .thenThrow(TestError.mock) mockStorage.write { db in - result = Result(catching: { + expect { try MessageReceiver.handleGroupUpdateMessage( db, threadId: groupId.hexString, threadVariant: .group, message: promoteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }) + }.to(throwError(TestError.mock)) } - - expect(result.failure).to(matchError(MessageReceiverError.invalidMessage)) } // MARK: ---- updates the GROUP_KEYS state correctly it("updates the GROUP_KEYS state correctly") { mockCrypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn(KeyPair(publicKey: [1, 2, 3], secretKey: [4, 5, 6])) mockStorage.write { db in @@ -1126,8 +1190,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: promoteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1160,8 +1226,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: promoteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1176,6 +1244,16 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving an info changed message context("when receiving an info changed message") { beforeEach { + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: infoChangedMessage.sentTimestampMs! + ) + mockStorage.write { db in try SessionThread.upsert( db, @@ -1190,44 +1268,6 @@ class MessageReceiverGroupsSpec: QuickSpec { } } - // MARK: ---- throws if there is no sender - it("throws if there is no sender") { - infoChangedMessage.sender = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: infoChangedMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - using: dependencies - ) - }.to(throwError(MessageReceiverError.invalidMessage)) - } - } - - // MARK: ---- throws if there is no timestamp - it("throws if there is no timestamp") { - infoChangedMessage.sentTimestampMs = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: infoChangedMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - using: dependencies - ) - }.to(throwError(MessageReceiverError.invalidMessage)) - } - } - // MARK: ---- throws if the admin signature fails to verify it("throws if the admin signature fails to verify") { mockCrypto @@ -1241,11 +1281,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: infoChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -1259,8 +1301,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: infoChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1286,6 +1330,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) infoChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" infoChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) } // MARK: ------ creates the correct control message @@ -1296,8 +1349,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: infoChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1323,6 +1378,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) infoChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" infoChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) } // MARK: ------ creates the correct control message @@ -1333,8 +1397,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: infoChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1361,6 +1427,16 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving a member changed message context("when receiving a member changed message") { beforeEach { + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: memberChangedMessage.sentTimestampMs! + ) + mockStorage.write { db in try SessionThread.upsert( db, @@ -1375,44 +1451,6 @@ class MessageReceiverGroupsSpec: QuickSpec { } } - // MARK: ---- throws if there is no sender - it("throws if there is no sender") { - memberChangedMessage.sender = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: memberChangedMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - using: dependencies - ) - }.to(throwError(MessageReceiverError.invalidMessage)) - } - } - - // MARK: ---- throws if there is no timestamp - it("throws if there is no timestamp") { - memberChangedMessage.sentTimestampMs = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: memberChangedMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - using: dependencies - ) - }.to(throwError(MessageReceiverError.invalidMessage)) - } - } - // MARK: ---- throws if the admin signature fails to verify it("throws if the admin signature fails to verify") { mockCrypto @@ -1426,11 +1464,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -1439,7 +1479,15 @@ class MessageReceiverGroupsSpec: QuickSpec { mockStorage.write { db in try Profile( id: "051111111111111111111111111111111111111111111111111111111111111112", - name: "TestOtherProfile" + name: "TestOtherProfile", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ).insert(db) } @@ -1449,8 +1497,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1485,8 +1535,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1524,8 +1576,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1557,6 +1611,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1564,8 +1627,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1598,6 +1663,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1605,8 +1679,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1633,6 +1709,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1640,8 +1725,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1669,6 +1756,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1676,8 +1772,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1706,6 +1804,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1713,8 +1820,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1741,6 +1850,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1748,8 +1866,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1777,6 +1897,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1784,8 +1913,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1826,8 +1957,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1836,28 +1969,9 @@ class MessageReceiverGroupsSpec: QuickSpec { expect(interactions).to(beEmpty()) } - // MARK: ---- throws if there is no sender - it("throws if there is no sender") { - memberLeftMessage.sender = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: memberLeftMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - using: dependencies - ) - }.to(throwError(MessageReceiverError.invalidMessage)) - } - } - - // MARK: ---- throws if there is no timestamp - it("throws if there is no timestamp") { - memberLeftMessage.sentTimestampMs = nil + // MARK: ---- throws if the current user is not an admin + it("throws if the current user is not an admin") { + mockLibSessionCache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(false) mockStorage.write { db in expect { @@ -1866,11 +1980,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.ignorableMessage)) } } @@ -1884,6 +2000,13 @@ class MessageReceiverGroupsSpec: QuickSpec { groupMember.set(\.name, to: "TestOtherName") groups_members_set(groupMembersConf, &groupMember) + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: try! SessionId(from: memberLeftMessage.sender!), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: memberLeftMessage.sentTimestampMs! + ) + mockStorage.write { db in try ClosedGroup( threadId: groupId.hexString, @@ -1913,8 +2036,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1933,8 +2058,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1952,8 +2079,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1982,8 +2111,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1997,7 +2128,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, interactionId: nil, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupId.hexString), + destination: .group(publicKey: groupId.hexString), message: try! GroupUpdateMemberChangeMessage( changeType: .removed, memberSessionIds: [ @@ -2023,6 +2154,13 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving a member left notification message context("when receiving a member left notification message") { beforeEach { + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: try! SessionId(from: memberLeftNotificationMessage.sender!), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: memberLeftNotificationMessage.sentTimestampMs! + ) + mockStorage.write { db in try SessionThread.upsert( db, @@ -2045,8 +2183,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftNotificationMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2065,7 +2205,15 @@ class MessageReceiverGroupsSpec: QuickSpec { mockStorage.write { db in try Profile( id: "051111111111111111111111111111111111111111111111111111111111111112", - name: "TestOtherProfile" + name: "TestOtherProfile", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ).insert(db) } @@ -2075,8 +2223,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftNotificationMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2108,28 +2258,9 @@ class MessageReceiverGroupsSpec: QuickSpec { } } - // MARK: ---- throws if there is no sender - it("throws if there is no sender") { - inviteResponseMessage.sender = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: inviteResponseMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - using: dependencies - ) - }.to(throwError(MessageReceiverError.invalidMessage)) - } - } - - // MARK: ---- throws if there is no timestamp - it("throws if there is no timestamp") { - inviteResponseMessage.sentTimestampMs = nil + // MARK: ---- throws if the message isn't an approval + it("throws if the message isn't an approval") { + inviteResponseMessage.isApproved = false mockStorage.write { db in expect { @@ -2138,11 +2269,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.ignorableMessage)) } } @@ -2154,8 +2287,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2179,6 +2314,13 @@ class MessageReceiverGroupsSpec: QuickSpec { groupMember.invited = 1 groups_members_set(groupMembersConf, &groupMember) + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: try! SessionId(from: inviteResponseMessage.sender!), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: inviteResponseMessage.sentTimestampMs! + ) + mockStorage.write { db in try ClosedGroup( threadId: groupId.hexString, @@ -2210,8 +2352,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2254,8 +2398,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2286,8 +2432,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2310,8 +2458,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2361,7 +2511,8 @@ class MessageReceiverGroupsSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Interaction( @@ -2386,7 +2537,8 @@ class MessageReceiverGroupsSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Interaction( @@ -2411,7 +2563,8 @@ class MessageReceiverGroupsSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Interaction( @@ -2436,7 +2589,8 @@ class MessageReceiverGroupsSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) } } @@ -2457,30 +2611,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) - } - } - - // MARK: ---- throws if there is no timestamp - it("throws if there is no timestamp") { - deleteContentMessage.sentTimestampMs = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: deleteContentMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - using: dependencies - ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -2497,11 +2634,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -2523,8 +2662,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2552,8 +2693,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2579,8 +2722,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2626,8 +2771,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2676,8 +2823,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2705,8 +2854,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2732,8 +2883,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2761,8 +2914,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2791,8 +2946,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2848,6 +3005,18 @@ class MessageReceiverGroupsSpec: QuickSpec { ) deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" deleteContentMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111112" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) + mockLibSessionCache + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData(groupIdentityPrivateKey: Data([1, 2, 3]), authData: nil)) let preparedRequest: Network.PreparedRequest<[String: Bool]> = try! Network.SnodeAPI .preparedDeleteMessages( @@ -2866,8 +3035,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2903,8 +3074,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2994,7 +3167,8 @@ class MessageReceiverGroupsSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) try ConfigDump( @@ -3126,6 +3300,9 @@ class MessageReceiverGroupsSpec: QuickSpec { mockUserDefaults .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(true) + mockLibSessionCache + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData(groupIdentityPrivateKey: nil, authData: Data([1, 2, 3]))) let expectedRequest: Network.PreparedRequest = mockStorage.read { db in try Network.PushNotification.preparedUnsubscribe( @@ -3329,7 +3506,7 @@ class MessageReceiverGroupsSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiverError.invalidMessage)) + .to(throwError(MessageError.invalidMessage("Test"))) } } @@ -3349,7 +3526,7 @@ class MessageReceiverGroupsSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiverError.invalidMessage)) + .to(throwError(MessageError.ignorableMessage)) } } @@ -3369,7 +3546,7 @@ class MessageReceiverGroupsSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiverError.invalidMessage)) + .to(throwError(MessageError.ignorableMessage)) } } } @@ -3385,6 +3562,16 @@ class MessageReceiverGroupsSpec: QuickSpec { groupMember.invited = 1 groups_members_set(groupMembersConf, &groupMember) + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: try! SessionId(from: visibleMessage.sender!), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: visibleMessage.sentTimestampMs! + ) + mockLibSessionCache + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData(groupIdentityPrivateKey: Data([1, 2, 3]), authData: nil)) + mockStorage.write { db in try SessionThread.upsert( db, @@ -3427,9 +3614,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: visibleMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: nil, - associatedWithProto: visibleMessageProto, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -3472,9 +3660,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: visibleMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: nil, - associatedWithProto: visibleMessageProto, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -3505,9 +3694,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: visibleMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: nil, - associatedWithProto: visibleMessageProto, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift index b58857dd04..778a3153fe 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift @@ -46,7 +46,15 @@ class MessageSenderGroupsSpec: AsyncSpec { try Profile( id: "05\(TestConstants.publicKey)", - name: "TestCurrentUser" + name: "TestCurrentUser", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ).insert(db) } ) @@ -114,7 +122,7 @@ class MessageSenderGroupsSpec: AsyncSpec { ) ) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Data(hex: groupId.hexString).bytes, @@ -149,11 +157,24 @@ class MessageSenderGroupsSpec: AsyncSpec { .when { $0.generate(.legacyEncryptedDisplayPicture(data: .any, key: .any)) } .thenReturn(TestConstants.validImageData) crypto - .when { $0.generate(.ciphertextForGroupMessage(groupSessionId: .any, message: .any)) } + .when { + try $0.generate( + .encodedMessage( + plaintext: Array.any, + proMessageFeatures: .any, + proProfileFeatures: .any, + destination: .any, + sentTimestampMs: .any + ) + ) + } .thenReturn("TestGroupMessageCiphertext".data(using: .utf8)!) crypto .when { $0.generate(.hash(message: .any)) } .thenReturn(Array(Data(hex: "01010101010101010101010101010101"))) + crypto + .when { $0.generate(.signatureSubaccount(config: .any, verificationBytes: .any, memberAuthData: .any)) } + .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) } ) @TestState(singleton: .keychain, in: dependencies) var mockKeychain: MockKeychain! = MockKeychain( @@ -234,6 +255,9 @@ class MessageSenderGroupsSpec: AsyncSpec { cache .when { try $0.pendingPushes(swarmPublicKey: .any) } .thenReturn(LibSession.PendingPushes(obsoleteHashes: ["testHash"])) + cache + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData(groupIdentityPrivateKey: nil, authData: Data([1, 2, 3]))) } ) @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( @@ -461,7 +485,7 @@ class MessageSenderGroupsSpec: AsyncSpec { ] ) ) - let expectedRequest: Network.PreparedRequest = mockStorage.write { db in + let expectedRequest: Network.PreparedRequest? = mockStorage.write { db in // Need the auth data to exist in the database to prepare the request _ = try SessionThread.upsert( db, @@ -494,7 +518,6 @@ class MessageSenderGroupsSpec: AsyncSpec { ), in: ConfigDump.Variant.groupInfo.namespace, authMethod: try Authentication.with( - db, swarmPublicKey: groupId.hexString, using: dependencies ), @@ -513,7 +536,8 @@ class MessageSenderGroupsSpec: AsyncSpec { try SessionThread.filter(id: groupId.hexString).deleteAll(db) return preparedRequest - }! + } + try require(expectedRequest).toNot(beNil()) let result = await Result { try await MessageSender.createGroup( @@ -533,10 +557,10 @@ class MessageSenderGroupsSpec: AsyncSpec { .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( endpoint: Network.SnodeAPI.Endpoint.sequence, - destination: expectedRequest.destination, - body: expectedRequest.body, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + destination: expectedRequest!.destination, + body: expectedRequest!.body, + requestTimeout: expectedRequest!.requestTimeout, + requestAndPathBuildTimeout: expectedRequest!.requestAndPathBuildTimeout ) }) } @@ -1275,7 +1299,7 @@ class MessageSenderGroupsSpec: AsyncSpec { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: groupId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupId.hexString), + destination: .group(publicKey: groupId.hexString), message: try GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: [ @@ -1477,7 +1501,7 @@ class MessageSenderGroupsSpec: AsyncSpec { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: groupId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupId.hexString), + destination: .group(publicKey: groupId.hexString), message: try GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: [ @@ -1522,7 +1546,7 @@ class MessageSenderGroupsSpec: AsyncSpec { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: groupId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupId.hexString), + destination: .group(publicKey: groupId.hexString), message: try GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: [ diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift index ee73e9fbc0..c56c1ea5f0 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift @@ -33,7 +33,7 @@ class MessageSenderSpec: QuickSpec { .when { $0.generate(.randomBytes(24)) } .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), @@ -61,7 +61,15 @@ class MessageSenderSpec: QuickSpec { beforeEach { mockCrypto .when { - $0.generate(.ciphertextWithSessionProtocol(plaintext: .any, destination: .any)) + try $0.generate( + .encodedMessage( + plaintext: Array.any, + proMessageFeatures: .any, + proProfileFeatures: .any, + destination: .any, + sentTimestampMs: .any + ) + ) } .thenReturn(Data([1, 2, 3])) mockCrypto diff --git a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift index 4ef4c200d4..a73ee50536 100644 --- a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift @@ -73,7 +73,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidSender)) + }.to(throwError(MessageError.invalidSender)) } // MARK: -- throws if the message was sent to note to self @@ -91,7 +91,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.selfSend)) + }.to(throwError(MessageError.selfSend)) } // MARK: -- throws if the message was sent by the current user @@ -115,7 +115,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.selfSend)) + }.to(throwError(MessageError.selfSend)) } // MARK: -- throws if notifications are muted @@ -138,7 +138,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } // MARK: -- throws if the message is not an incoming message @@ -156,7 +156,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } // MARK: -- for mentions only @@ -185,7 +185,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } // MARK: ---- does not throw if the current user is mentioned @@ -296,7 +296,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) expect { try mockNotificationsManager.ensureWeShouldShowNotification( message: message, @@ -315,7 +315,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) expect { try mockNotificationsManager.ensureWeShouldShowNotification( message: message, @@ -334,7 +334,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } } @@ -380,7 +380,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) expect { try mockNotificationsManager.ensureWeShouldShowNotification( message: message, @@ -394,7 +394,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) expect { try mockNotificationsManager.ensureWeShouldShowNotification( message: message, @@ -408,7 +408,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } // MARK: ---- throws if the message is not a preOffer @@ -434,7 +434,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } // MARK: ---- throws for the expected states @@ -445,7 +445,7 @@ class NotificationsManagerSpec: QuickSpec { let stateToError: [String: String] = CallMessage.MessageInfo.State.allCases .filter { !nonThrowingStates.contains($0) } .reduce(into: [:]) { result, next in - result["\(next)"] = "\(MessageReceiverError.ignorableMessage)" + result["\(next)"] = "\(MessageError.ignorableMessage)" } var result: [String: String] = [:] @@ -569,7 +569,7 @@ class NotificationsManagerSpec: QuickSpec { expect(Message.Variant.allCases.count - nonThrowingMessageTypes.count).to(equal(throwingMessages.count)) let messageTypeNameToError: [String: String] = throwingMessages .reduce(into: [:]) { result, next in - result["\(type(of: next))"] = "\(MessageReceiverError.ignorableMessage)" + result["\(type(of: next))"] = "\(MessageError.ignorableMessage)" } var result: [String: String] = [:] @@ -612,7 +612,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.senderBlocked)) + }.to(throwError(MessageError.senderBlocked)) } // MARK: -- throws if the message was already read @@ -640,7 +640,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } // MARK: -- throws if the message was sent to a message request and we should not show @@ -658,7 +658,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { false }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessageRequestMessage)) + }.to(throwError(MessageError.ignorableMessageRequestMessage)) } // MARK: -- does not throw if the message was sent to a message request and we should show @@ -895,7 +895,7 @@ class NotificationsManagerSpec: QuickSpec { groupNameRetriever: { _, _ in nil }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } } @@ -1312,7 +1312,7 @@ class NotificationsManagerSpec: QuickSpec { return false } ) - }.to(throwError(MessageReceiverError.ignorableMessageRequestMessage)) + }.to(throwError(MessageError.ignorableMessageRequestMessage)) expect(didCallShouldShowForMessageRequest).to(beTrue()) } diff --git a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift index 1433cbab66..2136fe17dd 100644 --- a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift @@ -33,7 +33,7 @@ class CommunityPollerSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test", roomDescription: nil, imageId: nil, @@ -44,7 +44,7 @@ class CommunityPollerSpec: AsyncSpec { server: "testServer1", roomToken: "testRoom1", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test1", roomDescription: nil, imageId: nil, @@ -89,10 +89,10 @@ class CommunityPollerSpec: AsyncSpec { .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) } ) - @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( - initialSetup: { cache in - cache.when { $0.pendingChanges }.thenReturn([]) - cache.when { $0.getLastSuccessfulCommunityPollTimestamp() }.thenReturn(0) + @TestState(singleton: .communityManager, in: dependencies) var mockCommunityManager: MockCommunityManager! = MockCommunityManager( + initialSetup: { manager in + manager.when { await $0.pendingChanges }.thenReturn([]) + manager.when { await $0.getLastSuccessfulCommunityPollTimestamp() }.thenReturn(0) } ) @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( @@ -115,7 +115,7 @@ class CommunityPollerSpec: AsyncSpec { .when { $0.generate(.randomBytes(16)) } .thenReturn(Array(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!)) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), diff --git a/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift b/SessionMessagingKitTests/Types/GlobalSearchSpec.swift similarity index 87% rename from SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift rename to SessionMessagingKitTests/Types/GlobalSearchSpec.swift index 38cc13e864..c77f661d0f 100644 --- a/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift +++ b/SessionMessagingKitTests/Types/GlobalSearchSpec.swift @@ -1,4 +1,4 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB @@ -8,7 +8,7 @@ import SessionUtilitiesKit @testable import SessionMessagingKit -class SessionThreadViewModelSpec: QuickSpec { +class GlobalSearchSpec: QuickSpec { override class func spec() { // MARK: Configuration @@ -30,25 +30,25 @@ class SessionThreadViewModelSpec: QuickSpec { } ) - // MARK: - a SessionThreadViewModel - describe("a SessionThreadViewModel") { + // MARK: - GlobalSearch + describe("GlobalSearch") { // MARK: -- when processing a search term context("when processing a search term") { // MARK: ---- correctly generates a safe search term it("correctly generates a safe search term") { - expect(SessionThreadViewModel.searchSafeTerm("Test")).to(equal("\"Test\"")) + expect(GlobalSearch.searchSafeTerm("Test")).to(equal("\"Test\"")) } // MARK: ---- standardises odd quote characters it("standardises odd quote characters") { - expect(SessionThreadViewModel.standardQuotes("\"")).to(equal("\"")) - expect(SessionThreadViewModel.standardQuotes("”")).to(equal("\"")) - expect(SessionThreadViewModel.standardQuotes("“")).to(equal("\"")) + expect(GlobalSearch.standardQuotes("\"")).to(equal("\"")) + expect(GlobalSearch.standardQuotes("”")).to(equal("\"")) + expect(GlobalSearch.standardQuotes("“")).to(equal("\"")) } // MARK: ---- splits on the space character it("splits on the space character") { - expect(SessionThreadViewModel.searchTermParts("Test Message")) + expect(GlobalSearch.searchTermParts("Test Message")) .to(equal([ "\"Test\"", "\"Message\"" @@ -57,7 +57,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- surrounds each split term with quotes it("surrounds each split term with quotes") { - expect(SessionThreadViewModel.searchTermParts("Test Message")) + expect(GlobalSearch.searchTermParts("Test Message")) .to(equal([ "\"Test\"", "\"Message\"" @@ -66,32 +66,32 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- keeps words within quotes together it("keeps words within quotes together") { - expect(SessionThreadViewModel.searchTermParts("This ”is a Test“ Message")) + expect(GlobalSearch.searchTermParts("This ”is a Test“ Message")) .to(equal([ "\"This\"", "\"is a Test\"", "\"Message\"" ])) - expect(SessionThreadViewModel.searchTermParts("\"This is\" a Test Message")) + expect(GlobalSearch.searchTermParts("\"This is\" a Test Message")) .to(equal([ "\"This is\"", "\"a\"", "\"Test\"", "\"Message\"" ])) - expect(SessionThreadViewModel.searchTermParts("\"This is\" \"a Test\" Message")) + expect(GlobalSearch.searchTermParts("\"This is\" \"a Test\" Message")) .to(equal([ "\"This is\"", "\"a Test\"", "\"Message\"" ])) - expect(SessionThreadViewModel.searchTermParts("\"This is\" a \"Test Message\"")) + expect(GlobalSearch.searchTermParts("\"This is\" a \"Test Message\"")) .to(equal([ "\"This is\"", "\"a\"", "\"Test Message\"" ])) - expect(SessionThreadViewModel.searchTermParts("\"This is\"\" a \"Test Message")) + expect(GlobalSearch.searchTermParts("\"This is\"\" a \"Test Message")) .to(equal([ "\"This is\"", "\" a \"", @@ -102,7 +102,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- keeps words within weird quotes together it("keeps words within weird quotes together") { - expect(SessionThreadViewModel.searchTermParts("This \"is a Test\" Message")) + expect(GlobalSearch.searchTermParts("This \"is a Test\" Message")) .to(equal([ "\"This\"", "\"is a Test\"", @@ -112,7 +112,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- removes extra whitespace it("removes extra whitespace") { - expect(SessionThreadViewModel.searchTermParts(" Test Message ")) + expect(GlobalSearch.searchTermParts(" Test Message ")) .to(equal([ "\"Test\"", "\"Message\"" @@ -146,7 +146,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- returns results it("returns results") { let results = mockStorage.read { db in - let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + let pattern: FTS5Pattern = try GlobalSearch.pattern( db, searchTerm: "Message", forTable: TestMessage.self @@ -176,7 +176,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- adds a wildcard to the final part it("adds a wildcard to the final part") { let results = mockStorage.read { db in - let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + let pattern: FTS5Pattern = try GlobalSearch.pattern( db, searchTerm: "This mes", forTable: TestMessage.self @@ -206,7 +206,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- does not add a wildcard to other parts it("does not add a wildcard to other parts") { let results = mockStorage.read { db in - let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + let pattern: FTS5Pattern = try GlobalSearch.pattern( db, searchTerm: "mes Random", forTable: TestMessage.self @@ -229,7 +229,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- finds similar words without the wildcard due to the porter tokenizer it("finds similar words without the wildcard due to the porter tokenizer") { let results = mockStorage.read { db in - let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + let pattern: FTS5Pattern = try GlobalSearch.pattern( db, searchTerm: "message z", forTable: TestMessage.self @@ -261,7 +261,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- finds results containing the words regardless of the order it("finds results containing the words regardless of the order") { let results = mockStorage.read { db in - let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + let pattern: FTS5Pattern = try GlobalSearch.pattern( db, searchTerm: "is a message", forTable: TestMessage.self @@ -293,7 +293,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- does not find quoted parts out of order it("does not find quoted parts out of order") { let results = mockStorage.read { db in - let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + let pattern: FTS5Pattern = try GlobalSearch.pattern( db, searchTerm: "\"this is a\" \"test message\"", forTable: TestMessage.self diff --git a/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift b/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift index 2254bb59fd..9f5f4dc295 100644 --- a/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift +++ b/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift @@ -2141,11 +2141,7 @@ class ExtensionHelperSpec: AsyncSpec { publicKey: "05\(TestConstants.publicKey)", namespace: .default, rawMessage: GetMessagesResponse.RawMessage( - base64EncodedDataString: try! MessageWrapper.wrap( - type: .sessionMessage, - timestampMs: 1234567890, - content: Data([1, 2, 3]) - ).base64EncodedString(), + base64EncodedDataString: "TestData", expirationMs: nil, hash: "TestHash", timestampMs: 1234567890 @@ -2159,8 +2155,28 @@ class ExtensionHelperSpec: AsyncSpec { dataMessage.setBody("Test") content.setDataMessage(try! dataMessage.build()) mockCrypto - .when { $0.generate(.plaintextWithSessionProtocol(ciphertext: .any)) } - .thenReturn((try! content.build().serializedData(), "05\(TestConstants.publicKey)")) + .when { + try $0.generate( + .decodedMessage( + encodedMessage: Array.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) + ) + ) + } + .thenReturn( + DecodedMessage( + content: try! content.build().serializedData(), + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890 + ) + ) } // MARK: ---- successfully loads messages diff --git a/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift b/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift index edda4ebd98..bf57eae924 100644 --- a/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift @@ -86,7 +86,8 @@ extension Interaction: Mocked { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .mock, + proProfileFeatures: .mock ) } @@ -164,3 +165,37 @@ extension ConfigDump: Mocked { timestampMs: 1234567890 ) } + +extension SessionPro.MessageFeatures: Mocked { + static var mock: SessionPro.MessageFeatures = .all +} + +extension SessionPro.ProfileFeatures: Mocked { + static var mock: SessionPro.ProfileFeatures = .all +} + +extension CommunityManager.PendingChange: Mocked { + static var mock: CommunityManager.PendingChange = CommunityManager.PendingChange( + server: .mock, + room: .mock, + changeType: .mock, + seqNo: .mock, + metadata: .mock + ) +} + +extension CommunityManager.PendingChange.ChangeType: Mocked { + static var mock: CommunityManager.PendingChange.ChangeType = .reaction +} + +extension CommunityManager.PendingChange.ReactAction: Mocked { + static var mock: CommunityManager.PendingChange.ReactAction = .remove +} + +extension CommunityManager.PendingChange.Metadata: Mocked { + static var mock: CommunityManager.PendingChange.Metadata = .reaction( + messageId: .mock, + emoji: .mock, + action: .mock + ) +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockCommunityManager.swift b/SessionMessagingKitTests/_TestUtilities/MockCommunityManager.swift new file mode 100644 index 0000000000..643d482eaf --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockCommunityManager.swift @@ -0,0 +1,180 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import SessionNetworkingKit +import SessionUtilitiesKit + +@testable import SessionMessagingKit + +class MockCommunityManager: Mock, CommunityManagerType { + nonisolated var defaultRooms: AsyncStream<(rooms: [Network.SOGS.Room], lastError: Error?)> { + mock() + } + var pendingChanges: [CommunityManager.PendingChange] { + get async { mock() } + } + nonisolated var syncPendingChanges: [CommunityManager.PendingChange] { + mock() + } + + // MARK: - Cache + + nonisolated func getLastSuccessfulCommunityPollTimestampSync() -> TimeInterval { + return mock() + } + + func getLastSuccessfulCommunityPollTimestamp() async -> TimeInterval { + return mock() + } + + func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) async { + return mockNoReturn(args: [timestamp]) + } + + @available(*, deprecated, message: "use `server(_:)?.currentUserSessionIds` instead") + nonisolated func currentUserSessionIdsSync(_ server: String) -> Set { + return mock(args: [server]) + } + + func fetchDefaultRoomsIfNeeded() async { mockNoReturn() } + func loadCacheIfNeeded() async { mockNoReturn() } + + func server(_ server: String) async -> CommunityManager.Server? { return mock(args: [server]) } + func server(threadId: String) async -> CommunityManager.Server? { return mock(args: [threadId]) } + func serversByThreadId() async -> [String: CommunityManager.Server] { return mock() } + func updateServer(server: CommunityManager.Server) async { return mock(args: [server]) } + func updateCapabilities( + capabilities: Set, + server: String, + publicKey: String + ) async { + mockNoReturn(args: [capabilities, server, publicKey]) + } + func updateRooms( + rooms: [Network.SOGS.Room], + server: String, + publicKey: String, + areDefaultRooms: Bool + ) async { + mockNoReturn(args: [rooms, server, publicKey, areDefaultRooms]) + } + + // MARK: - Adding & Removing + + func hasExistingCommunity(roomToken: String, server: String, publicKey: String) async -> Bool { + return mock(args: [roomToken, server, publicKey]) + } + + nonisolated func add( + _ db: ObservingDatabase, + roomToken: String, + server: String, + publicKey: String, + joinedAt: TimeInterval, + forceVisible: Bool + ) -> Bool { + return mock(args: [roomToken, server, publicKey, joinedAt, forceVisible]) + } + + nonisolated func performInitialRequestsAfterAdd( + queue: DispatchQueue, + successfullyAddedGroup: Bool, + roomToken: String, + server: String, + publicKey: String + ) -> AnyPublisher { + return mock(args: [successfullyAddedGroup, roomToken, server, publicKey], untrackedArgs: [queue]) + } + + nonisolated func delete( + _ db: ObservingDatabase, + openGroupId: String, + skipLibSessionUpdate: Bool + ) throws { + return try mockThrowingNoReturn(args: [db, openGroupId, skipLibSessionUpdate]) + } + + // MARK: - Response Processing + + nonisolated func handleCapabilities( + _ db: ObservingDatabase, + capabilities: Network.SOGS.CapabilitiesResponse, + server: String, + publicKey: String + ) { + return mockNoReturn(args: [db, capabilities, server, publicKey]) + } + nonisolated func handlePollInfo( + _ db: ObservingDatabase, + pollInfo: Network.SOGS.RoomPollInfo, + server: String, + roomToken: String, + publicKey: String + ) throws { + return try mockThrowingNoReturn(args: [db, pollInfo, server, roomToken, publicKey]) + } + nonisolated func handleMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.Message], + server: String, + roomToken: String, + currentUserSessionIds: Set + ) -> [MessageReceiver.InsertedInteractionInfo?] { + return mock(args: [db, messages, server, roomToken, currentUserSessionIds]) + } + + nonisolated func handleDirectMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.DirectMessage], + fromOutbox: Bool, + server: String, + currentUserSessionIds: Set + ) -> [MessageReceiver.InsertedInteractionInfo?] { + return mock(args: [db, messages, fromOutbox, server, currentUserSessionIds]) + } + + // MARK: - Convenience + + func addPendingReaction( + emoji: String, + id: Int64, + in roomToken: String, + on server: String, + type: CommunityManager.PendingChange.ReactAction + ) async -> CommunityManager.PendingChange { + return mock(args: [emoji, id, roomToken, server]) + } + + func setPendingChanges(_ pendingChanges: [CommunityManager.PendingChange]) async { + mockNoReturn(args: [pendingChanges]) + } + func updatePendingChange(_ pendingChange: CommunityManager.PendingChange, seqNo: Int64?) async { + mockNoReturn(args: [pendingChange, seqNo]) + } + func removePendingChange(_ pendingChange: CommunityManager.PendingChange) async { + mockNoReturn(args: [pendingChange]) + } + + func doesOpenGroupSupport( + capability: Capability.Variant, + on maybeServer: String? + ) async -> Bool { + return mock(args: [capability, maybeServer]) + } + func allModeratorsAndAdmins( + server maybeServer: String?, + roomToken: String?, + includingHidden: Bool + ) async -> Set { + return mock(args: [maybeServer, roomToken, includingHidden]) + } + func isUserModeratorOrAdmin( + targetUserPublicKey: String, + server maybeServer: String?, + roomToken: String?, + includingHidden: Bool + ) async -> Bool { + return mock(args: [targetUserPublicKey, maybeServer, roomToken, includingHidden]) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift b/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift index 6e51eb570f..563bd03e46 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift @@ -7,7 +7,7 @@ import SessionUtilitiesKit @testable import SessionMessagingKit class MockDisplayPictureCache: Mock, DisplayPictureCacheType { - var downloadsToSchedule: Set { + var downloadsToSchedule: Set { get { return mock() } set { mockNoReturn(args: [newValue]) } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift index 2f1859dbce..b852bfa187 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift @@ -12,6 +12,8 @@ class MockLibSessionCache: Mock, LibSessionCacheType { var userSessionId: SessionId { mock() } var isEmpty: Bool { mock() } var allDumpSessionIds: Set { mock() } + var proConfig: SessionPro.ProConfig? { mock() } + var proAccessExpiryTimestampMs: UInt64 { mock() } // MARK: - State Management @@ -120,26 +122,8 @@ class MockLibSessionCache: Mock, LibSessionCacheType { func mergeConfigMessages( swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo], - afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? - ) throws -> [LibSession.MergeResult] { - try mockThrowingNoReturn(args: [swarmPublicKey, messages]) - - /// **Note:** Since `afterMerge` is non-escaping (and we don't want to change it to be so for the purposes of mocking - /// in unit test) we just call it directly instead of storing in `untrackedArgs` - let expectation: MockFunction = getExpectation(args: [swarmPublicKey, messages]) - - guard - expectation.closureCallArgs.count == 4, - let sessionId: SessionId = expectation.closureCallArgs[0] as? SessionId, - let variant: ConfigDump.Variant = expectation.closureCallArgs[1] as? ConfigDump.Variant, - let timestamp: Int64 = expectation.closureCallArgs[3] as? Int64, - let oldState: [ObservableKey: Any] = expectation.closureCallArgs[4] as? [ObservableKey: Any] - else { - return try mockThrowing(args: [swarmPublicKey, messages]) - } - - _ = try afterMerge(sessionId, variant, expectation.closureCallArgs[2] as? LibSession.Config, timestamp, oldState) + messages: [ConfigMessageReceiveJob.Details.MessageInfo] + ) throws -> [ConfigDump.Variant: Int64] { return try mockThrowing(args: [swarmPublicKey, messages]) } @@ -151,13 +135,6 @@ class MockLibSessionCache: Mock, LibSessionCacheType { try mockThrowingNoReturn(args: [swarmPublicKey, messages], untrackedArgs: [db]) } - func unsafeDirectMergeConfigMessage( - swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo] - ) throws { - try mockThrowingNoReturn(args: [swarmPublicKey, messages]) - } - // MARK: - State Access var displayName: String? { mock() } @@ -186,9 +163,22 @@ class MockLibSessionCache: Mock, LibSessionCacheType { displayName: Update, displayPictureUrl: Update, displayPictureEncryptionKey: Update, + proProfileFeatures: Update, isReuploadProfilePicture: Bool ) throws { - try mockThrowingNoReturn(args: [displayName, displayPictureUrl, displayPictureEncryptionKey, isReuploadProfilePicture]) + try mockThrowingNoReturn(args: [displayName, displayPictureUrl, displayPictureEncryptionKey, proProfileFeatures, isReuploadProfilePicture]) + } + + func updateProConfig(proConfig: SessionPro.ProConfig) { + mockNoReturn(args: [proConfig]) + } + + func removeProConfig() { + mockNoReturn() + } + + func updateProAccessExpiryTimestampMs(_ proAccessExpiryTimestampMs: UInt64) { + mockNoReturn(args: [proAccessExpiryTimestampMs]) } func canPerformChange( @@ -227,6 +217,10 @@ class MockLibSessionCache: Mock, LibSessionCacheType { return mock(args: [threadId, threadVariant, openGroupUrlInfo]) } + func proProofMetadata(threadId: String) -> LibSession.ProProofMetadata? { + return mock(args: [threadId]) + } + func isMessageRequest( threadId: String, threadVariant: SessionThread.Variant @@ -286,6 +280,14 @@ class MockLibSessionCache: Mock, LibSessionCacheType { return mock(args: [groupSessionId]) } + func latestGroupKey(groupSessionId: SessionId) throws -> [UInt8] { + return try mockThrowing(args: [groupSessionId]) + } + + func allActiveGroupKeys(groupSessionId: SessionId) throws -> [[UInt8]] { + return try mockThrowing(args: [groupSessionId]) + } + func isAdmin(groupSessionId: SessionId) -> Bool { return mock(args: [groupSessionId]) } @@ -317,6 +319,10 @@ class MockLibSessionCache: Mock, LibSessionCacheType { return mock(args: [groupSessionId]) } + func groupInfo(for groupIds: Set) -> [LibSession.GroupInfo?] { + return mock(args: [groupIds]) + } + func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? { return mock(args: [groupSessionId]) } @@ -324,6 +330,10 @@ class MockLibSessionCache: Mock, LibSessionCacheType { func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? { return mock(args: [groupSessionId]) } + + func authData(groupSessionId: SessionId) -> GroupAuthData { + return mock(args: [groupSessionId]) + } } // MARK: - Convenience @@ -434,7 +444,20 @@ extension Mock where T == LibSessionCacheType { self.when { $0.isContactBlocked(contactId: .any) }.thenReturn(false) self .when { $0.profile(contactId: .any, threadId: .any, threadVariant: .any, visibleMessage: .any) } - .thenReturn(Profile(id: "TestProfileId", name: "TestProfileName")) + .thenReturn( + Profile( + id: "TestProfileId", + name: "TestProfileName", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil + ) + ) self.when { $0.hasCredentials(groupSessionId: .any) }.thenReturn(true) self.when { $0.secretKey(groupSessionId: .any) }.thenReturn(nil) self.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) @@ -444,8 +467,10 @@ extension Mock where T == LibSessionCacheType { self.when { $0.wasKickedFromGroup(groupSessionId: .any) }.thenReturn(false) self.when { $0.groupName(groupSessionId: .any) }.thenReturn("TestGroupName") self.when { $0.groupIsDestroyed(groupSessionId: .any) }.thenReturn(false) + self.when { $0.groupInfo(for: .any) }.thenReturn([]) self.when { $0.groupDeleteBefore(groupSessionId: .any) }.thenReturn(nil) self.when { $0.groupDeleteAttachmentsBefore(groupSessionId: .any) }.thenReturn(nil) + self.when { $0.authData(groupSessionId: .any) }.thenReturn(GroupAuthData(groupIdentityPrivateKey: nil, authData: nil)) self.when { $0.get(.any) }.thenReturn(false) self.when { $0.get(.any) }.thenReturn(MockLibSessionConvertible.mock) self.when { $0.get(.any) }.thenReturn(Preferences.Sound.defaultNotificationSound) diff --git a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift deleted file mode 100644 index 191d60c4d0..0000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -class MockOGMCache: Mock, OGMCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> { - mock() - } - - var pendingChanges: [OpenGroupManager.PendingChange] { - get { return mock() } - set { mockNoReturn(args: [newValue]) } - } - - func getLastSuccessfulCommunityPollTimestamp() -> TimeInterval { - return mock() - } - - func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) { - mockNoReturn(args: [timestamp]) - } - - func setDefaultRoomInfo(_ info: [OpenGroupManager.DefaultRoomInfo]) { - mockNoReturn(args: [info]) - } -} diff --git a/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift b/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift index 2c35f4348d..4dc3fe126b 100644 --- a/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift +++ b/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation diff --git a/SessionNetworkingKit/PushNotification/PushNotificationEndpoint.swift b/SessionNetworkingKit/PushNotification/PushNotificationEndpoint.swift index 5fdb987b04..6dc32c3e2b 100644 --- a/SessionNetworkingKit/PushNotification/PushNotificationEndpoint.swift +++ b/SessionNetworkingKit/PushNotification/PushNotificationEndpoint.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import SessionNetworkingKit import SessionUtilitiesKit public extension Network.PushNotification { diff --git a/SessionNetworkingKit/SOGS/Models/PinnedMessage.swift b/SessionNetworkingKit/SOGS/Models/PinnedMessage.swift index 332a8bb34a..79dabc50b3 100644 --- a/SessionNetworkingKit/SOGS/Models/PinnedMessage.swift +++ b/SessionNetworkingKit/SOGS/Models/PinnedMessage.swift @@ -3,7 +3,7 @@ import Foundation extension Network.SOGS { - public struct PinnedMessage: Codable, Equatable { + public struct PinnedMessage: Sendable, Codable, Equatable { enum CodingKeys: String, CodingKey { case id case pinnedAt = "pinned_at" diff --git a/SessionNetworkingKit/SOGS/Models/Room.swift b/SessionNetworkingKit/SOGS/Models/Room.swift index 6f188c5ce7..2e310b7330 100644 --- a/SessionNetworkingKit/SOGS/Models/Room.swift +++ b/SessionNetworkingKit/SOGS/Models/Room.swift @@ -1,9 +1,10 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit extension Network.SOGS { - public struct Room: Codable, Equatable { + public struct Room: Sendable, Codable, Equatable { enum CodingKeys: String, CodingKey { case token case name @@ -141,6 +142,96 @@ extension Network.SOGS { /// /// It is included in the response only if the requesting user has moderator or admin permissions public let defaultUpload: Bool? + + public init( + token: String, + name: String, + roomDescription: String?, + infoUpdates: Int64, + messageSequence: Int64, + created: TimeInterval, + activeUsers: Int64, + activeUsersCutoff: Int64, + imageId: String?, + pinnedMessages: [PinnedMessage]?, + admin: Bool, + globalAdmin: Bool, + admins: [String], + hiddenAdmins: [String]?, + moderator: Bool, + globalModerator: Bool, + moderators: [String], + hiddenModerators: [String]?, + read: Bool, + defaultRead: Bool?, + defaultAccessible: Bool?, + write: Bool, + defaultWrite: Bool?, + upload: Bool, + defaultUpload: Bool? + ) { + self.token = token + self.name = name + self.roomDescription = roomDescription + self.infoUpdates = infoUpdates + self.messageSequence = messageSequence + self.created = created + self.activeUsers = activeUsers + self.activeUsersCutoff = activeUsersCutoff + self.imageId = imageId + self.pinnedMessages = pinnedMessages + self.admin = admin + self.globalAdmin = globalAdmin + self.admins = admins + self.hiddenAdmins = hiddenAdmins + self.moderator = moderator + self.globalModerator = globalModerator + self.moderators = moderators + self.hiddenModerators = hiddenModerators + self.read = read + self.defaultRead = defaultRead + self.defaultAccessible = defaultAccessible + self.write = write + self.defaultWrite = defaultWrite + self.upload = upload + self.defaultUpload = defaultUpload + } + } +} + +// MARK: - Convenience + +public extension Network.SOGS.Room { + func with( + messageSequence: Update = .useExisting + ) -> Network.SOGS.Room { + return Network.SOGS.Room( + token: token, + name: name, + roomDescription: roomDescription, + infoUpdates: infoUpdates, + messageSequence: messageSequence.or(self.messageSequence), + created: created, + activeUsers: activeUsers, + activeUsersCutoff: activeUsersCutoff, + imageId: imageId, + pinnedMessages: pinnedMessages, + admin: admin, + globalAdmin: globalAdmin, + admins: admins, + hiddenAdmins: hiddenAdmins, + moderator: moderator, + globalModerator: globalModerator, + moderators: moderators, + hiddenModerators: hiddenModerators, + read: read, + defaultRead: defaultRead, + defaultAccessible: defaultAccessible, + write: write, + defaultWrite: defaultWrite, + upload: upload, + defaultUpload: defaultUpload + ) } } diff --git a/SessionNetworkingKit/SOGS/Models/SOGSMessage.swift b/SessionNetworkingKit/SOGS/Models/SOGSMessage.swift index 5b902a0941..3851ca2528 100644 --- a/SessionNetworkingKit/SOGS/Models/SOGSMessage.swift +++ b/SessionNetworkingKit/SOGS/Models/SOGSMessage.swift @@ -4,7 +4,7 @@ import Foundation import SessionUtilitiesKit extension Network.SOGS { - public struct Message: Codable, Equatable { + public struct Message: Codable, Equatable, Hashable { enum CodingKeys: String, CodingKey { case id case sender = "session_id" @@ -35,7 +35,7 @@ extension Network.SOGS { public let base64EncodedData: String? public let base64EncodedSignature: String? - public struct Reaction: Codable, Equatable { + public struct Reaction: Codable, Equatable, Hashable { enum CodingKeys: String, CodingKey { case count case reactors diff --git a/SessionNetworkingKit/SOGS/SOGS.swift b/SessionNetworkingKit/SOGS/SOGS.swift index 80299ad743..2482ef11df 100644 --- a/SessionNetworkingKit/SOGS/SOGS.swift +++ b/SessionNetworkingKit/SOGS/SOGS.swift @@ -3,12 +3,21 @@ // stringlint:disable import Foundation +import SessionUtilitiesKit public extension Network { enum SOGS { public static let legacyDefaultServerIP = "116.203.70.33" public static let defaultServer = "https://open.getsession.org" public static let defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" + public static let defaultAuthMethod: AuthenticationMethod = Authentication.community( + roomToken: "", + server: defaultServer, + publicKey: defaultServerPublicKey, + hasCapabilities: false, + supportsBlinding: true, + forceBlinded: false + ) public static let validTimestampVarianceThreshold: TimeInterval = (6 * 60 * 60) internal static let maxInactivityPeriodForPolling: TimeInterval = (14 * 24 * 60 * 60) diff --git a/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift b/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift index a96d9fd2d4..3d5a615157 100644 --- a/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift +++ b/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift @@ -1,9 +1,11 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation public extension Network.SessionNetwork { - public enum Endpoint: EndpointType { + enum Endpoint: EndpointType { case info case price case token diff --git a/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGenerateProProofResponse.swift b/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGenerateProProofResponse.swift new file mode 100644 index 0000000000..f8a3745ef7 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGenerateProProofResponse.swift @@ -0,0 +1,46 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct AddProPaymentOrGenerateProProofResponse: Decodable, Equatable { + public let header: ResponseHeader + public let proof: ProProof + + public init(from decoder: any Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + let jsonData: Data + + if let data: Data = try? container.decode(Data.self) { + jsonData = data + } + else if let jsonString: String = try? container.decode(String.self) { + guard let data: Data = jsonString.data(using: .utf8) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid UTF-8 in JSON string" // stringlint:ignore + ) + } + + jsonData = data + } + else { + let anyValue: AnyCodable = try container.decode(AnyCodable.self) + jsonData = try JSONEncoder().encode(anyValue) + } + + var result = jsonData.withUnsafeBytes { bytes in + session_pro_backend_add_pro_payment_or_generate_pro_proof_response_parse( + bytes.baseAddress?.assumingMemoryBound(to: CChar.self), + jsonData.count + ) + } + defer { session_pro_backend_add_pro_payment_or_generate_pro_proof_response_free(&result) } + + self.header = ResponseHeader(result.header) + self.proof = ProProof(result.proof) + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Requests/AppProPaymentRequest.swift b/SessionNetworkingKit/SessionPro/Requests/AppProPaymentRequest.swift new file mode 100644 index 0000000000..8d781cf829 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/AppProPaymentRequest.swift @@ -0,0 +1,42 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct AddProPaymentRequest: Encodable, Equatable { + public let masterPublicKey: [UInt8] + public let rotatingPublicKey: [UInt8] + public let paymentTransaction: UserTransaction + public let signatures: Signatures + + // MARK: - Functions + + func toLibSession() -> session_pro_backend_add_pro_payment_request { + var result: session_pro_backend_add_pro_payment_request = session_pro_backend_add_pro_payment_request() + result.version = Network.SessionPro.apiVersion + result.set(\.master_pkey, to: masterPublicKey) + result.set(\.rotating_pkey, to: rotatingPublicKey) + result.payment_tx = paymentTransaction.toLibSession() + result.set(\.master_sig, to: signatures.masterSignature) + result.set(\.rotating_sig, to: signatures.rotatingSignature) + + return result + } + + public func encode(to encoder: any Encoder) throws { + var cRequest: session_pro_backend_add_pro_payment_request = toLibSession() + var cJson: session_pro_backend_to_json = session_pro_backend_add_pro_payment_request_to_json(&cRequest); + defer { session_pro_backend_to_json_free(&cJson) } + + guard cJson.success else { throw NetworkError.invalidPayload } + + let jsonData: Data = Data(bytes: cJson.json.data, count: cJson.json.size) + let decoded: [String: AnyCodable] = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + try decoded.encode(to: encoder) + } + } +} + +extension session_pro_backend_add_pro_payment_request: @retroactive CMutable {} diff --git a/SessionNetworkingKit/SessionPro/Requests/GenerateProProofRequest.swift b/SessionNetworkingKit/SessionPro/Requests/GenerateProProofRequest.swift new file mode 100644 index 0000000000..65e377ae28 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/GenerateProProofRequest.swift @@ -0,0 +1,42 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct GenerateProProofRequest: Encodable, Equatable { + public let masterPublicKey: [UInt8] + public let rotatingPublicKey: [UInt8] + public let timestampMs: UInt64 + public let signatures: Signatures + + // MARK: - Functions + + func toLibSession() -> session_pro_backend_generate_pro_proof_request { + var result: session_pro_backend_generate_pro_proof_request = session_pro_backend_generate_pro_proof_request() + result.version = Network.SessionPro.apiVersion + result.set(\.master_pkey, to: masterPublicKey) + result.set(\.rotating_pkey, to: rotatingPublicKey) + result.unix_ts_ms = timestampMs + result.set(\.master_sig, to: signatures.masterSignature) + result.set(\.rotating_sig, to: signatures.rotatingSignature) + + return result + } + + public func encode(to encoder: any Encoder) throws { + var cRequest: session_pro_backend_generate_pro_proof_request = toLibSession() + var cJson: session_pro_backend_to_json = session_pro_backend_generate_pro_proof_request_to_json(&cRequest); + defer { session_pro_backend_to_json_free(&cJson) } + + guard cJson.success else { throw NetworkError.invalidPayload } + + let jsonData: Data = Data(bytes: cJson.json.data, count: cJson.json.size) + let decoded: [String: AnyCodable] = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + try decoded.encode(to: encoder) + } + } +} + +extension session_pro_backend_generate_pro_proof_request: @retroactive CMutable {} diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProDetailsRequest.swift b/SessionNetworkingKit/SessionPro/Requests/GetProDetailsRequest.swift new file mode 100644 index 0000000000..7ca795c0b4 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/GetProDetailsRequest.swift @@ -0,0 +1,41 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct GetProDetailsRequest: Encodable, Equatable { + public let masterPublicKey: [UInt8] + public let timestampMs: UInt64 + public let count: UInt32 + public let signature: Signature + + // MARK: - Functions + + func toLibSession() -> session_pro_backend_get_pro_details_request { + var result: session_pro_backend_get_pro_details_request = session_pro_backend_get_pro_details_request() + result.version = Network.SessionPro.apiVersion + result.set(\.master_pkey, to: masterPublicKey) + result.set(\.master_sig, to: signature.signature) + result.unix_ts_ms = timestampMs + result.count = count + + return result + } + + public func encode(to encoder: any Encoder) throws { + var cRequest: session_pro_backend_get_pro_details_request = toLibSession() + var cJson: session_pro_backend_to_json = session_pro_backend_get_pro_details_request_to_json(&cRequest); + defer { session_pro_backend_to_json_free(&cJson) } + + guard cJson.success else { throw NetworkError.invalidPayload } + + let jsonData: Data = Data(bytes: cJson.json.data, count: cJson.json.size) + let decoded: [String: AnyCodable] = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + try decoded.encode(to: encoder) + } + } +} + +extension session_pro_backend_get_pro_details_request: @retroactive CMutable {} diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProDetailsResponse.swift b/SessionNetworkingKit/SessionPro/Requests/GetProDetailsResponse.swift new file mode 100644 index 0000000000..3863d0ba5f --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/GetProDetailsResponse.swift @@ -0,0 +1,88 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct GetProDetailsResponse: Decodable, Equatable { + public let header: ResponseHeader + public let items: [PaymentItem] + public let status: BackendUserProStatus + public let errorReport: ErrorReport + public let autoRenewing: Bool + public let expiryTimestampMs: UInt64 + public let gracePeriodDurationMs: UInt64 + public let paymentsTotal: UInt32 + + public init(from decoder: any Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + let jsonData: Data + + if let data: Data = try? container.decode(Data.self) { + jsonData = data + } + else if let jsonString: String = try? container.decode(String.self) { + guard let data: Data = jsonString.data(using: .utf8) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid UTF-8 in JSON string" // stringlint:ignore + ) + } + + jsonData = data + } + else { + let anyValue: AnyCodable = try container.decode(AnyCodable.self) + jsonData = try JSONEncoder().encode(anyValue) + } + + var result = jsonData.withUnsafeBytes { bytes in + session_pro_backend_get_pro_details_response_parse( + bytes.baseAddress?.assumingMemoryBound(to: CChar.self), + jsonData.count + ) + } + defer { session_pro_backend_get_pro_details_response_free(&result) } + + self.header = ResponseHeader(result.header) + self.status = BackendUserProStatus(result.status) + self.errorReport = ErrorReport(result.error_report) + self.autoRenewing = result.auto_renewing + self.expiryTimestampMs = result.expiry_unix_ts_ms + self.gracePeriodDurationMs = result.grace_period_duration_ms + self.paymentsTotal = result.payments_total + + if result.items_count > 0 { + self.items = (0.. session_pro_backend_get_pro_revocations_request { + var result: session_pro_backend_get_pro_revocations_request = session_pro_backend_get_pro_revocations_request() + result.version = Network.SessionPro.apiVersion + result.ticket = ticket + + return result + } + + public func encode(to encoder: any Encoder) throws { + var cRequest: session_pro_backend_get_pro_revocations_request = toLibSession() + var cJson: session_pro_backend_to_json = session_pro_backend_get_pro_revocations_request_to_json(&cRequest); + defer { session_pro_backend_to_json_free(&cJson) } + + guard cJson.success else { throw NetworkError.invalidPayload } + + let jsonData: Data = Data(bytes: cJson.json.data, count: cJson.json.size) + let decoded: [String: AnyCodable] = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + try decoded.encode(to: encoder) + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProRevocationsResponse.swift b/SessionNetworkingKit/SessionPro/Requests/GetProRevocationsResponse.swift new file mode 100644 index 0000000000..b20bc63391 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/GetProRevocationsResponse.swift @@ -0,0 +1,56 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct GetProRevocationsResponse: Decodable, Equatable { + public let header: ResponseHeader + public let ticket: UInt32 + public let items: [RevocationItem] + + public init(from decoder: any Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + let jsonData: Data + + if let data: Data = try? container.decode(Data.self) { + jsonData = data + } + else if let jsonString: String = try? container.decode(String.self) { + guard let data: Data = jsonString.data(using: .utf8) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid UTF-8 in JSON string" // stringlint:ignore + ) + } + + jsonData = data + } + else { + let anyValue: AnyCodable = try container.decode(AnyCodable.self) + jsonData = try JSONEncoder().encode(anyValue) + } + + var result = jsonData.withUnsafeBytes { bytes in + session_pro_backend_get_pro_revocations_response_parse( + bytes.baseAddress?.assumingMemoryBound(to: CChar.self), + jsonData.count + ) + } + defer { session_pro_backend_get_pro_revocations_response_free(&result) } + + self.header = ResponseHeader(result.header) + self.ticket = result.ticket + + if result.items_count > 0 { + self.items = (0.. session_pro_backend_set_payment_refund_requested_request { + var result: session_pro_backend_set_payment_refund_requested_request = session_pro_backend_set_payment_refund_requested_request() + result.version = Network.SessionPro.apiVersion + result.set(\.master_pkey, to: masterPublicKey) + result.set(\.master_sig, to: masterSignature.signature) + result.unix_ts_ms = timestampMs + result.refund_requested_unix_ts_ms = refundRequestedTimestampMs + result.payment_tx = transaction.toLibSession() + + return result + } + + public func encode(to encoder: any Encoder) throws { + var cRequest: session_pro_backend_set_payment_refund_requested_request = toLibSession() + var cJson: session_pro_backend_to_json = session_pro_backend_set_payment_refund_requested_request_to_json(&cRequest); + defer { session_pro_backend_to_json_free(&cJson) } + + guard cJson.success else { throw NetworkError.invalidPayload } + + let jsonData: Data = Data(bytes: cJson.json.data, count: cJson.json.size) + let decoded: [String: AnyCodable] = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + try decoded.encode(to: encoder) + } + } +} + +extension session_pro_backend_set_payment_refund_requested_request: @retroactive CMutable {} diff --git a/SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedResponse.swift b/SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedResponse.swift new file mode 100644 index 0000000000..1cb6ab5ee7 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedResponse.swift @@ -0,0 +1,48 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct SetPaymentRefundRequestedResponse: Decodable, Equatable { + public let header: ResponseHeader + public let version: UInt8 + public let updated: Bool + + public init(from decoder: any Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + let jsonData: Data + + if let data: Data = try? container.decode(Data.self) { + jsonData = data + } + else if let jsonString: String = try? container.decode(String.self) { + guard let data: Data = jsonString.data(using: .utf8) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid UTF-8 in JSON string" // stringlint:ignore + ) + } + + jsonData = data + } + else { + let anyValue: AnyCodable = try container.decode(AnyCodable.self) + jsonData = try JSONEncoder().encode(anyValue) + } + + var result = jsonData.withUnsafeBytes { bytes in + session_pro_backend_set_payment_refund_requested_response_parse( + bytes.baseAddress?.assumingMemoryBound(to: CChar.self), + jsonData.count + ) + } + defer { session_pro_backend_set_payment_refund_requested_response_free(&result) } + + self.header = ResponseHeader(result.header) + self.version = result.version + self.updated = result.updated + } + } +} diff --git a/SessionNetworkingKit/SessionPro/SessionPro.swift b/SessionNetworkingKit/SessionPro/SessionPro.swift new file mode 100644 index 0000000000..0c3bf959e1 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/SessionPro.swift @@ -0,0 +1,22 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUtilitiesKit + +public extension Network { + enum SessionPro { + public static let apiVersion: UInt8 = 0 + static let server = "{NEED_TO_SET}" + public static let serverEdPublicKey = "{NEED_TO_SET}" + + internal static func x25519PublicKey(using dependencies: Dependencies) throws -> String { + let x25519Pubkey: [UInt8] = try dependencies[singleton: .crypto].tryGenerate( + .x25519(ed25519Pubkey: Array(Data(hex: serverEdPublicKey))) + ) + + return x25519Pubkey.toHexString() + } + } +} diff --git a/SessionNetworkingKit/SessionPro/SessionProAPI.swift b/SessionNetworkingKit/SessionPro/SessionProAPI.swift new file mode 100644 index 0000000000..a3279f3758 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/SessionProAPI.swift @@ -0,0 +1,261 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import SessionUtil +import SessionUtilitiesKit + +// MARK: - Log.Category + +public extension Log.Category { + static let sessionPro: Log.Category = .create("SessionPro", defaultLevel: .info) +} + +public extension Network.SessionPro { + static func test(using dependencies: Dependencies) { + let masterKeyPair: KeyPair = try! dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + let rotatingKeyPair: KeyPair = try! dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + + Task { + // FIXME: Make this async/await when the refactored networking is merged + do { + let addProProofRequest = try? Network.SessionPro.addProPayment( + transactionId: "12345678", + masterKeyPair: masterKeyPair, + rotatingKeyPair: rotatingKeyPair, + requestTimeout: 5, + using: dependencies + ) + let addProProofResponse = try await addProProofRequest + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 + + let proProofRequest = try? Network.SessionPro.generateProProof( + masterKeyPair: masterKeyPair, + rotatingKeyPair: rotatingKeyPair, + using: dependencies + ) + let proProofResponse = try await proProofRequest + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 + + let proDetailsRequest = try? Network.SessionPro.getProDetails( + masterKeyPair: masterKeyPair, + using: dependencies + ) + let proDetailsResponse = try await proDetailsRequest + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 + + let proRevocationsRequest = try? Network.SessionPro.getProRevocations( + ticket: 0, + using: dependencies + ) + let proRevocationsResponse = try await proRevocationsRequest + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 + + await MainActor.run { + let tmp1 = addProProofResponse + let tmp2 = proProofResponse + let tmp3 = proDetailsResponse + let tmp4 = proRevocationsResponse + print("RAWR Test Success") + } + } + catch { + print("RAWR Test Error") + } + } + } + + static func addProPayment( + transactionId: String, + masterKeyPair: KeyPair, + rotatingKeyPair: KeyPair, + requestTimeout: TimeInterval, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey + let cRotatingPrivateKey: [UInt8] = rotatingKeyPair.secretKey + let cTransactionId: [UInt8] = Array(transactionId.utf8) + let signatures: Signatures = try Signatures( + session_pro_backend_add_pro_payment_request_build_sigs( + Network.SessionPro.apiVersion, + cMasterPrivateKey, + cMasterPrivateKey.count, + cRotatingPrivateKey, + cRotatingPrivateKey.count, + PaymentProvider.appStore.libSessionValue, + cTransactionId, + cTransactionId.count, + [], /// The `order_id` is only needed for Google transactions + 0 + ) + ) + + return try Network.PreparedRequest( + request: try Request( + method: .post, + endpoint: .addProPayment, + body: AddProPaymentRequest( + masterPublicKey: masterKeyPair.publicKey, + rotatingPublicKey: rotatingKeyPair.publicKey, + paymentTransaction: UserTransaction( + provider: .appStore, + paymentId: transactionId, + orderId: "" /// The `order_id` is only needed for Google transactions + ), + signatures: signatures + ), + using: dependencies + ), + responseType: AddProPaymentOrGenerateProProofResponse.self, + requestTimeout: requestTimeout, + using: dependencies + ) + } + + /// Generate a pro proof for the provided `rotatingKeyPair` + /// + /// **Note:** If the user doesn't currently have an active Session Pro subscription then this will return an error + static func generateProProof( + masterKeyPair: KeyPair, + rotatingKeyPair: KeyPair, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey + let cRotatingPrivateKey: [UInt8] = rotatingKeyPair.secretKey + let timestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let signatures: Signatures = try Signatures( + session_pro_backend_generate_pro_proof_request_build_sigs( + Network.SessionPro.apiVersion, + cMasterPrivateKey, + cMasterPrivateKey.count, + cRotatingPrivateKey, + cRotatingPrivateKey.count, + timestampMs + ) + ) + + return try Network.PreparedRequest( + request: try Request( + method: .post, + endpoint: .generateProProof, + body: GenerateProProofRequest( + masterPublicKey: masterKeyPair.publicKey, + rotatingPublicKey: rotatingKeyPair.publicKey, + timestampMs: timestampMs, + signatures: signatures + ), + using: dependencies + ), + responseType: AddProPaymentOrGenerateProProofResponse.self, + using: dependencies + ) + } + + static func getProDetails( + count: UInt32 = 1, + masterKeyPair: KeyPair, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey + let timestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let signature: Signature = try Signature( + session_pro_backend_get_pro_details_request_build_sig( + Network.SessionPro.apiVersion, + cMasterPrivateKey, + cMasterPrivateKey.count, + timestampMs, + count + ) + ) + + return try Network.PreparedRequest( + request: try Request( + method: .post, + endpoint: .getProDetails, + body: GetProDetailsRequest( + masterPublicKey: masterKeyPair.publicKey, + timestampMs: timestampMs, + count: count, + signature: signature + ), + using: dependencies + ), + responseType: GetProDetailsResponse.self, + using: dependencies + ) + } + + static func getProRevocations( + ticket: UInt32, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + return try Network.PreparedRequest( + request: try Request( + method: .post, + endpoint: .getProRevocations, + body: GetProRevocationsRequest( + ticket: ticket + ), + using: dependencies + ), + responseType: GetProRevocationsResponse.self, + using: dependencies + ) + } + + static func setPaymentRefundRequested( + transactionId: String, + refundRequestedTimestampMs: UInt64, + masterKeyPair: KeyPair, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey + let timestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let cTransactionId: [UInt8] = Array(transactionId.utf8) + let signature: Signature = try Signature( + session_pro_backend_set_payment_refund_requested_request_build_sigs( + Network.SessionPro.apiVersion, + cMasterPrivateKey, + cMasterPrivateKey.count, + timestampMs, + refundRequestedTimestampMs, + PaymentProvider.appStore.libSessionValue, + cTransactionId, + cTransactionId.count, + [], /// The `order_id` is only needed for Google transactions + 0 + ) + ) + + return try Network.PreparedRequest( + request: try Request( + method: .post, + endpoint: .getProRevocations, + body: SetPaymentRefundRequestedRequest( + masterPublicKey: masterKeyPair.publicKey, + masterSignature: signature, + timestampMs: timestampMs, + refundRequestedTimestampMs: refundRequestedTimestampMs, + transaction: UserTransaction( + provider: .appStore, + paymentId: transactionId, + orderId: "" /// The `order_id` is only needed for Google transactions + ) + ), + using: dependencies + ), + responseType: SetPaymentRefundRequestedResponse.self, + using: dependencies + ) + } +} diff --git a/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift b/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift new file mode 100644 index 0000000000..15acce7eda --- /dev/null +++ b/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift @@ -0,0 +1,27 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation + +public extension Network.SessionPro { + enum Endpoint: EndpointType { + case addProPayment + case generateProProof + case getProRevocations + case getProDetails + case setPaymentRefundRequested + + public static var name: String { "SessionPro.Endpoint" } + + public var path: String { + switch self { + case .addProPayment: return "add_pro_payment" + case .generateProProof: return "generate_pro_proof" + case .getProRevocations: return "get_pro_revocations" + case .getProDetails: return "get_pro_details" + case .setPaymentRefundRequested: return "set_payment_refund_requested" + } + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/AddProPaymentResponseStatus.swift b/SessionNetworkingKit/SessionPro/Types/AddProPaymentResponseStatus.swift new file mode 100644 index 0000000000..8ee175bc1d --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/AddProPaymentResponseStatus.swift @@ -0,0 +1,35 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension Network.SessionPro { + enum AddProPaymentResponseStatus: CaseIterable { + case success + case error + case parseError + case alreadyRedeemed + case unknownPayment + + var libSessionValue: SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS { + switch self { + case .success: return SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_SUCCESS + case .error: return SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_ERROR + case .parseError: return SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_PARSE_ERROR + case .alreadyRedeemed: return SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_ALREADY_REDEEMED + case .unknownPayment: return SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_UNKNOWN_PAYMENT + } + } + + init(_ libSessionValue: SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS) { + switch libSessionValue { + case SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_SUCCESS: self = .success + case SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_ERROR: self = .error + case SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_PARSE_ERROR: self = .parseError + case SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_ALREADY_REDEEMED: self = .alreadyRedeemed + case SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_UNKNOWN_PAYMENT: self = .unknownPayment + default: self = .error + } + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift b/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift new file mode 100644 index 0000000000..968e106d42 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift @@ -0,0 +1,40 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + enum BackendUserProStatus: Sendable, CaseIterable, Equatable, CustomStringConvertible { + case neverBeenPro + case active + case expired + + var libSessionValue: SESSION_PRO_BACKEND_USER_PRO_STATUS { + switch self { + case .neverBeenPro: return SESSION_PRO_BACKEND_USER_PRO_STATUS_NEVER_BEEN_PRO + case .active: return SESSION_PRO_BACKEND_USER_PRO_STATUS_ACTIVE + case .expired: return SESSION_PRO_BACKEND_USER_PRO_STATUS_EXPIRED + } + } + + init(_ libSessionValue: SESSION_PRO_BACKEND_USER_PRO_STATUS) { + switch libSessionValue { + case SESSION_PRO_BACKEND_USER_PRO_STATUS_NEVER_BEEN_PRO: self = .neverBeenPro + case SESSION_PRO_BACKEND_USER_PRO_STATUS_ACTIVE: self = .active + case SESSION_PRO_BACKEND_USER_PRO_STATUS_EXPIRED: self = .expired + default: self = .neverBeenPro + } + } + + public var description: String { + switch self { + case .neverBeenPro: return "Never been pro" + case .active: return "Active" + case .expired: return "Expired" + } + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift b/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift new file mode 100644 index 0000000000..0cb53a8fd6 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift @@ -0,0 +1,71 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct PaymentItem: Sendable, Equatable, Hashable { + public let status: PaymentStatus + public let plan: Plan + public let paymentProvider: PaymentProvider? + + public let autoRenewing: Bool + public let unredeemedTimestampMs: UInt64 + public let redeemedTimestampMs: UInt64 + public let expiryTimestampMs: UInt64 + public let gracePeriodDurationMs: UInt64 + public let platformRefundExpiryTimestampMs: UInt64 + public let revokedTimestampMs: UInt64 + public let refundRequestedTimestampMs: UInt64 + + public let googlePaymentToken: String? + public let googleOrderId: String? + public let appleOriginalTransactionId: String? + public let appleTransactionId: String? + public let appleWebLineOrderId: String? + + init(_ libSessionValue: session_pro_backend_pro_payment_item) { + status = PaymentStatus(libSessionValue.status) + plan = Plan(libSessionValue.plan) + paymentProvider = PaymentProvider(libSessionValue.payment_provider) + + autoRenewing = libSessionValue.auto_renewing + unredeemedTimestampMs = libSessionValue.unredeemed_unix_ts_ms + redeemedTimestampMs = libSessionValue.redeemed_unix_ts_ms + expiryTimestampMs = libSessionValue.expiry_unix_ts_ms + gracePeriodDurationMs = libSessionValue.grace_period_duration_ms + platformRefundExpiryTimestampMs = libSessionValue.platform_refund_expiry_unix_ts_ms + revokedTimestampMs = libSessionValue.revoked_unix_ts_ms + refundRequestedTimestampMs = libSessionValue.refund_requested_unix_ts_ms + + googlePaymentToken = libSessionValue.get( + \.google_payment_token, + nullIfEmpty: true, + explicitLength: libSessionValue.google_payment_token_count + ) + googleOrderId = libSessionValue.get( + \.google_order_id, + nullIfEmpty: true, + explicitLength: libSessionValue.google_order_id_count + ) + appleOriginalTransactionId = libSessionValue.get( + \.apple_original_tx_id, + nullIfEmpty: true, + explicitLength: libSessionValue.apple_original_tx_id_count + ) + appleTransactionId = libSessionValue.get( + \.apple_tx_id, + nullIfEmpty: true, + explicitLength: libSessionValue.apple_tx_id_count + ) + appleWebLineOrderId = libSessionValue.get( + \.apple_web_line_order_id, + nullIfEmpty: true, + explicitLength: libSessionValue.apple_web_line_order_id_count + ) + } + } +} + +extension session_pro_backend_pro_payment_item: @retroactive CAccessible {} diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentProvider.swift b/SessionNetworkingKit/SessionPro/Types/PaymentProvider.swift new file mode 100644 index 0000000000..5052a750ca --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/PaymentProvider.swift @@ -0,0 +1,27 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension Network.SessionPro { + enum PaymentProvider: Sendable, Equatable, Hashable, CaseIterable { + case playStore + case appStore + + var libSessionValue: SESSION_PRO_BACKEND_PAYMENT_PROVIDER { + switch self { + case .playStore: return SESSION_PRO_BACKEND_PAYMENT_PROVIDER_GOOGLE_PLAY_STORE + case .appStore: return SESSION_PRO_BACKEND_PAYMENT_PROVIDER_IOS_APP_STORE + } + } + + init?(_ libSessionValue: SESSION_PRO_BACKEND_PAYMENT_PROVIDER) { + switch libSessionValue { + case SESSION_PRO_BACKEND_PAYMENT_PROVIDER_NIL: return nil + case SESSION_PRO_BACKEND_PAYMENT_PROVIDER_GOOGLE_PLAY_STORE: self = .playStore + case SESSION_PRO_BACKEND_PAYMENT_PROVIDER_IOS_APP_STORE: self = .appStore + default: return nil + } + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentStatus.swift b/SessionNetworkingKit/SessionPro/Types/PaymentStatus.swift new file mode 100644 index 0000000000..8ab5639fd5 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/PaymentStatus.swift @@ -0,0 +1,35 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension Network.SessionPro { + enum PaymentStatus: Sendable, Equatable, Hashable, CaseIterable { + case none + case unredeemed + case redeemed + case expired + case refunded + + var libSessionValue: SESSION_PRO_BACKEND_PAYMENT_STATUS { + switch self { + case .none: return SESSION_PRO_BACKEND_PAYMENT_STATUS_NIL + case .unredeemed: return SESSION_PRO_BACKEND_PAYMENT_STATUS_UNREDEEMED + case .redeemed: return SESSION_PRO_BACKEND_PAYMENT_STATUS_REDEEMED + case .expired: return SESSION_PRO_BACKEND_PAYMENT_STATUS_EXPIRED + case .refunded: return SESSION_PRO_BACKEND_PAYMENT_STATUS_REFUNDED + } + } + + init(_ libSessionValue: SESSION_PRO_BACKEND_PAYMENT_STATUS) { + switch libSessionValue { + case SESSION_PRO_BACKEND_PAYMENT_STATUS_NIL: self = .none + case SESSION_PRO_BACKEND_PAYMENT_STATUS_UNREDEEMED: self = .unredeemed + case SESSION_PRO_BACKEND_PAYMENT_STATUS_REDEEMED: self = .redeemed + case SESSION_PRO_BACKEND_PAYMENT_STATUS_EXPIRED: self = .expired + case SESSION_PRO_BACKEND_PAYMENT_STATUS_REFUNDED: self = .refunded + default: self = .none + } + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/Plan.swift b/SessionNetworkingKit/SessionPro/Types/Plan.swift new file mode 100644 index 0000000000..10ceda9aae --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/Plan.swift @@ -0,0 +1,32 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension Network.SessionPro { + enum Plan: Sendable, Equatable, Hashable, CaseIterable { + case none + case oneMonth + case threeMonths + case twelveMonths + + var libSessionValue: SESSION_PRO_BACKEND_PLAN { + switch self { + case .none: return SESSION_PRO_BACKEND_PLAN_NIL + case .oneMonth: return SESSION_PRO_BACKEND_PLAN_ONE_MONTH + case .threeMonths: return SESSION_PRO_BACKEND_PLAN_THREE_MONTHS + case .twelveMonths: return SESSION_PRO_BACKEND_PLAN_TWELVE_MONTHS + } + } + + init(_ libSessionValue: SESSION_PRO_BACKEND_PLAN) { + switch libSessionValue { + case SESSION_PRO_BACKEND_PLAN_NIL: self = .none + case SESSION_PRO_BACKEND_PLAN_ONE_MONTH: self = .oneMonth + case SESSION_PRO_BACKEND_PLAN_THREE_MONTHS: self = .threeMonths + case SESSION_PRO_BACKEND_PLAN_TWELVE_MONTHS: self = .twelveMonths + default: self = .none + } + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/ProProof.swift b/SessionNetworkingKit/SessionPro/Types/ProProof.swift new file mode 100644 index 0000000000..37fe43a7be --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/ProProof.swift @@ -0,0 +1,52 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct ProProof: Sendable, Codable, Equatable, Hashable { + public let version: UInt8 + public let genIndexHash: [UInt8] + public let rotatingPubkey: [UInt8] + public let expiryUnixTimestampMs: UInt64 + public let signature: [UInt8] + + public var libSessionValue: session_protocol_pro_proof { + var result: session_protocol_pro_proof = session_protocol_pro_proof() + result.version = version + result.set(\.gen_index_hash, to: genIndexHash) + result.set(\.rotating_pubkey, to: rotatingPubkey) + result.expiry_unix_ts_ms = expiryUnixTimestampMs + result.set(\.sig, to: signature) + + return result + } + + // MARK: - Initialization + + public init( + version: UInt8 = Network.SessionPro.apiVersion, + genIndexHash: [UInt8] = [], + rotatingPubkey: [UInt8] = [], + expiryUnixTimestampMs: UInt64 = 0, + signature: [UInt8] = [] + ) { + self.version = version + self.genIndexHash = genIndexHash + self.rotatingPubkey = rotatingPubkey + self.expiryUnixTimestampMs = expiryUnixTimestampMs + self.signature = signature + } + + public init(_ libSessionValue: session_protocol_pro_proof) { + version = libSessionValue.version + genIndexHash = libSessionValue.get(\.gen_index_hash) + rotatingPubkey = libSessionValue.get(\.rotating_pubkey) + expiryUnixTimestampMs = libSessionValue.expiry_unix_ts_ms + signature = libSessionValue.get(\.sig) + } + } +} + +extension session_protocol_pro_proof: @retroactive CMutable & CAccessible {} diff --git a/SessionNetworkingKit/SessionPro/Types/Request+SessionProAPI.swift b/SessionNetworkingKit/SessionPro/Types/Request+SessionProAPI.swift new file mode 100644 index 0000000000..d75da90e94 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/Request+SessionProAPI.swift @@ -0,0 +1,27 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public extension Request where Endpoint == Network.SessionPro.Endpoint { + init( + method: HTTPMethod, + endpoint: Endpoint, + queryParameters: [HTTPQueryParam: String] = [:], + headers: [HTTPHeader: String] = [:], + body: T? = nil, + using dependencies: Dependencies + ) throws { + self = try Request( + endpoint: endpoint, + destination: try .server( + method: method, + server: Network.SessionPro.server, + queryParameters: queryParameters, + headers: headers, + x25519PublicKey: Network.SessionPro.x25519PublicKey(using: dependencies) + ), + body: body + ) + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/ResponseHeader.swift b/SessionNetworkingKit/SessionPro/Types/ResponseHeader.swift new file mode 100644 index 0000000000..61415eb057 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/ResponseHeader.swift @@ -0,0 +1,22 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension Network.SessionPro { + struct ResponseHeader: Equatable { + public let status: UInt32 + public let errors: [String] + + init(_ libSessionValue: session_pro_backend_response_header) { + status = libSessionValue.status + errors = (0.. session_pro_backend_add_pro_payment_user_transaction { + var result: session_pro_backend_add_pro_payment_user_transaction = session_pro_backend_add_pro_payment_user_transaction() + result.provider = (provider?.libSessionValue ?? SESSION_PRO_BACKEND_PAYMENT_PROVIDER_NIL) + result.set(\.payment_id, to: paymentId) + result.payment_id_count = paymentId.count + result.set(\.order_id, to: orderId) + result.order_id_count = orderId.count + + return result + } + } +} + +extension session_pro_backend_add_pro_payment_user_transaction: @retroactive CAccessible & CMutable {} diff --git a/SessionNetworkingKit/Types/HTTPFragmentParam.swift b/SessionNetworkingKit/Types/HTTPFragmentParam.swift index 50821c5496..574229cc5e 100644 --- a/SessionNetworkingKit/Types/HTTPFragmentParam.swift +++ b/SessionNetworkingKit/Types/HTTPFragmentParam.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation diff --git a/SessionNetworkingKit/Types/HTTPQueryParam.swift b/SessionNetworkingKit/Types/HTTPQueryParam.swift index a350963bdb..f1d9c2b2dc 100644 --- a/SessionNetworkingKit/Types/HTTPQueryParam.swift +++ b/SessionNetworkingKit/Types/HTTPQueryParam.swift @@ -1,4 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation diff --git a/SessionNetworkingKit/Types/NetworkError.swift b/SessionNetworkingKit/Types/NetworkError.swift index 8938775521..a438b7c47c 100644 --- a/SessionNetworkingKit/Types/NetworkError.swift +++ b/SessionNetworkingKit/Types/NetworkError.swift @@ -11,6 +11,7 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case forbidden case notFound case parsingFailed + case invalidPayload case invalidResponse case maxFileSizeExceeded case unauthorised @@ -21,6 +22,7 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case badRequest(error: String, rawData: Data?) case requestFailed(error: String, rawData: Data?) case timeout(error: String, rawData: Data?) + case explicit(String) case suspended case unknown @@ -32,6 +34,7 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case .forbidden: return "Forbidden (NetworkError.forbidden)." case .notFound: return "Not Found (NetworkError.notFound)." case .parsingFailed: return "Invalid response (NetworkError.parsingFailed)." + case .invalidPayload: return "Invalid payload (NetworkError.invalidPayload)." case .invalidResponse: return "Invalid response (NetworkError.invalidResponse)." case .maxFileSizeExceeded: return "Maximum file size exceeded (NetworkError.maxFileSizeExceeded)." case .unauthorised: return "Unauthorised (Failed to verify the signature - NetworkError.unauthorised)." @@ -41,6 +44,7 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case .gatewayTimeout: return "Gateway timeout (NetworkError.gatewayTimeout)." case .badRequest(let error, _), .requestFailed(let error, _): return error case .timeout(let error, _): return "The request timed out with error: \(error) (NetworkError.timeout)." + case .explicit(let error): return error case .suspended: return "Network requests are suspended (NetworkError.suspended)." case .unknown: return "An unknown error occurred (NetworkError.unknown)." } diff --git a/SessionNetworkingKitTests/SOGS/Crypto/Authentication+SOGS.swift b/SessionNetworkingKitTests/SOGS/Crypto/Authentication+SOGS.swift index 92862325b1..ff332b79b5 100644 --- a/SessionNetworkingKitTests/SOGS/Crypto/Authentication+SOGS.swift +++ b/SessionNetworkingKitTests/SOGS/Crypto/Authentication+SOGS.swift @@ -6,8 +6,26 @@ import SessionUtilitiesKit // MARK: - Authentication Types public extension Authentication { + static func community( + roomToken: String, + server: String, + publicKey: String, + hasCapabilities: Bool, + supportsBlinding: Bool, + forceBlinded: Bool = false + ) -> AuthenticationMethod { + return Community( + roomToken: roomToken, + server: server, + publicKey: publicKey, + hasCapabilities: hasCapabilities, + supportsBlinding: supportsBlinding, + forceBlinded: forceBlinded + ) + } + /// Used when interacting with a community - struct community: AuthenticationMethod { + struct Community: AuthenticationMethod { public let roomToken: String public let server: String public let publicKey: String @@ -31,7 +49,7 @@ public extension Authentication { publicKey: String, hasCapabilities: Bool, supportsBlinding: Bool, - forceBlinded: Bool = false + forceBlinded: Bool ) { self.roomToken = roomToken self.server = server diff --git a/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift b/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift index 80ae78bb07..6df9a0b218 100644 --- a/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift +++ b/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift @@ -48,7 +48,7 @@ class SOGSAPISpec: QuickSpec { .when { $0.generate(.randomBytes(24)) } .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), diff --git a/SessionNotificationServiceExtension/NotificationResolution.swift b/SessionNotificationServiceExtension/NotificationResolution.swift index c291628c84..7cb42c61fa 100644 --- a/SessionNotificationServiceExtension/NotificationResolution.swift +++ b/SessionNotificationServiceExtension/NotificationResolution.swift @@ -22,6 +22,7 @@ enum NotificationResolution: CustomStringConvertible { case ignoreDueToDuplicateMessage case ignoreDueToDuplicateCall case ignoreDueToContentSize(Network.PushNotification.NotificationMetadata) + case ignoreDueToCryptoError(CryptoError) case errorTimeout case errorNotReadyForExtensions @@ -29,7 +30,7 @@ enum NotificationResolution: CustomStringConvertible { case errorCallFailure case errorNoContent(Network.PushNotification.NotificationMetadata) case errorProcessing(Network.PushNotification.ProcessResult) - case errorMessageHandling(MessageReceiverError, Network.PushNotification.NotificationMetadata) + case errorMessageHandling(MessageError, Network.PushNotification.NotificationMetadata) case errorOther(Error) public var description: String { @@ -55,6 +56,9 @@ enum NotificationResolution: CustomStringConvertible { case .ignoreDueToContentSize(let metadata): return "Ignored: Notification content from \(metadata.messageOriginString) was too long (\(Format.fileSize(UInt(metadata.dataLength))))" + + case .ignoreDueToCryptoError(let error): + return "Ignored: Crypto error occurred: \(error)" case .errorTimeout: return "Failed: Execution time expired" case .errorNotReadyForExtensions: return "Failed: App not ready for extensions" @@ -77,7 +81,7 @@ enum NotificationResolution: CustomStringConvertible { .ignoreDueToSelfSend, .ignoreDueToNonLegacyGroupLegacyNotification, .ignoreDueToOutdatedMessage, .ignoreDueToRequiresNoNotification, .ignoreDueToMessageRequest, .ignoreDueToDuplicateMessage, .ignoreDueToDuplicateCall, - .ignoreDueToContentSize: + .ignoreDueToContentSize, .ignoreDueToCryptoError: return .info case .errorNotReadyForExtensions, .errorLegacyPushNotification, .errorNoContent, .errorCallFailure: diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index da4e8e35b1..ff7b7c6dcb 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -62,7 +62,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension /// Setup the extension and handle the notification var notificationInfo: NotificationInfo = self.cachedNotificationInfo.with(content: content) - var processedNotification: ProcessedNotification = (self.cachedNotificationInfo, .invalid, "", nil, nil) + var processedNotification: ProcessedNotification = (self.cachedNotificationInfo, nil, "", nil, nil) do { let mainAppUnreadCount: Int = try performSetup(notificationInfo) @@ -104,7 +104,8 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } /// Setup Version Info and Network - dependencies.warmCache(cache: .appVersion) + dependencies.warm(cache: .appVersion) + dependencies.warm(singleton: .sessionProManager) /// Configure the different targets SNUtilitiesKit.configure( @@ -220,12 +221,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension var threadDisplayName: String? switch processedMessage { - case .invalid: throw MessageReceiverError.invalidMessage case .config: threadVariant = nil threadDisplayName = nil - case .standard(let threadId, let threadVariantVal, _, let messageInfo, _): + case .standard(let threadId, let threadVariantVal, let messageInfo, _): threadVariant = threadVariantVal threadDisplayName = dependencies.mutate(cache: .libSession) { cache in cache.conversationDisplayName( @@ -258,7 +258,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension private func handleNotification(_ notification: ProcessedNotification) throws { switch notification.processedMessage { - case .invalid: throw MessageReceiverError.invalidMessage + case .none: throw MessageError.missingRequiredField("processedMessage") case .config(let swarmPublicKey, let namespace, let serverHash, let serverTimestampMs, let data, _): try handleConfigMessage( notification, @@ -269,12 +269,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension data: data ) - case .standard(let threadId, let threadVariant, let proto, let messageInfo, _): + case .standard(let threadId, let threadVariant, let messageInfo, _): try handleStandardMessage( notification, threadId: threadId, threadVariant: threadVariant, - proto: proto, messageInfo: messageInfo ) } @@ -288,8 +287,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension serverTimestampMs: Int64, data: Data ) throws { + guard let processedMessage: ProcessedMessage = notification.processedMessage else { + throw MessageError.missingRequiredField("processedMessage") + } try dependencies.mutate(cache: .libSession) { cache in - try cache.mergeConfigMessages( + let latestServerTimestampsMs: [ConfigDump.Variant: Int64] = try cache.mergeConfigMessages( swarmPublicKey: swarmPublicKey, messages: [ ConfigMessageReceiveJob.Details.MessageInfo( @@ -298,18 +300,24 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension serverTimestampMs: serverTimestampMs, data: data ) - ], - afterMerge: { sessionId, variant, config, timestampMs, _ in - try updateConfigIfNeeded( - cache: cache, - config: config, - variant: variant, - sessionId: sessionId, - timestampMs: timestampMs - ) - return nil - } + ] ) + + try latestServerTimestampsMs.forEach { variant, timestampMs in + let sessionId: SessionId = SessionId(hex: swarmPublicKey, dumpVariant: variant) + + guard let config: LibSession.Config = cache.config(for: variant, sessionId: sessionId) else { + return + } + + try updateConfigIfNeeded( + cache: cache, + config: config, + variant: variant, + sessionId: sessionId, + timestampMs: timestampMs + ) + } } /// Write the message to disk via the `extensionHelper` so the main app will have it immediately instead of having to wait @@ -336,7 +344,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension /// Since we successfully handled the message we should now create the dedupe file for the message so we don't /// show duplicate PNs - try MessageDeduplication.createDedupeFile(notification.processedMessage, using: dependencies) + try MessageDeduplication.createDedupeFile(processedMessage, using: dependencies) /// No notification should be shown for config messages so we can just succeed silently here completeSilenty(notification.info, .success(notification.info.metadata)) @@ -373,7 +381,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension _ notification: ProcessedNotification, threadId: String, threadVariant: SessionThread.Variant, - proto: SNProtoContent, messageInfo: MessageReceiveJob.Details.MessageInfo ) throws { /// Throw if the message is outdated and shouldn't be processed (this is based on pretty flaky logic which checks if the config @@ -391,7 +398,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension let currentUserSessionIds: Set = [userSessionId.hexString] /// Define the `displayNameRetriever` so it can be reused - let displayNameRetriever: (String, Bool) -> String? = { [dependencies] sessionId, isInMessageBody in + let displayNameRetriever: DisplayNameRetriever = { [dependencies] sessionId, inMessageBody in (dependencies .mutate(cache: .libSession) { cache in cache.profile( @@ -402,8 +409,8 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension ) }? .displayName( - for: threadVariant, - suppressId: !isInMessageBody /// Don't want to show the id in a PN unless it's part of the body + /// Don't want to show the id in a PN unless it's part of the body + includeSessionIdSuffix: (threadVariant == .community && inMessageBody) )) .defaulting(to: sessionId.truncated()) } @@ -431,7 +438,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension groupName: inviteMessage.groupName, memberAuthData: inviteMessage.memberAuthData, groupIdentitySeed: nil, - proto: proto, messageInfo: messageInfo, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever @@ -447,7 +453,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension let groupIdentityKeyPair: KeyPair = dependencies[singleton: .crypto].generate( .ed25519KeyPair(seed: Array(promoteMessage.groupIdentitySeed)) ) - else { throw MessageReceiverError.invalidMessage } + else { throw CryptoError.invalidSeed } try handleGroupInviteOrPromotion( notification, @@ -455,7 +461,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension groupName: promoteMessage.groupName, memberAuthData: nil, groupIdentitySeed: promoteMessage.groupIdentitySeed, - proto: proto, messageInfo: messageInfo, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever @@ -555,13 +560,13 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension case (_, _, .endCall): break case (true, true, _): guard let sender: String = callMessage.sender else { - throw MessageReceiverError.invalidMessage + throw MessageError.missingRequiredField("sender") } guard let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) ) - else { throw SnodeAPIError.noKeyPair } + else { throw CryptoError.invalidSeed } Log.info(.calls, "Sending end call message because there is an ongoing call.") /// Update the `CallMessage.state` value so the correct notification logic can occur @@ -608,12 +613,16 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension libSession.isMessageRequest(threadId: threadId, threadVariant: threadVariant) } + guard let sender: String = callMessage.sender, !sender.isEmpty else { + throw MessageError.missingRequiredField("sender") + } + guard let sentTimestampMs: UInt64 = callMessage.sentTimestampMs, sentTimestampMs > 0 else { + throw MessageError.missingRequiredField("sentTimestampMs") + } guard - let sender: String = callMessage.sender, - let sentTimestampMs: UInt64 = callMessage.sentTimestampMs, threadVariant == .contact, !isMessageRequest - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Calls are only supported in 1-to-1 conversations") } /// Save the message and generate any deduplication files needed try saveMessage( @@ -662,7 +671,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension notification, threadId: threadId, threadVariant: threadVariant, - proto: proto, messageInfo: messageInfo, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever @@ -678,10 +686,9 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension groupName: String, memberAuthData: Data?, groupIdentitySeed: Data?, - proto: SNProtoContent, messageInfo: MessageReceiveJob.Details.MessageInfo, currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) throws { typealias GroupInfo = ( wasMessageRequest: Bool, @@ -799,7 +806,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension notification, threadId: groupSessionId.hexString, threadVariant: .group, - proto: proto, messageInfo: messageInfo, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever @@ -842,11 +848,15 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension messageInfo: MessageReceiveJob.Details.MessageInfo, currentUserSessionIds: Set ) throws { + guard let processedMessage: ProcessedMessage = notification.processedMessage else { + throw MessageError.missingRequiredField("processedMessage") + } + /// Write the message to disk via the `extensionHelper` so the main app will have it immediately instead of having to wait /// for a poll to return do { - guard let sentTimestamp: Int64 = messageInfo.message.sentTimestampMs.map(Int64.init) else { - throw MessageReceiverError.invalidMessage + guard let sentTimestamp: UInt64 = messageInfo.message.sentTimestampMs, sentTimestamp > 0 else { + throw MessageError.missingRequiredField("sentTimestamp") } try dependencies[singleton: .extensionHelper].saveMessage( @@ -873,7 +883,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension !cache.timestampAlreadyRead( threadId: threadId, threadVariant: threadVariant, - timestampMs: (messageInfo.message.sentTimestampMs.map { Int64($0) } ?? 0), /// Default to unread + timestampMs: messageInfo.decodedMessage.sentTimestampMs, openGroupUrlInfo: nil /// Communities currently don't support PNs ) }) && @@ -913,7 +923,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension /// **Note:** If we fail to write the message to disk then we don't want to create the dedupe files as that would mean /// when the main app receives the message it would incorrectly be considered a duplicate (due to the dedupe file) so /// in that case the user may receive duplicate PNs (as the lesser of the two evils) - try MessageDeduplication.createDedupeFile(notification.processedMessage, using: dependencies) + try MessageDeduplication.createDedupeFile(processedMessage, using: dependencies) try MessageDeduplication.createCallDedupeFilesIfNeeded( threadId: threadId, callMessage: messageInfo.message as? CallMessage, @@ -927,10 +937,9 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension _ notification: ProcessedNotification, threadId: String, threadVariant: SessionThread.Variant, - proto: SNProtoContent, messageInfo: MessageReceiveJob.Details.MessageInfo, currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) throws { /// Since we are going to save the message and generate deduplication files we need to determine whether we would want /// to show the message in case it is a message request (this is done by checking if there are already any dedupe records @@ -956,6 +965,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension ) /// Try to show a notification for the message + let proto: SNProtoContent = try messageInfo.decodedMessage.decodeProtoContent() try dependencies[singleton: .notificationsManager].notifyUser( cat: .cat, message: messageInfo.message, @@ -1025,44 +1035,44 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension case (NotificationError.processingError(let result, let errorMetadata), _, _): self.completeSilenty(info.with(metadata: errorMetadata), .errorProcessing(result)) + + case (CryptoError.invalidSeed, _, _): + self.completeSilenty(info, .ignoreDueToCryptoError(.invalidSeed)) - case (MessageReceiverError.selfSend, _, _): + case (MessageError.selfSend, _, _): self.completeSilenty(info, .ignoreDueToSelfSend) - case (MessageReceiverError.noGroupKeyPair, _, _): - self.completeSilenty(info, .errorLegacyPushNotification) - - case (MessageReceiverError.outdatedMessage, _, _): + case (MessageError.outdatedMessage, _, _): self.completeSilenty(info, .ignoreDueToOutdatedMessage) - case (MessageReceiverError.ignorableMessage, _, _): + case (MessageError.ignorableMessage, _, _): self.completeSilenty(info, .ignoreDueToRequiresNoNotification) - case (MessageReceiverError.ignorableMessageRequestMessage, _, _): + case (MessageError.ignorableMessageRequestMessage, _, _): self.completeSilenty(info, .ignoreDueToMessageRequest) - case (MessageReceiverError.duplicateMessage, _, _): + case (MessageError.duplicateMessage, _, _): self.completeSilenty(info, .ignoreDueToDuplicateMessage) - case (MessageReceiverError.duplicatedCall, _, _): + case (MessageError.duplicatedCall, _, _): self.completeSilenty(info, .ignoreDueToDuplicateCall) - /// If it was a `decryptionFailed` error, but it was for a config namespace then just fail silently (don't + /// If it was a `decodingFailed` error, but it was for a config namespace then just fail silently (don't /// want to show the fallback notification in this case) - case (MessageReceiverError.decryptionFailed, _, true): - self.completeSilenty(info, .errorMessageHandling(.decryptionFailed, info.metadata)) + case (MessageError.decodingFailed, _, true): + self.completeSilenty(info, .errorMessageHandling(.decodingFailed, info.metadata)) - /// If it was a `decryptionFailed` error for a group conversation and the group doesn't exist or + /// If it was a `decodingFailed` error for a group conversation and the group doesn't exist or /// doesn't have auth info (ie. group destroyed or member kicked), then just fail silently (don't want /// to show the fallback notification in these cases) - case (MessageReceiverError.decryptionFailed, .group, _): + case (MessageError.decodingFailed, .group, _): guard let threadId: String = processedNotification?.threadId, dependencies.mutate(cache: .libSession, { cache in cache.hasCredentials(groupSessionId: SessionId(.group, hex: threadId)) }) else { - self.completeSilenty(info, .errorMessageHandling(.decryptionFailed, info.metadata)) + self.completeSilenty(info, .errorMessageHandling(.decodingFailed, info.metadata)) return } @@ -1072,10 +1082,10 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension threadId: processedNotification?.threadId, threadVariant: processedNotification?.threadVariant, threadDisplayName: processedNotification?.threadDisplayName, - resolution: .errorMessageHandling(.decryptionFailed, info.metadata) + resolution: .errorMessageHandling(.decodingFailed, info.metadata) ) - case (let msgError as MessageReceiverError, _, _): + case (let msgError as MessageError, _, _): self.handleFailure( info, threadId: processedNotification?.threadId, @@ -1139,7 +1149,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension callMessage: CallMessage, sender: String, sentTimestampMs: UInt64, - displayNameRetriever: @escaping (String, Bool) -> String? + displayNameRetriever: @escaping DisplayNameRetriever ) { guard Preferences.isCallKitSupported else { Log.info(.cat, "CallKit not supported, handling call as a failure, requestId: \(notification.info.requestId).") @@ -1155,8 +1165,10 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension VoipPayloadKey.uuid.rawValue: callMessage.uuid, VoipPayloadKey.caller.rawValue: sender, VoipPayloadKey.timestamp.rawValue: sentTimestampMs, - VoipPayloadKey.contactName.rawValue: displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + VoipPayloadKey.contactName.rawValue: ( + displayNameRetriever(sender, false) ?? + sender.truncated() + ) ] Log.info(.cat, "Notifying CallKit of new call, requestId: \(notification.info.requestId).") @@ -1186,7 +1198,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension _ notification: ProcessedNotification, threadVariant: SessionThread.Variant, callMessage: CallMessage, - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) { if isAlreadyCompleted() { Log.info(.cat, "Extension already completed, skipping VoIP failure handling for requestId: \(notification.info.requestId).") @@ -1378,7 +1390,7 @@ private extension NotificationServiceExtension { typealias ProcessedNotification = ( info: NotificationInfo, - processedMessage: ProcessedMessage, + processedMessage: ProcessedMessage?, threadId: String, threadVariant: SessionThread.Variant?, threadDisplayName: String? diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index cd4774b994..fe693feece 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -43,7 +43,7 @@ final class ShareNavController: UINavigationController { guard !SNUtilitiesKit.isRunningTests else { return } - dependencies.warmCache(cache: .appVersion) + dependencies.warm(cache: .appVersion) AppSetup.setupEnvironment( appSpecificBlock: { [dependencies] in @@ -60,7 +60,8 @@ final class ShareNavController: UINavigationController { // stringlint:ignore_stop // Setup LibSession - dependencies.warmCache(cache: .libSessionNetwork) + dependencies.warm(cache: .libSessionNetwork) + dependencies.warm(singleton: .sessionProManager) // Configure the different targets SNUtilitiesKit.configure( @@ -414,7 +415,7 @@ final class ShareNavController: UINavigationController { case .none: return continuation.resume( throwing: ShareViewControllerError.assertionError( - description: "missing item provider" + description: "missing item provider" // stringlint:ignore ) ) @@ -422,7 +423,7 @@ final class ShareNavController: UINavigationController { guard let tempFilePath = try? dependencies[singleton: .fileManager].write(dataToTemporaryFile: data) else { return continuation.resume( throwing: ShareViewControllerError.assertionError( - description: "Error writing item data" + description: "Error writing item data" // stringlint:ignore ) ) } @@ -477,7 +478,7 @@ final class ShareNavController: UINavigationController { catch { return continuation.resume( throwing: ShareViewControllerError.assertionError( - description: "Failed to copy temporary file: \(error)" + description: "Failed to copy temporary file: \(error)" // stringlint:ignore ) ) } @@ -496,7 +497,7 @@ final class ShareNavController: UINavigationController { // don't know how to handle. return continuation.resume( throwing: ShareViewControllerError.assertionError( - description: "Unexpected value: \(String(describing: value))" + description: "Unexpected value: \(String(describing: value))" // stringlint:ignore ) ) } @@ -759,9 +760,21 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { } @MainActor func numberOfCharactersLeft(for text: String) -> Int { - return LibSession.numberOfCharactersLeft( - for: text, - isSessionPro: dependencies[cache: .libSession].isSessionPro - ) + return dependencies[singleton: .sessionProManager].numberOfCharactersLeft(for: text) + } + + func urlStringProvider() -> StringProvider.Url { + return Constants.urls + } + + func buildVariantStringProvider() -> StringProvider.BuildVariant { + return Constants.buildVariants + } + + func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> StringProvider.ClientPlatform { + switch platform { + case .iOS: return Constants.PaymentProvider.appStore + case .android: return Constants.PaymentProvider.playStore + } } } diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index 7d3c4d4047..aa7b92ceb5 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -92,22 +92,22 @@ final class SimplifiedConversationCell: UITableViewCell { // MARK: - Updating - public func update(with cellViewModel: SessionThreadViewModel, using dependencies: Dependencies) { - accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0) + public func update(with cellViewModel: ConversationInfoViewModel, using dependencies: Dependencies) { + accentLineView.alpha = (cellViewModel.isBlocked ? 1 : 0) profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) profilePictureView.update( - publicKey: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant, - displayPictureUrl: cellViewModel.threadDisplayPictureUrl, + publicKey: cellViewModel.id, + threadVariant: cellViewModel.variant, + displayPictureUrl: cellViewModel.displayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies ) - displayNameLabel.text = cellViewModel.displayName - displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) + displayNameLabel.themeAttributedText = cellViewModel.displayName.formatted(baseFont: displayNameLabel.font) + displayNameLabel.isProBadgeHidden = !cellViewModel.shouldShowProBadge self.isAccessibilityElement = true self.accessibilityIdentifier = "Contact" - self.accessibilityLabel = cellViewModel.displayName + self.accessibilityLabel = cellViewModel.displayName.deformatted() } } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 7f9e8bebdc..f4f4399d47 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -170,7 +170,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView dataChangeObservable = nil } - private func handleUpdates(_ updatedViewData: [SessionThreadViewModel]) { + private func handleUpdates(_ updatedViewData: [ConversationInfoViewModel]) { // Ensure the first load runs without animations (if we don't do this the cells will animate // in from a frame of CGRect.zero) guard hasLoadedInitialData else { @@ -224,12 +224,12 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView let viewController: AttachmentApprovalViewController = AttachmentApprovalViewController( mode: .modal, delegate: self, - threadId: viewModel.viewData[indexPath.row].threadId, - threadVariant: viewModel.viewData[indexPath.row].threadVariant, + threadId: viewModel.viewData[indexPath.row].id, + threadVariant: viewModel.viewData[indexPath.row].variant, attachments: attachments, messageText: nil, quoteViewModel: nil, - disableLinkPreviewImageDownload: (viewModel.viewData[indexPath.row].threadCanUpload != true), + disableLinkPreviewImageDownload: !viewModel.viewData[indexPath.row].canUpload, didLoadLinkPreview: { [weak self] result in self?.viewModel.didLoadLinkPreview(result: result) }, @@ -342,16 +342,18 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView let shareData: ShareDatabaseData = try await dependencies[singleton: .storage].writeAsync { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { - throw MessageSenderError.noThread + throw MessageError.messageRequiresThreadToExistButThreadDoesNotExist } /// Update the thread to be visible (if it isn't already) if !thread.shouldBeVisible || thread.pinnedPriority == LibSession.hiddenPriority { - try SessionThread.updateVisibility( + try SessionThread.update( db, - threadId: threadId, - isVisible: true, - additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], + id: threadId, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true), + isDraft: .setTo(false) + ), using: dependencies ) } @@ -388,7 +390,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView if isSharingUrl, let linkPreviewViewModel: LinkPreviewViewModel = linkPreviewViewModel, - (try? interaction.linkPreview.isEmpty(db)) == true + (((try? Interaction + .linkPreview(url: interaction.linkPreviewUrl, timestampMs: interaction.timestampMs)? + .fetchCount(db)) ?? 0) == 0) { try LinkPreview( url: linkPreviewViewModel.urlString, diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index cf279e19b7..80d4e6c773 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -43,7 +43,7 @@ public class ThreadPickerViewModel { // MARK: - Content /// This value is the current state of the view - public private(set) var viewData: [SessionThreadViewModel] = [] + public private(set) var viewData: [ConversationInfoViewModel] = [] /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise /// performance https://github.com/groue/GRDB.swift#valueobservation-performance @@ -56,43 +56,67 @@ public class ThreadPickerViewModel { /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this public lazy var observableViewData = ValueObservation - .trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in - let userSessionId: SessionId = dependencies[cache: .general].sessionId + .trackingConstantRegion { [dependencies] db -> ([String], ConversationDataCache) in + var dataCache: ConversationDataCache = ConversationDataCache( + userSessionId: dependencies[cache: .general].sessionId, + context: ConversationDataCache.Context( + source: .conversationList, + requireFullRefresh: true, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ) + let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements( + for: .empty, + currentCache: dataCache, + itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel](), + loadPageEvent: .initial + ) + + /// Fetch any required data from the cache + var loadResult: PagedData.LoadResult = PagedData.LoadedInfo( + record: SessionThread.self, + pageSize: Int.max, + requiredJoinSQL: ConversationInfoViewModel.requiredJoinSQL, + filterSQL: ConversationInfoViewModel.homeFilterSQL(userSessionId: dataCache.userSessionId), + groupSQL: nil, + orderSQL: ConversationInfoViewModel.homeOrderSQL + ).asResult + (loadResult, dataCache) = try ConversationDataHelper.fetchFromDatabase( + ObservingDatabase.create(db, using: dependencies), + requirements: fetchRequirements, + currentCache: dataCache, + loadResult: loadResult, + loadPageEvent: .initial, + using: dependencies + ) + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) - return try SessionThreadViewModel - .shareQuery(userSessionId: userSessionId) - .fetchAll(db) - .map { threadViewModel in - let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { - guard threadViewModel.threadVariant == .group else { return (false, false) } - - let sessionId: SessionId = SessionId(.group, hex: threadViewModel.threadId) - return dependencies.mutate(cache: .libSession) { cache in - ( - cache.wasKickedFromGroup(groupSessionId: sessionId), - cache.groupIsDestroyed(groupSessionId: sessionId) - ) - } - }() + return (loadResult.info.currentIds, dataCache) + } + .map { [dependencies, hasNonTextAttachment] threadIds, dataCache -> [ConversationInfoViewModel] in + threadIds + .compactMap { id in + guard let thread: SessionThread = dataCache.thread(for: id) else { return nil } - return threadViewModel.populatingPostQueryData( - recentReactionEmoji: nil, - openGroupCapabilities: nil, - currentUserSessionIds: [userSessionId.hexString], - wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed, - threadCanWrite: threadViewModel.determineInitialCanWriteFlag(using: dependencies), - threadCanUpload: threadViewModel.determineInitialCanUploadFlag(using: dependencies) + return ConversationInfoViewModel( + thread: thread, + dataCache: dataCache, + using: dependencies + ) + } + .filter { + $0.canWrite && ( /// Exclude unwritable threads + $0.canUpload == true || /// Exclude ununploadable threads unleass we only include text-based attachments + !hasNonTextAttachment ) } - } - .map { [dependencies, hasNonTextAttachment] threads -> [SessionThreadViewModel] in - threads.filter { - $0.threadCanWrite == true && ( /// Exclude unwritable threads - $0.threadCanUpload == true || /// Exclude ununploadable threads unleass we only include text-based attachments - !hasNonTextAttachment - ) - } } .removeDuplicates() .handleEvents(didFail: { Log.error("Observation failed with error: \($0)") }) @@ -106,7 +130,7 @@ public class ThreadPickerViewModel { } } - public func updateData(_ updatedData: [SessionThreadViewModel]) { + public func updateData(_ updatedData: [ConversationInfoViewModel]) { self.viewData = updatedData } } diff --git a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift index beaaa7ed26..350ae49591 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -44,8 +44,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: AsyncSpec { @TestState var viewModel: ThreadDisappearingMessagesSettingsViewModel! = ThreadDisappearingMessagesSettingsViewModel( threadId: "TestId", threadVariant: .contact, - currentUserIsClosedGroupMember: nil, - currentUserIsClosedGroupAdmin: nil, + currentUserRole: nil, config: DisappearingMessagesConfiguration.defaultWith("TestId"), using: dependencies ) @@ -143,8 +142,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: AsyncSpec { viewModel = ThreadDisappearingMessagesSettingsViewModel( threadId: "TestId", threadVariant: .contact, - currentUserIsClosedGroupMember: nil, - currentUserIsClosedGroupAdmin: nil, + currentUserRole: nil, config: config, using: dependencies ) @@ -265,8 +263,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: AsyncSpec { viewModel = ThreadDisappearingMessagesSettingsViewModel( threadId: "TestId", threadVariant: .contact, - currentUserIsClosedGroupMember: nil, - currentUserIsClosedGroupAdmin: nil, + currentUserRole: nil, config: config, using: dependencies ) diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index b1c9946e0b..a9282eabfa 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -36,8 +36,30 @@ class ThreadSettingsViewModelSpec: AsyncSpec { variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey) ).insert(db) - try Profile(id: userPubkey, name: "TestMe").insert(db) - try Profile(id: user2Pubkey, name: "TestUser").insert(db) + try Profile( + id: userPubkey, + name: "TestMe", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil + ).insert(db) + try Profile( + id: user2Pubkey, + name: "TestUser", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil + ).insert(db) } ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( @@ -129,9 +151,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: user2Pubkey, - threadVariant: .contact, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: user2Pubkey, + variant: .contact, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: user2Pubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -164,9 +205,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: userPubkey, - threadVariant: .contact, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: userPubkey, + variant: .contact, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: userPubkey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: userPubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -177,7 +237,8 @@ class ThreadSettingsViewModelSpec: AsyncSpec { // MARK: ---- has the correct title it("has the correct title") { - expect(viewModel.title).to(equal("sessionSettings".localized())) + await expect { await viewModel.title } + .toEventually(equal("sessionSettings".localized())) } // MARK: ---- has the correct display name @@ -217,9 +278,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: user2Pubkey, - threadVariant: .contact, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: user2Pubkey, + variant: .contact, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: user2Pubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -230,7 +310,8 @@ class ThreadSettingsViewModelSpec: AsyncSpec { // MARK: ---- has the correct title it("has the correct title") { - expect(viewModel.title).to(equal("sessionSettings".localized())) + await expect { await viewModel.title } + .toEventually(equal("sessionSettings".localized())) } // MARK: ---- has the correct display name @@ -377,9 +458,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: legacyGroupPubkey, - threadVariant: .legacyGroup, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: legacyGroupPubkey, + variant: .legacyGroup, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: legacyGroupPubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -390,7 +490,8 @@ class ThreadSettingsViewModelSpec: AsyncSpec { // MARK: ---- has the correct title it("has the correct title") { - expect(viewModel.title).to(equal("deleteAfterGroupPR1GroupSettings".localized())) + await expect { await viewModel.title } + .toEventually(equal("deleteAfterGroupPR1GroupSettings".localized())) } // MARK: ---- has the correct display name @@ -436,9 +537,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: legacyGroupPubkey, - threadVariant: .legacyGroup, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: legacyGroupPubkey, + variant: .legacyGroup, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: legacyGroupPubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -492,9 +612,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: groupPubkey, - threadVariant: .group, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: groupPubkey, + variant: .group, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: groupPubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -505,7 +644,8 @@ class ThreadSettingsViewModelSpec: AsyncSpec { // MARK: ---- has the correct title it("has the correct title") { - expect(viewModel.title).to(equal("deleteAfterGroupPR1GroupSettings".localized())) + await expect { await viewModel.title } + .toEventually(equal("deleteAfterGroupPR1GroupSettings".localized())) } // MARK: ---- has the correct display name @@ -556,9 +696,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: groupPubkey, - threadVariant: .group, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: groupPubkey, + variant: .group, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: groupPubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -587,9 +746,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { beforeEach { dependencies[feature: .updatedGroupsAllowDescriptionEditing] = true - viewModel = ThreadSettingsViewModel( - threadId: groupPubkey, - threadVariant: .group, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: groupPubkey, + variant: .group, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: groupPubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -725,7 +903,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { threadId: groupPubkey, interactionId: nil, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupPubkey), + destination: .group(publicKey: groupPubkey), message: try GroupUpdateInfoChangeMessage( changeType: .name, updatedName: "TestNewGroupName", @@ -793,16 +971,35 @@ class ThreadSettingsViewModelSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: TestConstants.serverPublicKey, - isActive: false, + shouldPoll: false, name: "TestCommunity", userCount: 1, infoUpdates: 1 ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: communityId, - threadVariant: .community, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: communityId, + variant: .community, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: communityId), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -813,7 +1010,8 @@ class ThreadSettingsViewModelSpec: AsyncSpec { // MARK: ---- has the correct title it("has the correct title") { - expect(viewModel.title).to(equal("deleteAfterGroupPR1GroupSettings".localized())) + await expect { await viewModel.title } + .toEventually(equal("deleteAfterGroupPR1GroupSettings".localized())) } // MARK: ---- has the correct display name diff --git a/SessionTests/Database/DatabaseSpec.swift b/SessionTests/Database/DatabaseSpec.swift index 9dede2231b..628527ad38 100644 --- a/SessionTests/Database/DatabaseSpec.swift +++ b/SessionTests/Database/DatabaseSpec.swift @@ -239,7 +239,9 @@ class DatabaseSpec: QuickSpec { "messagingKit.RenameAttachments", "messagingKit.AddProMessageFlag", "LastProfileUpdateTimestamp", - "RemoveQuoteUnusedColumnsAndForeignKeys" + "RemoveQuoteUnusedColumnsAndForeignKeys", + "DropUnneededColumnsAndTables", + "SessionProChanges" ])) } diff --git a/SessionTests/Onboarding/OnboardingSpec.swift b/SessionTests/Onboarding/OnboardingSpec.swift index 1ac596f4aa..e474a4f2e1 100644 --- a/SessionTests/Onboarding/OnboardingSpec.swift +++ b/SessionTests/Onboarding/OnboardingSpec.swift @@ -39,7 +39,7 @@ class OnboardingSpec: AsyncSpec { .when { $0.generate(.randomBytes(.any)) } .thenReturn(Data([1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8])) crypto - .when { $0.generate(.ed25519Seed(ed25519SecretKey: .any)) } + .when { $0.generate(.ed25519Seed(ed25519SecretKey: Array.any)) } .thenReturn(Data([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, @@ -47,7 +47,7 @@ class OnboardingSpec: AsyncSpec { 1, 2 ])) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), @@ -223,7 +223,7 @@ class OnboardingSpec: AsyncSpec { beforeEach { mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) mockCrypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn(KeyPair(publicKey: [1, 2, 3], secretKey: [4, 5, 6])) mockCrypto .when { $0.generate(.x25519(ed25519Pubkey: .any)) } @@ -250,7 +250,7 @@ class OnboardingSpec: AsyncSpec { .when { $0.ed25519SecretKey } .thenReturn(Array(Data(hex: TestConstants.edSecretKey))) mockCrypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), @@ -296,10 +296,10 @@ class OnboardingSpec: AsyncSpec { // MARK: ---- and failing to generate an x25519KeyPair context("and failing to generate an x25519KeyPair") { beforeEach { - mockCrypto.removeMocksFor { $0.generate(.ed25519KeyPair(seed: .any)) } - mockCrypto.removeMocksFor { $0.generate(.ed25519Seed(ed25519SecretKey: .any)) } + mockCrypto.removeMocksFor { $0.generate(.ed25519KeyPair(seed: Array.any)) } + mockCrypto.removeMocksFor { $0.generate(.ed25519Seed(ed25519SecretKey: Array.any)) } mockCrypto - .when { try $0.tryGenerate(.ed25519Seed(ed25519SecretKey: .any)) } + .when { try $0.tryGenerate(.ed25519Seed(ed25519SecretKey: Array.any)) } .thenThrow(MockError.mockedData) mockCrypto .when { @@ -367,7 +367,20 @@ class OnboardingSpec: AsyncSpec { visibleMessage: .any ) } - .thenReturn(Profile(id: "TestProfileId", name: "TestProfileName")) + .thenReturn( + Profile( + id: "TestProfileId", + name: "TestProfileName", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil + ) + ) } // MARK: ------ loads from libSession @@ -396,10 +409,10 @@ class OnboardingSpec: AsyncSpec { // MARK: ------ after generating new credentials context("after generating new credentials") { beforeEach { - mockCrypto.removeMocksFor { $0.generate(.ed25519KeyPair(seed: .any)) } - mockCrypto.removeMocksFor { $0.generate(.ed25519Seed(ed25519SecretKey: .any)) } + mockCrypto.removeMocksFor { $0.generate(.ed25519KeyPair(seed: Array.any)) } + mockCrypto.removeMocksFor { $0.generate(.ed25519Seed(ed25519SecretKey: Array.any)) } mockCrypto - .when { try $0.tryGenerate(.ed25519Seed(ed25519SecretKey: .any)) } + .when { try $0.tryGenerate(.ed25519Seed(ed25519SecretKey: Array.any)) } .thenThrow(MockError.mockedData) mockCrypto .when { @@ -458,7 +471,7 @@ class OnboardingSpec: AsyncSpec { describe("an Onboarding Cache when setting seed data") { beforeEach { mockCrypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), @@ -642,7 +655,10 @@ class OnboardingSpec: AsyncSpec { displayPictureUrl: nil, displayPictureEncryptionKey: nil, profileLastUpdated: 12345678900, - blocksCommunityMessageRequests: nil + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) ])) } @@ -681,7 +697,10 @@ class OnboardingSpec: AsyncSpec { displayPictureUrl: nil, displayPictureEncryptionKey: nil, profileLastUpdated: profile.profileLastUpdated, - blocksCommunityMessageRequests: nil + blocksCommunityMessageRequests: true, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )) expect(profile.profileLastUpdated).toNot(beNil()) @@ -852,6 +871,7 @@ class OnboardingSpec: AsyncSpec { displayName: .set(to: "TestPolledName"), displayPictureUrl: .set(to: "http://filev2.getsession.org/file/1234"), displayPictureEncryptionKey: .set(to: Data([1, 2, 3])), + proProfileFeatures: .set(to: .none), isReuploadProfilePicture: false ) testCacheProfile = cache.profile diff --git a/SessionUIKit/Components/Input View/InputView.swift b/SessionUIKit/Components/Input View/InputView.swift index aa7dff9329..157a06f363 100644 --- a/SessionUIKit/Components/Input View/InputView.swift +++ b/SessionUIKit/Components/Input View/InputView.swift @@ -56,17 +56,16 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele private static let linkPreviewViewInset: CGFloat = 6 private static let thresholdForCharacterLimit: Int = 200 - private var disposables: Set = Set() private let imageDataManager: ImageDataManagerType private let linkPreviewManager: LinkPreviewManagerType private let didLoadLinkPreview: (@MainActor (LinkPreviewViewModel.LoadResult) -> Void)? - private let displayNameRetriever: (String, Bool) -> String? private let onQuoteCancelled: (() -> Void)? private weak var delegate: InputViewDelegate? - private var sessionProStatePublisher: AnyPublisher + private var sessionProManager: SessionProUIManagerType? public var quoteViewModel: QuoteViewModel? { didSet { handleQuoteDraftChanged() } } public var linkPreviewViewModel: LinkPreviewViewModel? + private var proStatusObservationTask: Task? private var linkPreviewLoadTask: Task? private var voiceMessageRecordingView: VoiceMessageRecordingView? private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) @@ -236,21 +235,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele }() private lazy var quoteView: QuoteView = QuoteView( - viewModel: QuoteViewModel( - mode: .draft, - direction: .outgoing, - currentUserSessionIds: [], - rowId: 0, - interactionId: nil, - authorId: "", - showProBadge: false, - timestampMs: 0, - quotedInteractionId: 0, - quotedInteractionIsDeleted: false, - quotedText: nil, - quotedAttachmentInfo: nil, - displayNameRetriever: displayNameRetriever - ), + viewModel: .emptyDraft, dataManager: imageDataManager, onCancel: { [weak self] in self?.quoteViewModel = nil @@ -317,8 +302,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele private lazy var sessionProBadge: SessionProBadge = { let result: SessionProBadge = SessionProBadge(size: .medium) - // TODO: [PRO] Need to add this back -// result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro + result.isHidden = (sessionProManager?.currentUserIsCurrentlyPro == true) return result }() @@ -356,18 +340,16 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele public init( delegate: InputViewDelegate, - displayNameRetriever: @escaping (String, Bool) -> String?, imageDataManager: ImageDataManagerType, linkPreviewManager: LinkPreviewManagerType, - sessionProStatePublisher: AnyPublisher, + sessionProManager: SessionProUIManagerType?, onQuoteCancelled: (() -> Void)? = nil, didLoadLinkPreview: (@MainActor (LinkPreviewViewModel.LoadResult) -> Void)? ) { self.imageDataManager = imageDataManager self.linkPreviewManager = linkPreviewManager self.delegate = delegate - self.displayNameRetriever = displayNameRetriever - self.sessionProStatePublisher = sessionProStatePublisher + self.sessionProManager = sessionProManager self.didLoadLinkPreview = didLoadLinkPreview self.onQuoteCancelled = onQuoteCancelled @@ -375,16 +357,17 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele setUpViewHierarchy() - self.sessionProStatePublisher - .subscribe(on: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink( - receiveValue: { [weak self] isPro in + self.proStatusObservationTask = Task(priority: .userInitiated) { [weak self] in + guard let sessionProManager else { return } + + for await isPro in sessionProManager.currentUserIsPro { + await MainActor.run { [weak self] in + /// The pro badge is a button to prompt a pro upgrade so hide it when already pro self?.sessionProBadge.isHidden = isPro self?.updateNumberOfCharactersLeft((self?.inputTextView.text ?? "")) } - ) - .store(in: &disposables) + } + } } override init(frame: CGRect) { @@ -397,6 +380,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele deinit { linkPreviewLoadTask?.cancel() + proStatusObservationTask?.cancel() } private func setUpViewHierarchy() { diff --git a/SessionUIKit/Components/LinkPreviewView.swift b/SessionUIKit/Components/LinkPreviewView.swift index aeb89c5f98..1ea463359f 100644 --- a/SessionUIKit/Components/LinkPreviewView.swift +++ b/SessionUIKit/Components/LinkPreviewView.swift @@ -5,8 +5,8 @@ import NVActivityIndicatorView // MARK: - LinkPreviewViewModel -public struct LinkPreviewViewModel { - public enum State { +public struct LinkPreviewViewModel: Sendable, Equatable, Hashable { + public enum State: Sendable, Equatable, Hashable { case loading case draft case sent diff --git a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift index 760d012bb9..6a455b84a9 100644 --- a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift +++ b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift @@ -517,6 +517,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { options: options.enumerated().map { otherIndex, otherInfo in Info.Body.RadioOptionInfo( title: otherInfo.title, + descriptionText: otherInfo.descriptionText, enabled: otherInfo.enabled, selected: (index == otherIndex), accessibility: otherInfo.accessibility @@ -527,6 +528,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { ) } radioButton.text = optionInfo.title + radioButton.descriptionText = optionInfo.descriptionText radioButton.accessibilityLabel = optionInfo.accessibility?.label radioButton.accessibilityIdentifier = optionInfo.accessibility?.identifier radioButton.update(isEnabled: optionInfo.enabled, isSelected: optionInfo.selected) @@ -540,7 +542,53 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { mainStackView.spacing = 0 contentStackView.spacing = Values.verySmallSpacing proDescriptionLabelContainer.isHidden = (description == nil) - proDescriptionLabel.themeAttributedText = description + + if let description { + var result: ThemedAttributedString = ThemedAttributedString() + + if let attributedString: ThemedAttributedString = description.attributedString { + result.append(attributedString) + } + else if let text: String = description.text { + result.append(ThemedAttributedString(string: text)) + } + + switch description.accessory { + case .none: break + case .proBadgeLeading(let size, let themeBackgroundColor): + let proBadgeImage: UIImage = UIView.image( + for: .themedKey(size.cacheKey, themeBackgroundColor: themeBackgroundColor), + generator: { SessionProBadge(size: size) } + ) + result.insert(ThemedAttributedString(string: " "), at: 0) + result.insert( + ThemedAttributedString( + image: proBadgeImage, + accessibilityLabel: SessionProBadge.accessibilityLabel, + font: proDescriptionLabel.font + ), + at: 0 + ) + + case .proBadgeTrailing(let size, let themeBackgroundColor): + let proBadgeImage: UIImage = UIView.image( + for: .themedKey(size.cacheKey, themeBackgroundColor: themeBackgroundColor), + generator: { SessionProBadge(size: size) } + ) + + result.append(ThemedAttributedString(string: " ")) + result.append( + ThemedAttributedString( + image: proBadgeImage, + accessibilityLabel: SessionProBadge.accessibilityLabel, + font: proDescriptionLabel.font + ) + ) + } + + proDescriptionLabel.themeAttributedText = result + } + imageViewContainer.isHidden = false profileView.clipsToBounds = (style == .circular) profileView.setDataManager(dataManager) @@ -973,17 +1021,20 @@ public extension ConfirmationModal.Info { public struct RadioOptionInfo: Equatable, Hashable { public let title: String + public let descriptionText: ThemedAttributedString? public let enabled: Bool public let selected: Bool public let accessibility: Accessibility? public init( title: String, + descriptionText: ThemedAttributedString? = nil, enabled: Bool, selected: Bool = false, accessibility: Accessibility? = nil ) { self.title = title + self.descriptionText = descriptionText self.enabled = enabled self.selected = selected self.accessibility = accessibility @@ -1020,10 +1071,10 @@ public extension ConfirmationModal.Info { placeholder: ImageDataManager.DataSource?, icon: ProfilePictureView.Info.ProfileIcon = .none, style: ImageStyle, - description: ThemedAttributedString?, + description: SessionListScreenContent.TextInfo?, accessibility: Accessibility?, dataManager: ImageDataManagerType, - onProBageTapped: (() -> Void)?, + onProBageTapped: (@MainActor () -> Void)?, onClick: (@MainActor (@escaping (ConfirmationModal.ValueUpdate) -> Void) -> Void) ) diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index e1181e0365..954d5c033f 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -87,8 +87,7 @@ public final class ProfilePictureView: UIView { } } - // TODO: [PRO] Should be able to remove the "public" once `MessageInfoScreen.getProFeaturesInfo()` has been updated - public let source: ImageDataManager.DataSource? + let source: ImageDataManager.DataSource? let canAnimate: Bool let renderingMode: UIImage.RenderingMode? let themeTintColor: ThemeValue? diff --git a/SessionUIKit/Components/QuoteView.swift b/SessionUIKit/Components/QuoteView.swift index 8145f66a8c..0a4882a662 100644 --- a/SessionUIKit/Components/QuoteView.swift +++ b/SessionUIKit/Components/QuoteView.swift @@ -94,7 +94,7 @@ public final class QuoteView: UIView { imageView.center(in: imageContainerView) // Generate the thumbnail if needed - if let source: ImageDataManager.DataSource = viewModel.quotedAttachmentInfo?.thumbnailSource { + if let source: ImageDataManager.DataSource = viewModel.quotedInfo?.attachmentInfo?.thumbnailSource { imageView.loadImage(source) { [weak imageView] buffer in guard buffer != nil else { return } @@ -119,17 +119,17 @@ public final class QuoteView: UIView { bodyLabel.numberOfLines = 2 bodyLabel.themeAttributedText = viewModel.attributedText - // Label stack view + /// Label stack view let authorLabel = SessionLabelWithProBadge( proBadgeSize: .mini, proBadgeThemeBackgroundColor: viewModel.proBadgeThemeColor ) authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) - authorLabel.text = viewModel.author + authorLabel.text = (viewModel.quotedInfo?.authorName ?? "") authorLabel.themeTextColor = viewModel.targetThemeColor authorLabel.lineBreakMode = .byTruncatingTail authorLabel.numberOfLines = 1 - authorLabel.isHidden = (authorLabel.text == nil) + authorLabel.isHidden = (viewModel.quotedInfo == nil) authorLabel.isProBadgeHidden = !viewModel.showProBadge authorLabel.setCompressionResistance(.vertical, to: .required) diff --git a/SessionUIKit/Components/RadioButton.swift b/SessionUIKit/Components/RadioButton.swift index 6eb78fdb80..e3d6d7ff7e 100644 --- a/SessionUIKit/Components/RadioButton.swift +++ b/SessionUIKit/Components/RadioButton.swift @@ -4,6 +4,7 @@ import UIKit // FIXME: Remove this and use the 'SessionCell' instead public class RadioButton: UIView { + public static let descriptionFont: UIFont = .systemFont(ofSize: Values.verySmallFontSize) private static let selectionBorderSize: CGFloat = 26 private static let selectionSize: CGFloat = 20 @@ -36,6 +37,14 @@ public class RadioButton: UIView { set { titleLabel.text = newValue } } + public var descriptionText: ThemedAttributedString? { + get { descriptionLabel.attributedText.map { ThemedAttributedString(attributedString: $0) } } + set { + descriptionLabel.themeAttributedText = newValue + descriptionLabel.isHidden = (newValue == nil) + } + } + public private(set) var isEnabled: Bool = true public private(set) var isSelected: Bool = false private let titleTextColor: ThemeValue @@ -51,6 +60,16 @@ public class RadioButton: UIView { return result }() + private lazy var textStackView: UIStackView = { + let result: UIStackView = UIStackView() + result.translatesAutoresizingMaskIntoConstraints = false + result.isUserInteractionEnabled = false + result.axis = .vertical + result.distribution = .fill + + return result + }() + private lazy var titleLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false @@ -62,6 +81,18 @@ public class RadioButton: UIView { return result }() + private lazy var descriptionLabel: UILabel = { + let result: UILabel = UILabel() + result.translatesAutoresizingMaskIntoConstraints = false + result.isUserInteractionEnabled = false + result.font = RadioButton.descriptionFont + result.themeTextColor = titleTextColor + result.numberOfLines = 0 + result.isHidden = true + + return result + }() + private let selectionBorderView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false @@ -108,10 +139,13 @@ public class RadioButton: UIView { private func setupViewHierarchy(size: Size) { addSubview(selectionButton) - addSubview(titleLabel) + addSubview(textStackView) addSubview(selectionBorderView) addSubview(selectionView) + textStackView.addArrangedSubview(titleLabel) + textStackView.addArrangedSubview(descriptionLabel) + self.heightAnchor.constraint( greaterThanOrEqualTo: titleLabel.heightAnchor, constant: Values.mediumSpacing @@ -123,9 +157,9 @@ public class RadioButton: UIView { selectionButton.pin(to: self) - titleLabel.center(.vertical, in: self) - titleLabel.pin(.leading, to: .leading, of: self) - titleLabel.pin(.trailing, to: .trailing, of: selectionBorderView, withInset: -Values.verySmallSpacing) + textStackView.center(.vertical, in: self) + textStackView.pin(.leading, to: .leading, of: self) + textStackView.pin(.trailing, to: .leading, of: selectionBorderView, withInset: -Values.verySmallSpacing) selectionBorderView.center(.vertical, in: self) selectionBorderView.pin(.trailing, to: .trailing, of: self) @@ -153,21 +187,25 @@ public class RadioButton: UIView { switch (self.isEnabled, self.isSelected) { case (true, true): titleLabel.themeTextColor = titleTextColor + descriptionLabel.themeTextColor = titleTextColor selectionBorderView.themeBorderColor = .radioButton_selectedBorder selectionView.themeBackgroundColor = .radioButton_selectedBackground case (true, false): titleLabel.themeTextColor = titleTextColor + descriptionLabel.themeTextColor = titleTextColor selectionBorderView.themeBorderColor = .radioButton_unselectedBorder selectionView.themeBackgroundColor = .radioButton_unselectedBackground case (false, true): titleLabel.themeTextColor = .disabled + descriptionLabel.themeTextColor = .disabled selectionBorderView.themeBorderColor = .radioButton_disabledBorder selectionView.themeBackgroundColor = .radioButton_disabledSelectedBackground case (false, false): titleLabel.themeTextColor = .disabled + descriptionLabel.themeTextColor = .disabled selectionBorderView.themeBorderColor = .radioButton_disabledBorder selectionView.themeBackgroundColor = .radioButton_disabledUnselectedBackground } diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index b6396e3f0b..3269ba064e 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -3,9 +3,23 @@ import UIKit public class SessionProBadge: UIView { + public static let accessibilityLabel: String = Constants.app_pro + + public static let identifier: String = "ProBadge" // stringlint:ignore + public enum Size { case mini, small, medium, large + // stringlint:ignore_contents + public var cacheKey: String { + switch self { + case .mini: return "SessionProBadge.Mini" + case .small: return "SessionProBadge.Small" + case .medium: return "SessionProBadge.Medium" + case .large: return "SessionProBadge.Large" + } + } + public var width: CGFloat { switch self { case .mini: return 24 diff --git a/SessionUIKit/Components/SwiftUI/AnimatedToggle.swift b/SessionUIKit/Components/SwiftUI/AnimatedToggle.swift index bc9198cb39..23583d7d56 100644 --- a/SessionUIKit/Components/SwiftUI/AnimatedToggle.swift +++ b/SessionUIKit/Components/SwiftUI/AnimatedToggle.swift @@ -5,6 +5,7 @@ import SwiftUI public struct AnimatedToggle: View { let value: Bool let oldValue: Bool? + let allowHitTesting: Bool let accessibility: Accessibility @State private var uiValue: Bool @@ -12,18 +13,22 @@ public struct AnimatedToggle: View { public init( value: Bool, oldValue: Bool?, + allowHitTesting: Bool, accessibility: Accessibility ) { self.value = value self.oldValue = oldValue + self.allowHitTesting = allowHitTesting self.accessibility = accessibility _uiValue = State(initialValue: oldValue ?? value) } public var body: some View { Toggle("", isOn: $uiValue) + .allowsHitTesting(allowHitTesting) .labelsHidden() .accessibility(accessibility) + .tint(themeColor: .primary) .task { guard (oldValue ?? value) != value else { return } try? await Task.sleep(nanoseconds: 10_000_000) // ~10ms diff --git a/SessionUIKit/Components/SwiftUI/AttributedText.swift b/SessionUIKit/Components/SwiftUI/AttributedText.swift index 0fc516159e..cdefa0aae8 100644 --- a/SessionUIKit/Components/SwiftUI/AttributedText.swift +++ b/SessionUIKit/Components/SwiftUI/AttributedText.swift @@ -27,7 +27,7 @@ public struct AttributedText: View { } private mutating func extractDescriptions() { - if let text = attributedText?.value { + if let text = attributedText?.attributedString { text.enumerateAttributes(in: NSMakeRange(0, text.length), options: [], using: { (attribute, range, stop) in let substring = (text.string as NSString).substring(with: range) let font = (attribute[.font] as? UIFont).map { Font($0) } diff --git a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift index 230f336625..26beeac7dc 100644 --- a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift @@ -6,7 +6,7 @@ public struct Modal_SwiftUI: View where Content: View { let host: HostWrapper let dismissType: Modal.DismissType let afterClosed: (() -> Void)? - let content: (@escaping ((() -> Void)?) -> Void) -> Content + let content: (@escaping @MainActor ((() -> Void)?) -> Void) -> Content let cornerRadius: CGFloat = 11 let shadowRadius: CGFloat = 10 @@ -67,7 +67,7 @@ public struct Modal_SwiftUI: View where Content: View { // MARK: - Dismiss Logic - private func close(_ internalAfterClosed: (() -> Void)? = nil) { + @MainActor private func close(_ internalAfterClosed: (() -> Void)? = nil) { // Recursively dismiss all modals (ie. find the first modal presented by a non-modal // and get that to dismiss it's presented view controller) var targetViewController: UIViewController? = host.controller diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index bd7b0ae151..b50ed7935d 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -5,31 +5,43 @@ import Lucide import Combine public struct ProCTAModal: View { + public enum Variant { + case generic(renew: Bool) + case longerMessages(renew: Bool) + case animatedProfileImage(isSessionProActivated: Bool, renew: Bool) + case morePinnedConvos(isGrandfathered: Bool, renew: Bool) + case groupLimit(isAdmin: Bool, isSessionProActivated: Bool, proBadgeImage: UIImage) + case expiring(timeLeft: String?) + } + @EnvironmentObject var host: HostWrapper @State var proCTAImageHeight: CGFloat = 0 private let variant: ProCTAModal.Variant - private var dataManager: ImageDataManagerType + private let dataManager: ImageDataManagerType + private let sessionProUIManager: SessionProUIManagerType let dismissType: Modal.DismissType - let afterClosed: (() -> Void)? let onConfirm: (() -> Void)? let onCancel: (() -> Void)? + let afterClosed: (() -> Void)? public init( variant: ProCTAModal.Variant, dataManager: ImageDataManagerType, + sessionProUIManager: SessionProUIManagerType, dismissType: Modal.DismissType = .recursive, - afterClosed: (() -> Void)? = nil, onConfirm: (() -> Void)? = nil, - onCancel: (() -> Void)? = nil + onCancel: (() -> Void)? = nil, + afterClosed: (() -> Void)? = nil ) { self.variant = variant self.dataManager = dataManager + self.sessionProUIManager = sessionProUIManager self.dismissType = dismissType - self.afterClosed = afterClosed self.onConfirm = onConfirm self.onCancel = onCancel + self.afterClosed = afterClosed } public var body: some View { @@ -172,13 +184,13 @@ public struct ProCTAModal: View { case .groupLimit(_, let isSessionProActivated, let proBadgeImage) = variant, isSessionProActivated { - (Text(variant.subtitle.string) + Text(" \(Image(uiImage: proBadgeImage))")) + (Text(variant.subtitle(sessionProUIManager: sessionProUIManager).string) + Text(" \(Image(uiImage: proBadgeImage))")) .font(.Body.largeRegular) .foregroundColor(themeColor: .textSecondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) } else { - AttributedText(variant.subtitle) + AttributedText(variant.subtitle(sessionProUIManager: sessionProUIManager)) .font(.Body.largeRegular) .foregroundColor(themeColor: .textSecondary) .multilineTextAlignment(.center) @@ -266,6 +278,7 @@ public struct ProCTAModal: View { // Cancel Button Button { + onCancel?() close(nil) onCancel?() } label: { @@ -290,237 +303,224 @@ public struct ProCTAModal: View { } } -// MARK: - Variant +// MARK: - ProCTAModal.Benefits public extension ProCTAModal { - enum Variant { - case generic(renew: Bool) - case longerMessages(renew: Bool) - case animatedProfileImage(isSessionProActivated: Bool, renew: Bool) - case morePinnedConvos(isGrandfathered: Bool, renew: Bool) - case groupLimit(isAdmin: Bool, isSessionProActivated: Bool, proBadgeImage: UIImage) - case expiring(timeLeft: String?) + enum Benefits: Equatable { + case largerGroups + case longerMessages + case animatedProfileImage + case morePinnedConvos + case loadsMore - public var isRenewing: Bool { - switch self { - case .generic(let renew), .longerMessages(let renew), .animatedProfileImage(_, let renew), .morePinnedConvos(_, let renew): - return renew - case .groupLimit, .expiring: - return false + var description: String { + return switch self { + case .largerGroups: "proFeatureListLargerGroups".localized() + case .longerMessages: "proFeatureListLongerMessages".localized() + case .animatedProfileImage: "proFeatureListAnimatedDisplayPicture".localized() + case .morePinnedConvos: "proFeatureListPinnedConversations".localized() + case .loadsMore: "proFeatureListLoadsMore".localized() } } + } +} - // stringlint:ignore_contents - public var backgroundImageName: String { - switch self { - case .generic, .expiring: - return "GenericCTA.webp" - case .longerMessages: - return "HigherCharLimitCTA.webp" - case .animatedProfileImage: - return "AnimatedProfileCTA.webp" - case .morePinnedConvos: - return "PinnedConversationsCTA.webp" - case .groupLimit(let isAdmin, let isSessionProActivated, _): - switch (isAdmin, isSessionProActivated) { - case (false, false): - return "GroupNonAdminCTA.webp" - default: - return "GroupAdminCTA.webp" - } - } +// MARK: - Variant Content + +public extension ProCTAModal.Variant { + var isRenewing: Bool { + switch self { + case .generic(let renew), .longerMessages(let renew), .animatedProfileImage(_, let renew), .morePinnedConvos(_, let renew): + return renew + + case .groupLimit, .expiring: return false } - - public var themeColor: ThemeValue { - switch self { - case .expiring(let timeLeft): return (timeLeft?.isEmpty == false) ? .primary : .disabled - default: return .primary - } + } + + // stringlint:ignore_contents + var backgroundImageName: String { + switch self { + case .generic, .expiring: return "GenericCTA.webp" + case .longerMessages: return "HigherCharLimitCTA.webp" + case .animatedProfileImage: return "AnimatedProfileCTA.webp" + case .morePinnedConvos: return "PinnedConversationsCTA.webp" + case .groupLimit(false, false, _): return "GroupNonAdminCTA.webp" + case .groupLimit: return "GroupAdminCTA.webp" } - - public var grayscale: Double { - switch self { - case .expiring(let timeLeft): return (timeLeft?.isEmpty == false) ? 0.0 : 1.0 - default: return 0.0 - } + } + + var themeColor: ThemeValue { + switch self { + case .expiring(let timeLeft): return (timeLeft?.isEmpty == false ? .primary : .disabled) + default: return .primary } - - // stringlint:ignore_contents - public var animatedAvatarImageURL: URL? { - switch self { + } + + var grayscale: Double { + switch self { + case .expiring(let timeLeft): return (timeLeft?.isEmpty == false ? 0.0 : 1.0) + default: return 0.0 + } + } + + // stringlint:ignore_contents + var animatedAvatarImageURL: URL? { + switch self { case .generic, .animatedProfileImage, .expiring: - return Bundle.main.url(forResource: "AnimatedProfileCTAAnimationCropped", withExtension: "webp") - default: return nil - } + return Bundle.main.url(forResource: "AnimatedProfileCTAAnimationCropped", withExtension: "webp") + default: return nil } - /// Note: This is a hack to manually position the animated avatar in the CTA background image to prevent heavy loading for the - /// animated webp. These coordinates are based on the full size image and get scaled during rendering based on the actual size - /// of the modal. - public var animatedAvatarImagePadding: (leading: CGFloat, top: CGFloat) { - switch self { - case .generic, .expiring: return (1303, 743) - case .animatedProfileImage: return (680, 363) - default: return (0, 0) - } + } + + /// Note: This is a hack to manually position the animated avatar in the CTA background image to prevent heavy loading for the + /// animated webp. These coordinates are based on the full size image and get scaled during rendering based on the actual size + /// of the modal. + var animatedAvatarImagePadding: (leading: CGFloat, top: CGFloat) { + switch self { + case .generic, .expiring: return (1303, 743) + case .animatedProfileImage: return (680, 363) + default: return (0, 0) } - - public var animatedAvatarImageSize: CGFloat { - switch self { - case .generic, .expiring: return 115 - case .animatedProfileImage: return 200 - default: return 0 - } + } + + var animatedAvatarImageSize: CGFloat { + switch self { + case .generic, .expiring: return 115 + case .animatedProfileImage: return 200 + default: return 0 } + } - public var subtitle: ThemedAttributedString { - switch self { - case .generic(let renew): - return renew ? - "proRenewMaxPotential" - .put(key: "pro", value: Constants.pro) - .put(key: "app_name", value: Constants.app_name) - .localizedFormatted(baseFont: Fonts.Body.largeRegular) : - "proUserProfileModalCallToAction" - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "app_name", value: Constants.app_name) - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - case .longerMessages(let renew): - return renew ? - "proRenewLongerMessages" - .put(key: "pro", value: Constants.pro) - .localizedFormatted(baseFont: Fonts.Body.largeRegular) : - "proCallToActionLongerMessages" - .put(key: "app_pro", value: Constants.app_pro) - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - case .animatedProfileImage(let isSessionProActivated, let renew): - switch (isSessionProActivated, renew) { - case (true, _): - return "proAnimatedDisplayPicture" - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - case (false, true): - return "proRenewAnimatedDisplayPicture" - .put(key: "pro", value: Constants.pro) - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - case (false, false): - return "proAnimatedDisplayPictureCallToActionDescription" - .put(key: "app_pro", value: Constants.app_pro) - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - } - case .morePinnedConvos(let isGrandfathered, let renew): - switch (isGrandfathered, renew) { - case (true, false): - return "proCallToActionPinnedConversations" - .put(key: "app_pro", value: Constants.app_pro) - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - case (false, false): - return "proCallToActionPinnedConversationsMoreThan" - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "limit", value: 5) // TODO: [PRO] Get from SessionProUIManager - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - case (true, true): - return "proRenewPinMoreConversations" - .put(key: "pro", value: Constants.pro) - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - case (false, true): - return "proRenewPinFiveConversations" - .put(key: "pro", value: Constants.pro) - .put(key: "limit", value: 5) // TODO: [PRO] Get from SessionProUIManager - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - } - case .groupLimit(let isAdmin, let isSessionProActivated, _): - switch (isAdmin, isSessionProActivated) { - case (_, true): - return "proGroupActivatedDescription" - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - case (true, false): - return "proUserProfileModalCallToAction" - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "app_name", value: Constants.app_name) - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - case (false, false): - // TODO: Localised - return ThemedAttributedString( - string: "Want to upgrade this group to Pro? Tell one of the group admins to upgrade to Pro" - ) - } - case .expiring(let timeLeft): - if let timeLeft, !timeLeft.isEmpty { - return "proExpiringSoonDescription" - .put(key: "pro", value: Constants.pro) - .put(key: "time", value: timeLeft) - .put(key: "app_pro", value: Constants.app_pro) - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - } else { - return "proExpiredDescription" - .put(key: "pro", value: Constants.pro) - .put(key: "app_pro", value: Constants.app_pro) - .localizedFormatted(baseFont: Fonts.Body.largeRegular) - } - } - } - - public enum Benefits: Equatable { - case largerGroups - case longerMessages - case animatedProfileImage - case morePinnedConvos - case loadsMore + func subtitle(sessionProUIManager: SessionProUIManagerType) -> ThemedAttributedString { + switch self { + case .generic(renew: true): + return "proRenewMaxPotential" + .put(key: "pro", value: Constants.pro) + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .generic(renew: false): + return "proUserProfileModalCallToAction" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) - var description: String { - return switch self { - case .largerGroups: "proFeatureListLargerGroups".localized() - case .longerMessages: "proFeatureListLongerMessages".localized() - case .animatedProfileImage: "proFeatureListAnimatedDisplayPicture".localized() - case .morePinnedConvos: "proFeatureListPinnedConversations".localized() - case .loadsMore: "proFeatureListLoadsMore".localized() - } - } + case .longerMessages(renew: true): + return "proRenewLongerMessages" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .longerMessages(renew: false): + return "proCallToActionLongerMessages" + .put(key: "app_pro", value: Constants.app_pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .animatedProfileImage(isSessionProActivated: true, _): + return "proAnimatedDisplayPicture" + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .animatedProfileImage(isSessionProActivated: false, renew: true): + return "proRenewAnimatedDisplayPicture" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .animatedProfileImage(isSessionProActivated: false, renew: false): + return "proAnimatedDisplayPictureCallToActionDescription" + .put(key: "app_pro", value: Constants.app_pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .morePinnedConvos(isGrandfathered: true, renew: false): + return "proCallToActionPinnedConversations" + .put(key: "app_pro", value: Constants.app_pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .morePinnedConvos(isGrandfathered: false, renew: false): + return "proCallToActionPinnedConversationsMoreThan" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "limit", value: sessionProUIManager.pinnedConversationLimit) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .morePinnedConvos(isGrandfathered: true, renew: true): + return "proRenewPinMoreConversations" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .morePinnedConvos(isGrandfathered: false, renew: true): + return "proRenewPinFiveConversations" + .put(key: "pro", value: Constants.pro) + .put(key: "limit", value: sessionProUIManager.pinnedConversationLimit) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .groupLimit(_, isSessionProActivated: true, _): + return "proGroupActivatedDescription" + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .groupLimit(isAdmin: true, isSessionProActivated: false, _): + return "proUserProfileModalCallToAction" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .groupLimit(isAdmin: false, isSessionProActivated: false, _): + // TODO: [PRO] Localised + return ThemedAttributedString( + string: "Want to upgrade this group to Pro? Tell one of the group admins to upgrade to Pro" + ) + + case .expiring(let timeLeft) where timeLeft?.isEmpty == false: + return "proExpiringSoonDescription" + .put(key: "pro", value: Constants.pro) + .put(key: "time", value: timeLeft ?? "") + .put(key: "app_pro", value: Constants.app_pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + + case .expiring: + return "proExpiredDescription" + .put(key: "pro", value: Constants.pro) + .put(key: "app_pro", value: Constants.app_pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) } - - public var benefits: [Benefits] { - return switch self { - case .generic: [ .longerMessages, .morePinnedConvos, .loadsMore ] - case .longerMessages: [ .longerMessages, .morePinnedConvos, .loadsMore ] - case .animatedProfileImage: [ .animatedProfileImage, .longerMessages, .loadsMore ] - case .morePinnedConvos: [ .morePinnedConvos, .longerMessages, .loadsMore ] - case .groupLimit(let isAdmin, let isSessionProActivated, _): - switch (isAdmin, isSessionProActivated) { - case (true, false): [ .largerGroups, .longerMessages, .loadsMore ] - default: [] - } - case .expiring: [ .longerMessages, .morePinnedConvos, .animatedProfileImage ] - } + } + + var benefits: [ProCTAModal.Benefits] { + switch self { + case .generic: return [ .longerMessages, .morePinnedConvos, .loadsMore ] + case .longerMessages: return [ .longerMessages, .morePinnedConvos, .loadsMore ] + case .animatedProfileImage: return [ .animatedProfileImage, .longerMessages, .loadsMore ] + case .morePinnedConvos: return [ .morePinnedConvos, .longerMessages, .loadsMore ] + case .groupLimit(isAdmin: true, isSessionProActivated: false, _): + return [ .largerGroups, .longerMessages, .loadsMore ] + + case .groupLimit: return [] + case .expiring: return [ .longerMessages, .morePinnedConvos, .animatedProfileImage ] } - - public var confirmButtonTitle: String { - switch self { - case .expiring(let timeLeft): - return (timeLeft?.isEmpty == false) ? "update".localized() : "renew".localized() - default: return "theContinue".localized() - } + } + + var confirmButtonTitle: String { + switch self { + case .expiring(let timeLeft) where timeLeft?.isEmpty == false: return "update".localized() + case .expiring: return "renew".localized() + default: return "theContinue".localized() } - - public var cancelButtonTitle: String { - guard !self.onlyShowCloseButton else { - return "close".localized() - } - - switch self { - case .expiring(let timeLeft): - return (timeLeft?.isEmpty == false) ? "close".localized() : "cancel".localized() - default: return "cancel".localized() - } + } + + var cancelButtonTitle: String { + guard !self.onlyShowCloseButton else { + return "close".localized() } - public var onlyShowCloseButton: Bool { - switch self { - case .animatedProfileImage(let isSessionProActivated, _): - return isSessionProActivated - case .groupLimit(let isAdmin, let isSessionProActivated, _): - return (!isAdmin || isSessionProActivated) - default: - return false - } + switch self { + case .expiring(let timeLeft) where timeLeft?.isEmpty == false: return "close".localized() + case .expiring: return "cancel".localized() + default: return "cancel".localized() + } + } + + var onlyShowCloseButton: Bool { + switch self { + case .animatedProfileImage(let isSessionProActivated, _): return isSessionProActivated + case .groupLimit(let isAdmin, let isSessionProActivated, _): return (!isAdmin || isSessionProActivated) + default: return false } } } @@ -534,6 +534,7 @@ struct ProCTAModal_Previews: PreviewProvider { ProCTAModal( variant: .generic(renew: false), dataManager: ImageDataManager(), + sessionProUIManager: NoopSessionProUIManager(), dismissType: .single, afterClosed: nil ) @@ -545,6 +546,7 @@ struct ProCTAModal_Previews: PreviewProvider { ProCTAModal( variant: .generic(renew: false), dataManager: ImageDataManager(), + sessionProUIManager: NoopSessionProUIManager(), dismissType: .single, afterClosed: nil ) @@ -556,6 +558,7 @@ struct ProCTAModal_Previews: PreviewProvider { ProCTAModal( variant: .generic(renew: false), dataManager: ImageDataManager(), + sessionProUIManager: NoopSessionProUIManager(), dismissType: .single, afterClosed: nil ) @@ -567,6 +570,7 @@ struct ProCTAModal_Previews: PreviewProvider { ProCTAModal( variant: .generic(renew: false), dataManager: ImageDataManager(), + sessionProUIManager: NoopSessionProUIManager(), dismissType: .single, afterClosed: nil ) diff --git a/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift b/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift index e6a02ee65f..fc2ebda6c0 100644 --- a/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift @@ -3,10 +3,36 @@ import SwiftUI import UniformTypeIdentifiers -public struct QuoteViewModel: Equatable, Hashable { - public enum Mode: Equatable, Hashable { case regular, draft } - public enum Direction: Equatable, Hashable { case incoming, outgoing } - public struct AttachmentInfo: Equatable, Hashable { +public struct QuoteViewModel: Sendable, Equatable, Hashable { + public enum Mode: Sendable, Equatable, Hashable { case regular, draft } + public enum Direction: Sendable, Equatable, Hashable { case incoming, outgoing } + + public struct QuotedInfo: Sendable, Equatable, Hashable { + public let interactionId: Int64 + public let authorId: String + public let authorName: String + public let timestampMs: Int64 + public let body: String? + public let attachmentInfo: AttachmentInfo? + + public init( + interactionId: Int64, + authorId: String, + authorName: String, + timestampMs: Int64, + body: String?, + attachmentInfo: AttachmentInfo? + ) { + self.interactionId = interactionId + self.authorId = authorId + self.authorName = authorName + self.timestampMs = timestampMs + self.body = body + self.attachmentInfo = attachmentInfo + } + } + + public struct AttachmentInfo: Sendable, Equatable, Hashable { public let id: String public let utType: UTType public let isVoiceMessage: Bool @@ -31,35 +57,29 @@ public struct QuoteViewModel: Equatable, Hashable { } } + public static let emptyDraft: QuoteViewModel = QuoteViewModel( + mode: .draft, + direction: .outgoing, + quotedInfo: nil, + showProBadge: false, + currentUserSessionIds: [], + displayNameRetriever: { _, _ in nil }, + currentUserMentionImage: nil + ) + public let mode: Mode public let direction: Direction - public let currentUserSessionIds: Set - public let rowId: Int64 - public let interactionId: Int64? - public let authorId: String + public let targetThemeColor: ThemeValue + public let quotedInfo: QuotedInfo? public let showProBadge: Bool - public let timestampMs: Int64 - public let quotedInteractionId: Int64 - public let quotedInteractionIsDeleted: Bool - public let quotedText: String? - public let quotedAttachmentInfo: AttachmentInfo? - let displayNameRetriever: (String, Bool) -> String? + public let attributedText: ThemedAttributedString // MARK: - Computed Properties - var hasAttachment: Bool { quotedAttachmentInfo != nil } - var author: String? { - guard authorId.isEmpty || !currentUserSessionIds.contains(authorId) else { return "you".localized() } - guard quotedText != nil else { - // When we can't find the quoted message we want to hide the author label - return displayNameRetriever(authorId, false) - } - - return (displayNameRetriever(authorId, false) ?? authorId.truncated()) - } + var hasAttachment: Bool { quotedInfo?.attachmentInfo != nil } var fallbackImage: UIImage? { - guard let utType: UTType = quotedAttachmentInfo?.utType else { return nil } + guard let utType: UTType = quotedInfo?.attachmentInfo?.utType else { return nil } let fallbackImageName: String = (utType.conforms(to: .audio) ? "attachment_audio" : "actionsheet_document_black") @@ -70,17 +90,6 @@ public struct QuoteViewModel: Equatable, Hashable { return image } - var targetThemeColor: ThemeValue { - switch mode { - case .draft: return .textPrimary - case .regular: - return (direction == .outgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) - } - } - var proBadgeThemeColor: ThemeValue { switch mode { case .draft: return .primary @@ -103,87 +112,83 @@ public struct QuoteViewModel: Equatable, Hashable { } } - - var mentionLocation: MentionUtilities.MentionLocation { - switch (mode, direction) { - case (.draft, _): return .quoteDraft - case (_, .outgoing): return .outgoingQuote - case (_, .incoming): return .incomingQuote - } - } - - var attributedText: ThemedAttributedString? { - let text: String = { - switch (quotedText, quotedAttachmentInfo) { - case (.some(let text), _) where !text.isEmpty: return text - case (_, .some(let info)): - return info.utType.shortDescription(isVoiceMessage: info.isVoiceMessage) - - case (.some, .none), (.none, .none): return "messageErrorOriginal".localized() - } - }() - - return MentionUtilities.highlightMentions( - in: text, - currentUserSessionIds: currentUserSessionIds, - location: mentionLocation, - textColor: targetThemeColor, - attributes: [ - .themeForegroundColor: targetThemeColor, - .font: UIFont.systemFont(ofSize: Values.smallFontSize) - ], - displayNameRetriever: displayNameRetriever - ) - } // MARK: - Initialization public init( mode: Mode, direction: Direction, - currentUserSessionIds: Set, - rowId: Int64, - interactionId: Int64?, - authorId: String, + quotedInfo: QuotedInfo?, showProBadge: Bool, - timestampMs: Int64, - quotedInteractionId: Int64, - quotedInteractionIsDeleted: Bool, - quotedText: String?, - quotedAttachmentInfo: AttachmentInfo?, - displayNameRetriever: @escaping (String, Bool) -> String? + currentUserSessionIds: Set, + displayNameRetriever: @escaping DisplayNameRetriever, + currentUserMentionImage: UIImage? ) { self.mode = mode self.direction = direction - self.currentUserSessionIds = currentUserSessionIds - self.rowId = rowId - self.interactionId = interactionId - self.authorId = authorId + self.quotedInfo = quotedInfo self.showProBadge = showProBadge - self.timestampMs = timestampMs - self.quotedInteractionId = quotedInteractionId - self.quotedInteractionIsDeleted = quotedInteractionIsDeleted - self.quotedText = quotedText - self.quotedAttachmentInfo = quotedAttachmentInfo - self.displayNameRetriever = displayNameRetriever + self.targetThemeColor = { + switch mode { + case .draft: return .textPrimary + case .regular: + return (direction == .outgoing ? + .messageBubble_outgoingText : + .messageBubble_incomingText + ) + } + }() + + let text: String = { + switch (quotedInfo?.body, quotedInfo?.attachmentInfo) { + case (.some(let text), _) where !text.isEmpty: return text + case (_, .some(let info)): + return info.utType.shortDescription(isVoiceMessage: info.isVoiceMessage) + + case (.some, .none), (.none, .none): return "messageErrorOriginal".localized() + } + }() + + self.attributedText = text + .formatted( + baseFont: .systemFont(ofSize: Values.smallFontSize), + attributes: [.themeForegroundColor: targetThemeColor], + mentionColor: MentionUtilities.mentionColor( + textColor: targetThemeColor, + location: { + switch (mode, direction) { + case (.draft, _): return .quoteDraft + case (_, .outgoing): return .outgoingQuote + case (_, .incoming): return .incomingQuote + } + }() + ), + currentUserMentionImage: currentUserMentionImage + ) } public init(showYouAsAuthor: Bool, previewBody: String) { - self.quotedText = previewBody - self.authorId = (showYouAsAuthor ? "you".localized() : "") - - /// This is an preview version so none of these values matter + self.quotedInfo = QuotedInfo( + interactionId: 0, + authorId: "", + authorName: (showYouAsAuthor ? "you".localized() : ""), + timestampMs: 0, + body: previewBody, + attachmentInfo: nil + ) self.mode = .regular self.direction = .incoming - self.currentUserSessionIds = [] - self.rowId = -1 - self.interactionId = nil + self.targetThemeColor = .messageBubble_incomingText self.showProBadge = false - self.timestampMs = 0 - self.quotedInteractionId = 0 - self.quotedInteractionIsDeleted = false - self.quotedAttachmentInfo = nil - self.displayNameRetriever = { _, _ in nil } + self.attributedText = previewBody.formatted( + baseFont: .systemFont(ofSize: Values.smallFontSize), + attributes: [.themeForegroundColor: targetThemeColor], + mentionColor: MentionUtilities.mentionColor( + textColor: targetThemeColor, + location: .incomingQuote + ), + currentUserMentionImage: nil + ) } // MARK: - Conformance @@ -192,30 +197,16 @@ public struct QuoteViewModel: Equatable, Hashable { return ( lhs.mode == rhs.mode && lhs.direction == rhs.direction && - lhs.currentUserSessionIds == rhs.currentUserSessionIds && - lhs.rowId == rhs.rowId && - lhs.interactionId == rhs.interactionId && - lhs.authorId == rhs.authorId && - lhs.timestampMs == rhs.timestampMs && - lhs.quotedInteractionId == rhs.quotedInteractionId && - lhs.quotedInteractionIsDeleted == rhs.quotedInteractionIsDeleted && - lhs.quotedText == rhs.quotedText && - lhs.quotedAttachmentInfo == rhs.quotedAttachmentInfo + lhs.quotedInfo == rhs.quotedInfo && + lhs.attributedText == rhs.attributedText ) } public func hash(into hasher: inout Hasher) { mode.hash(into: &hasher) direction.hash(into: &hasher) - currentUserSessionIds.hash(into: &hasher) - rowId.hash(into: &hasher) - interactionId?.hash(into: &hasher) - authorId.hash(into: &hasher) - timestampMs.hash(into: &hasher) - quotedInteractionId.hash(into: &hasher) - quotedInteractionIsDeleted.hash(into: &hasher) - quotedText.hash(into: &hasher) - quotedAttachmentInfo.hash(into: &hasher) + quotedInfo.hash(into: &hasher) + attributedText.hash(into: &hasher) } } @@ -257,7 +248,7 @@ public struct QuoteView_SwiftUI: View { height: Self.thumbnailSize ) - if let source: ImageDataManager.DataSource = viewModel.quotedAttachmentInfo?.thumbnailSource { + if let source: ImageDataManager.DataSource = viewModel.quotedInfo?.attachmentInfo?.thumbnailSource { SessionAsyncImage( source: source, dataManager: dataManager @@ -310,15 +301,15 @@ public struct QuoteView_SwiftUI: View { alignment: .leading, spacing: Self.labelStackViewSpacing ) { - if let author: String = viewModel.author { - Text(author) + if let authorName: String = viewModel.quotedInfo?.authorName { + Text(authorName) .bold() .font(.system(size: Values.smallFontSize)) .foregroundColor(themeColor: viewModel.targetThemeColor) } - if let attributedText: ThemedAttributedString = viewModel.attributedText { - AttributedText(attributedText) + if viewModel.quotedInfo != nil { + AttributedText(viewModel.attributedText) .lineLimit(2) } else { Text("messageErrorOriginal".localized()) @@ -364,17 +355,18 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { viewModel: QuoteViewModel( mode: .draft, direction: .outgoing, + quotedInfo: QuoteViewModel.QuotedInfo( + interactionId: 0, + authorId: "05123", + authorName: "Test User", + timestampMs: 0, + body: nil, + attachmentInfo: nil + ), + showProBadge: true, currentUserSessionIds: ["05123"], - rowId: 0, - interactionId: nil, - authorId: "05123", - showProBadge: false, - timestampMs: 0, - quotedInteractionId: 0, - quotedInteractionIsDeleted: false, - quotedText: nil, - quotedAttachmentInfo: nil, - displayNameRetriever: { _, _ in nil } + displayNameRetriever: { _, _ in nil }, + currentUserMentionImage: nil ), dataManager: ImageDataManager() ) @@ -392,17 +384,18 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { viewModel: QuoteViewModel( mode: .draft, direction: .outgoing, + quotedInfo: QuoteViewModel.QuotedInfo( + interactionId: 0, + authorId: "05123", + authorName: "0512...1234", + timestampMs: 0, + body: "This was a message", + attachmentInfo: nil + ), + showProBadge: false, currentUserSessionIds: [], - rowId: 0, - interactionId: nil, - authorId: "05123", - showProBadge: true, - timestampMs: 0, - quotedInteractionId: 0, - quotedInteractionIsDeleted: false, - quotedText: "This was a message", - quotedAttachmentInfo: nil, - displayNameRetriever: { _, _ in "Some User" } + displayNameRetriever: { _, _ in "Some User" }, + currentUserMentionImage: nil ), dataManager: ImageDataManager() ) @@ -420,24 +413,25 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { viewModel: QuoteViewModel( mode: .regular, direction: .incoming, - currentUserSessionIds: [], - rowId: 0, - interactionId: nil, - authorId: "", - showProBadge: false, - timestampMs: 0, - quotedInteractionId: 0, - quotedInteractionIsDeleted: false, - quotedText: nil, - quotedAttachmentInfo: QuoteViewModel.AttachmentInfo( - id: "", - utType: .wav, - isVoiceMessage: false, - downloadUrl: nil, - sourceFilename: nil, - thumbnailSource: nil + quotedInfo: QuoteViewModel.QuotedInfo( + interactionId: 0, + authorId: "05123", + authorName: "Name", + timestampMs: 0, + body: nil, + attachmentInfo: QuoteViewModel.AttachmentInfo( + id: "", + utType: .wav, + isVoiceMessage: false, + downloadUrl: nil, + sourceFilename: nil, + thumbnailSource: nil + ) ), - displayNameRetriever: { _, _ in nil } + showProBadge: false, + currentUserSessionIds: [], + displayNameRetriever: { _, _ in nil }, + currentUserMentionImage: nil ), dataManager: ImageDataManager() ) diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift index 6e9275a08a..8e248669ef 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -161,7 +161,7 @@ public struct UserProfileModal: View { .foregroundColor(themeColor: .textPrimary) .multilineTextAlignment(.center) - if info.isProUser { + if info.shouldShowProBadge { SessionProBadge_SwiftUI(size: .large) .onTapGesture { info.onProBadgeTapped?() @@ -186,7 +186,7 @@ public struct UserProfileModal: View { case (.some(let sessionId), .some): return ("accountId".localized(), sessionId.splitIntoLines(charactersForLines: [23, 23, 20])) case (.none, .some(let blindedId)): - return ("blindedId".localized(), blindedId) + return ("blindedId".localized(), blindedId.truncated(prefix: 10, suffix: 10)) case (.none, .none): return ("", "") // Shouldn't happen } @@ -259,7 +259,7 @@ public struct UserProfileModal: View { } .padding(.bottom, 12) } else { - if !info.isMessageRequestsEnabled, let displayName: String = info.displayName { + if !info.areMessageRequestsEnabled, let displayName: String = info.displayName { AttributedText( "messageRequestsTurnedOff" .put(key: "name", value: displayName) @@ -277,17 +277,17 @@ public struct UserProfileModal: View { } label: { Text("message".localized()) .font(.Body.baseBold) - .foregroundColor(themeColor: (info.isMessageRequestsEnabled ? .sessionButton_text : .disabled)) + .foregroundColor(themeColor: (info.areMessageRequestsEnabled ? .sessionButton_text : .disabled)) .overlay( Capsule() - .stroke(themeColor: (info.isMessageRequestsEnabled ? .sessionButton_border : .disabled)) + .stroke(themeColor: (info.areMessageRequestsEnabled ? .sessionButton_border : .disabled)) .frame( width: (geometry.size.width - Values.mediumSpacing) / 2, height: Values.smallButtonHeight ) ) } - .disabled(!info.isMessageRequestsEnabled) + .disabled(!info.areMessageRequestsEnabled) .buttonStyle(PlainButtonStyle()) } .frame( @@ -395,10 +395,10 @@ public extension UserProfileModal { let profileInfo: ProfilePictureView.Info let displayName: String? let contactDisplayName: String? - let isProUser: Bool - let isMessageRequestsEnabled: Bool - let onStartThread: (() -> Void)? - let onProBadgeTapped: (() -> Void)? + let shouldShowProBadge: Bool + let areMessageRequestsEnabled: Bool + let onStartThread: (@MainActor () -> Void)? + let onProBadgeTapped: (@MainActor () -> Void)? public init( sessionId: String?, @@ -407,10 +407,10 @@ public extension UserProfileModal { profileInfo: ProfilePictureView.Info, displayName: String?, contactDisplayName: String?, - isProUser: Bool, - isMessageRequestsEnabled: Bool, - onStartThread: (() -> Void)?, - onProBadgeTapped: (() -> Void)? + shouldShowProBadge: Bool, + areMessageRequestsEnabled: Bool, + onStartThread: (@MainActor () -> Void)?, + onProBadgeTapped: (@MainActor () -> Void)? ) { self.sessionId = sessionId self.blindedId = blindedId @@ -418,8 +418,8 @@ public extension UserProfileModal { self.profileInfo = profileInfo self.displayName = displayName self.contactDisplayName = contactDisplayName - self.isProUser = isProUser - self.isMessageRequestsEnabled = isMessageRequestsEnabled + self.shouldShowProBadge = shouldShowProBadge + self.areMessageRequestsEnabled = areMessageRequestsEnabled self.onStartThread = onStartThread self.onProBadgeTapped = onProBadgeTapped } diff --git a/SessionUIKit/Configuration.swift b/SessionUIKit/Configuration.swift index e320deb9ba..0f913999ba 100644 --- a/SessionUIKit/Configuration.swift +++ b/SessionUIKit/Configuration.swift @@ -29,6 +29,10 @@ public actor SNUIKit { func mediaDecoderSource(for data: Data) -> CGImageSource? @MainActor func numberOfCharactersLeft(for text: String) -> Int + + func urlStringProvider() -> StringProvider.Url + func buildVariantStringProvider() -> StringProvider.BuildVariant + func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> StringProvider.ClientPlatform } @MainActor public static var mainWindow: UIWindow? = nil @@ -155,4 +159,34 @@ public actor SNUIKit { return (config?.numberOfCharactersLeft(for: text) ?? 0) } + + internal static func urlStringProvider() -> StringProvider.Url { + configLock.lock() + defer { configLock.unlock() } + + return ( + config?.urlStringProvider() ?? + StringProvider.FallbackUrlStringProvider() + ) + } + + internal static func buildVariantStringProvider() -> StringProvider.BuildVariant { + configLock.lock() + defer { configLock.unlock() } + + return ( + config?.buildVariantStringProvider() ?? + StringProvider.FallbackBuildVariantStringProvider() + ) + } + + internal static func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> StringProvider.ClientPlatform { + configLock.lock() + defer { configLock.unlock() } + + return ( + config?.proClientPlatformStringProvider(for: platform) ?? + StringProvider.FallbackClientPlatformStringProvider() + ) + } } diff --git a/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Toggle.swift b/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Toggle.swift index cb4d2ef720..09e3233f33 100644 --- a/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Toggle.swift +++ b/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Toggle.swift @@ -12,6 +12,7 @@ public extension SessionListScreenContent.ListItemAccessory { AnimatedToggle( value: value, oldValue: oldValue, + allowHitTesting: false, /// Disable hit testing as the `ListItem` should receive the touches accessibility: accessibility ) } diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift index 7d4e90b1d2..6b2e7eef9e 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift @@ -37,8 +37,14 @@ public extension SessionListScreenContent { struct TextInfo: Hashable, Equatable { public enum Accessory: Hashable, Equatable { - case proBadgeLeading(themeBackgroundColor: ThemeValue) - case proBadgeTrailing(themeBackgroundColor: ThemeValue) + case proBadgeLeading( + size: SessionProBadge.Size, + themeBackgroundColor: ThemeValue + ) + case proBadgeTrailing( + size: SessionProBadge.Size, + themeBackgroundColor: ThemeValue + ) case none } diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift index 381017ac87..99f5ad3295 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -37,8 +37,8 @@ public struct ListItemCell: View { VStack(alignment: .leading, spacing: 0) { if let title = info.title { HStack(spacing: Values.verySmallSpacing) { - if case .proBadgeLeading(let themeBackgroundColor) = title.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) + if case .proBadgeLeading(let size, let themeBackgroundColor) = title.accessory { + SessionProBadge_SwiftUI(size: size, themeBackgroundColor: themeBackgroundColor) } if let text = title.text { @@ -57,16 +57,16 @@ public struct ListItemCell: View { .fixedSize() } - if case .proBadgeTrailing(let themeBackgroundColor) = title.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) + if case .proBadgeTrailing(let size, let themeBackgroundColor) = title.accessory { + SessionProBadge_SwiftUI(size: size, themeBackgroundColor: themeBackgroundColor) } } } if let description = info.description { HStack(spacing: Values.verySmallSpacing) { - if case .proBadgeLeading(let themeBackgroundColor) = description.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) + if case .proBadgeLeading(let size, let themeBackgroundColor) = description.accessory { + SessionProBadge_SwiftUI(size: size, themeBackgroundColor: themeBackgroundColor) } if let text = description.text { @@ -85,8 +85,8 @@ public struct ListItemCell: View { .fixedSize(horizontal: false, vertical: true) } - if case .proBadgeTrailing(let themeBackgroundColor) = description.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) + if case .proBadgeTrailing(let size, let themeBackgroundColor) = description.accessory { + SessionProBadge_SwiftUI(size: size, themeBackgroundColor: themeBackgroundColor) } } } diff --git a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift index d71f5a3bff..9a4b770e93 100644 --- a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift +++ b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift @@ -37,13 +37,13 @@ public extension SessionNetworkScreenContent { guard let tokenUSD: Double = tokenUSD else { return "unavailable".localized() } - return "$\(tokenUSD.formatted(format: .currency(decimal: true, withLocalSymbol: false))) USD" + return "$\(tokenUSD.formatted(format: .currency(decimal: true, withLocalSymbol: false, roundingMode: .halfUp))) USD" } public var tokenUSDNoCentsString: String { guard let tokenUSD: Double = tokenUSD else { return "unavailable".localized() } - return "$\(tokenUSD.formatted(format: .currency(decimal: false, withLocalSymbol: false))) USD" + return "$\(tokenUSD.formatted(format: .currency(decimal: false, withLocalSymbol: false, roundingMode: .halfUp))) USD" } public var tokenUSDAbbreviatedString: String { guard let tokenUSD: Double = tokenUSD else { @@ -72,7 +72,7 @@ public extension SessionNetworkScreenContent { guard networkStakedUSD > 0 else { return DataModel.defaultPriceString } - return "$\(networkStakedUSD.formatted(format: .currency(decimal: false, withLocalSymbol: false))) USD" + return "$\(networkStakedUSD.formatted(format: .currency(decimal: false, withLocalSymbol: false, roundingMode: .halfUp))) USD" } public var networkStakedUSDAbbreviatedString: String { guard networkStakedUSD > 0 else { @@ -92,7 +92,7 @@ public extension SessionNetworkScreenContent { guard let marketCap: Double = marketCapUSD else { return "unavailable".localized() } - return "$\(marketCap.formatted(format: .currency(decimal: false, withLocalSymbol: false))) USD" + return "$\(marketCap.formatted(format: .currency(decimal: false, withLocalSymbol: false, roundingMode: .halfUp))) USD" } public var marketCapAbbreviatedString: String { guard let marketCap: Double = marketCapUSD else { diff --git a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift index 6a0caba3a6..8feb0d7240 100644 --- a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift @@ -25,7 +25,7 @@ public struct SessionNetworkScreen Void + let cancelPlanAction: @MainActor () -> Void var body: some View { VStack(spacing: Values.mediumSmallSpacing) { @@ -62,7 +62,7 @@ struct CancelPlanOriginatingPlatformContent: View { // MARK: - Cancel Plan Non Originating Platform Content struct CancelPlanNonOriginatingPlatformContent: View { - let originatingPlatform: SessionProPaymentScreenContent.ClientPlatform + let originatingPlatform: SessionProUI.ClientPlatform let openPlatformStoreWebsiteAction: () -> Void var body: some View { @@ -83,7 +83,7 @@ struct CancelPlanNonOriginatingPlatformContent: View { "proCancellationDescription" .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .localizedFormatted(Fonts.Body.baseRegular) ) .font(.Body.baseRegular) @@ -99,14 +99,14 @@ struct CancelPlanNonOriginatingPlatformContent: View { .foregroundColor(themeColor: .textSecondary) ApproachCell( - info: .init( + info: ApproachCell.Info( title: "onDevice" - .put(key: "device_type", value: originatingPlatform.deviceType) + .put(key: "device_type", value: originatingPlatform.device) .localized(), description: "onDeviceDescription" .put(key: "app_name", value: Constants.app_name) - .put(key: "device_type", value: originatingPlatform.deviceType) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "device_type", value: originatingPlatform.device) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) .localizedFormatted(), @@ -115,12 +115,12 @@ struct CancelPlanNonOriginatingPlatformContent: View { ) ApproachCell( - info: .init( + info: ApproachCell.Info( title: "onPlatformWebsite" - .put(key: "platform", value: originatingPlatform.name) + .put(key: "platform", value: originatingPlatform.platform) .localized(), description: "viaStoreWebsiteDescription" - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "platform_store", value: originatingPlatform.store) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular), diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift index a7c1d6a03f..66eec6b660 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift @@ -1,6 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit public enum SessionProPaymentScreenContent {} @@ -12,136 +12,123 @@ public extension SessionProPaymentScreenContent { case update( currentPlan: SessionProPlanInfo, expiredOn: Date, + originatingPlatform: SessionProUI.ClientPlatform, isAutoRenewing: Bool, - originatingPlatform: ClientPlatform, isNonOriginatingAccount: Bool?, billingAccess: Bool ) case renew( - originatingPlatform: ClientPlatform, + originatingPlatform: SessionProUI.ClientPlatform, billingAccess: Bool ) case refund( - originatingPlatform: ClientPlatform, + originatingPlatform: SessionProUI.ClientPlatform, isNonOriginatingAccount: Bool?, requestedAt: Date? ) case cancel( - originatingPlatform: ClientPlatform + originatingPlatform: SessionProUI.ClientPlatform ) var description: ThemedAttributedString { switch self { - case .purchase(let billingAccess): - billingAccess ? - "proChooseAccess" - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.baseRegular) : - "proUpgradeAccess" - .put(key: "app_pro", value: Constants.app_pro) - .localizedFormatted(Fonts.Body.baseRegular) - case .update(let currentPlan, let expiredOn, let isAutoRenewing, let originatingPlatform, _, _): - switch (originatingPlatform, isAutoRenewing) { - case (.Android, true): - "proAccessActivatedAutoShort" - .put(key: "current_plan_length", value: currentPlan.durationString) - .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.baseRegular) - case (.Android, false): - "proAccessExpireDate" - .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.baseRegular) - case (.iOS, true): - "proAccessActivatesAuto" - .put(key: "current_plan_length", value: currentPlan.durationString) - .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.baseRegular) - case (.iOS, false): - "proAccessActivatedNotAuto" - .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.baseRegular) - } - case .renew(_, let billingAccess): - billingAccess ? - "proChooseAccess" - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.baseRegular) : - "proAccessRenewStart" - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(baseFont: Fonts.Body.baseRegular) + case .purchase(billingAccess: true): + return "proChooseAccess" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.baseRegular) + + case .purchase(billingAccess: false): + return "proUpgradeAccess" + .put(key: "app_pro", value: Constants.app_pro) + .localizedFormatted(Fonts.Body.baseRegular) + + case .update(let currentPlan, let expiredOn, .android, true, _, _): + return "proAccessActivatedAutoShort" + .put(key: "current_plan_length", value: currentPlan.durationString) + .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.baseRegular) + + case .update(_, let expiredOn, .android, false, _, _): + return "proAccessExpireDate" + .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.baseRegular) + + case .update(let currentPlan, let expiredOn, .iOS, true, _, _): + return "proAccessActivatesAuto" + .put(key: "current_plan_length", value: currentPlan.durationString) + .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.baseRegular) + + case .update(_, let expiredOn, .iOS, false, _, _): + return "proAccessActivatedNotAuto" + .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.baseRegular) + + case .renew(_, billingAccess: true): + return "proChooseAccess" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.baseRegular) + + case .renew(_, billingAccess: false): + return "proAccessRenewStart" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(baseFont: Fonts.Body.baseRegular) + case .refund: - "proRefundDescription" + return "proRefundDescription" .localizedFormatted(baseFont: Fonts.Body.baseRegular) + case .cancel: - "proCancelSorry" + return "proCancelSorry" .put(key: "pro", value: Constants.pro) .localizedFormatted(baseFont: Fonts.Body.baseRegular) - } - } - } - - enum ClientPlatform: Equatable { - case iOS - case Android - - public var store: String { - switch self { - case .iOS: return Constants.platform_store - case .Android: return Constants.android_platform_store - } - } - - public var account: String { - switch self { - case .iOS: return Constants.platform_account - case .Android: return Constants.android_platform_account - } - } - - public var deviceType: String { - switch self { - case .iOS: return Constants.platform_name - case .Android: return Constants.android_platform - } - } - - public var name: String { - switch self { - case .iOS: return Constants.platform - case .Android: return Constants.android_platform_name } } } struct SessionProPlanInfo: Equatable { + public let id: String public let duration: Int + let totalPrice: Double + let pricePerMonth: Double + let discountPercent: Int? + let titleWithPrice: String + let subtitleWithPrice: String + var durationString: String { + let components = DateComponents(month: self.duration) let formatter = DateComponentsFormatter() formatter.unitsStyle = .full formatter.allowedUnits = [.month] - let components = DateComponents(month: self.duration) + return (formatter.string(from: components) ?? "\(self.duration) Months") } + var durationStringSingular: String { + let components = DateComponents(month: self.duration) let formatter = DateComponentsFormatter() formatter.unitsStyle = .full formatter.allowedUnits = [.month] formatter.maximumUnitCount = 1 - let components = DateComponents(month: self.duration) + return (formatter.string(from: components) ?? "\(self.duration) Month") } - let totalPrice: Double - let pricePerMonth: Double - let discountPercent: Int? - let titleWithPrice: String - let subtitleWithPrice: String - public init(duration: Int, totalPrice: Double, pricePerMonth: Double, discountPercent: Int?, titleWithPrice: String, subtitleWithPrice: String) { + public init( + id: String, + duration: Int, + totalPrice: Double, + pricePerMonth: Double, + discountPercent: Int?, + titleWithPrice: String, + subtitleWithPrice: String + ) { + self.id = id self.duration = duration self.totalPrice = totalPrice self.pricePerMonth = pricePerMonth @@ -170,13 +157,13 @@ public extension SessionProPaymentScreenContent { protocol ViewModelType: AnyObject { var dataModel: DataModel { get set } + var dateNow: Date { get } var isRefreshing: Bool { get set } var errorString: String? { get set } var isFromBottomSheet: Bool { get } - func purchase(planInfo: SessionProPlanInfo, success: (() -> Void)?, failure: (() -> Void)?) async - func cancelPro(success: (() -> Void)?, failure: (() -> Void)?) async - func requestRefund(success: (() -> Void)?, failure: (() -> Void)?) async + @MainActor func purchase(planInfo: SessionProPlanInfo) async throws + @MainActor func cancelPro(scene: UIWindowScene?) async throws + @MainActor func requestRefund(scene: UIWindowScene?) async throws } } - diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+NoBillingAccess.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+NoBillingAccess.swift index 4df46963d3..cad9ab90d1 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+NoBillingAccess.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+NoBillingAccess.swift @@ -7,13 +7,13 @@ import Lucide struct NoBillingAccessContent: View { let isRenewingPro: Bool - let originatingPlatform: SessionProPaymentScreenContent.ClientPlatform + let originatingPlatform: SessionProUI.ClientPlatform let openProRoadmapAction: (() -> Void)? let openPlatformStoreWebsiteAction: (() -> Void)? public init( isRenewingPro: Bool, - originatingPlatform: SessionProPaymentScreenContent.ClientPlatform, + originatingPlatform: SessionProUI.ClientPlatform, openProRoadmapAction: (() -> Void)?, openPlatformStoreWebsiteAction: (() -> Void)? = nil ) { @@ -31,8 +31,8 @@ struct NoBillingAccessContent: View { description: "proRenewDesktopLinked" .put(key: "app_name", value: Constants.app_name) .put(key: "app_pro", value: Constants.app_pro) - .put(key: "platform_store", value: Constants.platform_store) - .put(key: "platform_store_other", value: Constants.android_platform_store) + .put(key: "platform_store", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).store) + .put(key: "platform_store_other", value: SNUIKit.proClientPlatformStringProvider(for: .android).store) .put(key: "pro", value: Constants.pro) .localizedFormatted(), variant: .link @@ -41,7 +41,7 @@ struct NoBillingAccessContent: View { title: "proNewInstallation".localized(), description: "proNewInstallationDescription" .put(key: "app_name", value: Constants.app_name) - .put(key: "platform_store", value: Constants.platform_store) + .put(key: "platform_store", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).store) .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) .localizedFormatted(), @@ -52,8 +52,8 @@ struct NoBillingAccessContent: View { .put(key: "platform", value: originatingPlatform.store) .localized(), description: "proAccessRenewPlatformWebsite" - .put(key: "platform_account", value: originatingPlatform.account) - .put(key: "platform", value: originatingPlatform.name) + .put(key: "platform_account", value: originatingPlatform.platformAccount) + .put(key: "platform", value: originatingPlatform.platform) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular), variant: .website @@ -65,8 +65,8 @@ struct NoBillingAccessContent: View { description: "proUpgradeDesktopLinked" .put(key: "app_name", value: Constants.app_name) .put(key: "app_pro", value: Constants.app_pro) - .put(key: "platform_store", value: Constants.platform_store) - .put(key: "platform_store_other", value: Constants.android_platform_store) + .put(key: "platform_store", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).store) + .put(key: "platform_store_other", value: SNUIKit.proClientPlatformStringProvider(for: .android).store) .put(key: "pro", value: Constants.pro) .localizedFormatted(), variant: .link @@ -76,13 +76,13 @@ struct NoBillingAccessContent: View { description: isRenewingPro ? "proNewInstallationDescription" .put(key: "app_name", value: Constants.app_name) - .put(key: "platform_store", value: Constants.platform_store) + .put(key: "platform_store", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).store) .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) .localizedFormatted() : "proNewInstallationUpgrade" .put(key: "app_name", value: Constants.app_name) - .put(key: "platform_store", value: Constants.platform_store) + .put(key: "platform_store", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).store) .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) .localizedFormatted(), @@ -117,18 +117,18 @@ struct NoBillingAccessContent: View { isRenewingPro ? "proRenewingNoAccessBilling" .put(key: "pro", value: Constants.pro) - .put(key: "platform_store", value: Constants.platform_store) - .put(key: "platform_store_other", value: Constants.android_platform_store) + .put(key: "platform_store", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).store) + .put(key: "platform_store_other", value: SNUIKit.proClientPlatformStringProvider(for: .android).store) .put(key: "app_name", value: Constants.app_name) - .put(key: "build_variant", value: Constants.IPA) + .put(key: "build_variant", value: SNUIKit.buildVariantStringProvider().ipa) .put(key: "icon", value: Lucide.Icon.squareArrowUpRight) .localizedFormatted(Fonts.Body.baseRegular) : "proUpgradeNoAccessBilling" .put(key: "pro", value: Constants.pro) - .put(key: "platform_store", value: Constants.platform_store) - .put(key: "platform_store_other", value: Constants.android_platform_store) + .put(key: "platform_store", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).store) + .put(key: "platform_store_other", value: SNUIKit.proClientPlatformStringProvider(for: .android).store) .put(key: "app_name", value: Constants.app_name) - .put(key: "build_variant", value: Constants.IPA) + .put(key: "build_variant", value: SNUIKit.buildVariantStringProvider().ipa) .put(key: "icon", value: Lucide.Icon.squareArrowUpRight) .localizedFormatted(Fonts.Body.baseRegular) ) diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift index d0aa593d33..f40e2e613a 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift @@ -19,6 +19,12 @@ struct SessionProPlanPurchaseContent: View { let purchaseAction: () -> Void let openTosPrivacyAction: () -> Void + var isCurrentPlanSelected: Bool { + guard currentSelection < sessionProPlans.count else { return false } + + return (sessionProPlans[currentSelection] == currentPlan) + } + // TODO: [PRO] Do we need a loading state in case the plans aren't loaded yet? var body: some View { VStack(spacing: Values.mediumSmallSpacing) { ForEach(sessionProPlans.indices, id: \.self) { index in @@ -56,14 +62,15 @@ struct SessionProPlanPurchaseContent: View { .background( RoundedRectangle(cornerRadius: 7) .fill( - themeColor: (sessionProPlans[currentSelection] == currentPlan) ? + themeColor: (isCurrentPlanSelected ? .disabled : .sessionButton_primaryFilledBackground + ) ) ) .padding(.vertical, Values.smallSpacing) } - .disabled((sessionProPlans[currentSelection] == currentPlan)) + .disabled(isCurrentPlanSelected) AttributedText( "noteTosPrivacyPolicy" diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift index be50d816f5..7d942da476 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift @@ -6,7 +6,7 @@ import Lucide // MARK: - Request Refund Originating Platform Content struct RequestRefundOriginatingPlatformContent: View { - let requestRefundAction: () -> Void + let requestRefundAction: @MainActor () -> Void var body: some View { VStack(spacing: Values.mediumSmallSpacing) { @@ -25,8 +25,8 @@ struct RequestRefundOriginatingPlatformContent: View { AttributedText( "proRefundingDescription" .put(key: "app_pro", value: Constants.app_pro) - .put(key: "platform", value: Constants.platform) - .put(key: "platform_store", value: Constants.platform_store) + .put(key: "platform", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).platform) + .put(key: "platform_store", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).store) .put(key: "app_name", value: Constants.app_name) .localizedFormatted(Fonts.Body.baseRegular) ) @@ -92,7 +92,7 @@ struct RequestRefundSuccessContent: View { Text( "proRefundNextSteps" - .put(key: "platform", value: Constants.platform) + .put(key: "platform", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).platform) .put(key: "pro", value: Constants.pro) .put(key: "app_name", value: Constants.app_name) .localized() @@ -108,7 +108,7 @@ struct RequestRefundSuccessContent: View { AttributedText( "proRefundSupport" - .put(key: "platform", value: Constants.platform) + .put(key: "platform", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).platform) .put(key: "app_name", value: Constants.app_name) .localizedFormatted(Fonts.Body.baseRegular) ) @@ -150,7 +150,7 @@ struct RequestRefundSuccessContent: View { // MARK: - Request Refund Non Originating Platform Content struct RequestRefundNonOriginatorContent: View { - let originatingPlatform: SessionProPaymentScreenContent.ClientPlatform + let originatingPlatform: SessionProUI.ClientPlatform let isNonOriginatingAccount: Bool? let requestedAt: Date? var isLessThan48Hours: Bool { (requestedAt?.timeIntervalSinceNow ?? 0) <= 48 * 60 * 60 } @@ -161,14 +161,16 @@ struct RequestRefundNonOriginatorContent: View { return "refundNonOriginatorApple" .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .localizedFormatted(Fonts.Body.baseRegular) + case (_, true): return "proPlanPlatformRefund" .put(key: "app_pro", value: Constants.app_pro) .put(key: "platform_store", value: originatingPlatform.store) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .localizedFormatted(Fonts.Body.baseRegular) + case (_, false): return "proPlanPlatformRefundLong" .put(key: "app_pro", value: Constants.app_pro) @@ -208,14 +210,14 @@ struct RequestRefundNonOriginatorContent: View { .foregroundColor(themeColor: .textSecondary) ApproachCell( - info: .init( + info: ApproachCell.Info( title: "onDevice" - .put(key: "device_type", value: originatingPlatform.deviceType) + .put(key: "device_type", value: originatingPlatform.device) .localized(), description: "onDeviceDescription" .put(key: "app_name", value: Constants.app_name) - .put(key: "device_type", value: originatingPlatform.deviceType) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "device_type", value: originatingPlatform.device) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) .localizedFormatted(), @@ -224,12 +226,12 @@ struct RequestRefundNonOriginatorContent: View { ) ApproachCell( - info: .init( + info: ApproachCell.Info( title: "onPlatformWebsite" - .put(key: "platform", value: originatingPlatform.name) + .put(key: "platform", value: originatingPlatform.platform) .localized(), description: "viaStoreWebsiteDescription" - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "platform_store", value: originatingPlatform.store) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular), diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift index 889704922b..9bc9f7a347 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift @@ -9,7 +9,7 @@ struct UpdatePlanNonOriginatingPlatformContent: View { let currentPlan: SessionProPaymentScreenContent.SessionProPlanInfo let currentPlanExpiredOn: Date let isAutoRenewing: Bool - let originatingPlatform: SessionProPaymentScreenContent.ClientPlatform + let originatingPlatform: SessionProUI.ClientPlatform let openPlatformStoreWebsiteAction: () -> Void var body: some View { @@ -34,7 +34,7 @@ struct UpdatePlanNonOriginatingPlatformContent: View { "proAccessSignUp" .put(key: "app_pro", value: Constants.app_pro) .put(key: "platform_store", value: originatingPlatform.store) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular) ) @@ -52,14 +52,14 @@ struct UpdatePlanNonOriginatingPlatformContent: View { .foregroundColor(themeColor: .textSecondary) ApproachCell( - info: .init( + info: ApproachCell.Info( title: "onDevice" - .put(key: "device_type", value: originatingPlatform.deviceType) + .put(key: "device_type", value: originatingPlatform.device) .localized(), description: "onDeviceDescription" .put(key: "app_name", value: Constants.app_name) - .put(key: "device_type", value: originatingPlatform.deviceType) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "device_type", value: originatingPlatform.device) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular), @@ -68,12 +68,12 @@ struct UpdatePlanNonOriginatingPlatformContent: View { ) ApproachCell( - info: .init( + info: ApproachCell.Info( title: "viaStoreWebsite" - .put(key: "platform", value: originatingPlatform.name) + .put(key: "platform", value: originatingPlatform.platform) .localized(), description: "viaStoreWebsiteDescription" - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "platform_store", value: originatingPlatform.store) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular), diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift index cef3b01067..169a33c388 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift @@ -84,7 +84,7 @@ public struct SessionProPaymentScreen: View { private var content: some View { VStack(spacing: Values.mediumSmallSpacing) { ListItemLogoWithPro( - info: .init( + info: ListItemLogoWithPro.Info( themeStyle: { switch viewModel.dataModel.flow { case .refund, .cancel: return .disabled @@ -98,213 +98,229 @@ public struct SessionProPaymentScreen: View { ) switch viewModel.dataModel.flow { - case .purchase(let billingAccess): - if billingAccess { - SessionProPlanPurchaseContent( - currentSelection: $currentSelection, - isShowingTooltip: $isShowingTooltip, - suppressUntil: $suppressUntil, - isPendingPurchase: $isPendingPurchase, - currentPlan: nil, - sessionProPlans: viewModel.dataModel.plans, - actionButtonTitle: "upgrade".localized(), - actionType: "proUpgradingAction".localized(), - activationType: "proActivatingActivation".localized(), - purchaseAction: { updatePlan() }, - openTosPrivacyAction: { openTosPrivacy() } - ) - } else { - NoBillingAccessContent( - isRenewingPro: false, - originatingPlatform: .iOS, - openProRoadmapAction: { openUrl(Constants.session_pro_roadmap) } - ) - } + case .purchase(billingAccess: true): + SessionProPlanPurchaseContent( + currentSelection: $currentSelection, + isShowingTooltip: $isShowingTooltip, + suppressUntil: $suppressUntil, + isPendingPurchase: $isPendingPurchase, + currentPlan: nil, + sessionProPlans: viewModel.dataModel.plans, + actionButtonTitle: "upgrade".localized(), + actionType: "proUpgradingAction".localized(), + activationType: "proActivatingActivation".localized(), + purchaseAction: { + Task { @MainActor in + await updatePlan() + } + }, + openTosPrivacyAction: { openTosPrivacy() } + ) - case .renew(let originatingPlatform, let billingAccess): - if billingAccess { - SessionProPlanPurchaseContent( - currentSelection: $currentSelection, - isShowingTooltip: $isShowingTooltip, - suppressUntil: $suppressUntil, - isPendingPurchase: $isPendingPurchase, - currentPlan: nil, - sessionProPlans: viewModel.dataModel.plans, - actionButtonTitle: "renew".localized(), - actionType: "proRenewingAction".localized(), - activationType: "proReactivatingActivation".localized(), - purchaseAction: { updatePlan() }, - openTosPrivacyAction: { openTosPrivacy() } - ) - } else { - NoBillingAccessContent( - isRenewingPro: true, - originatingPlatform: originatingPlatform, - openProRoadmapAction: { openUrl(Constants.session_pro_roadmap) }, - openPlatformStoreWebsiteAction: { openUrl(Constants.apple_store_subscriptions_url) } - ) - } + case .purchase(billingAccess: false): + NoBillingAccessContent( + isRenewingPro: false, + originatingPlatform: .iOS, + openProRoadmapAction: { openUrl(SNUIKit.urlStringProvider().proRoadmap) } + ) + + case .renew(_, billingAccess: true): + SessionProPlanPurchaseContent( + currentSelection: $currentSelection, + isShowingTooltip: $isShowingTooltip, + suppressUntil: $suppressUntil, + isPendingPurchase: $isPendingPurchase, + currentPlan: nil, + sessionProPlans: viewModel.dataModel.plans, + actionButtonTitle: "renew".localized(), + actionType: "proRenewingAction".localized(), + activationType: "proReactivatingActivation".localized(), + purchaseAction: { + Task { @MainActor in + await updatePlan() + } + }, + openTosPrivacyAction: { openTosPrivacy() } + ) - case .update(let currentPlan, let expiredOn, let isAutoRenewing, let originatingPlatform, let isNonOriginatingAccount, let billingAccess): - if originatingPlatform != .iOS || isNonOriginatingAccount == true { - UpdatePlanNonOriginatingPlatformContent( - currentPlan: currentPlan, - currentPlanExpiredOn: expiredOn, - isAutoRenewing: isAutoRenewing, - originatingPlatform: originatingPlatform, - openPlatformStoreWebsiteAction: { openUrl(Constants.google_play_store_subscriptions_url) } - ) - } else { - if billingAccess { - SessionProPlanPurchaseContent( - currentSelection: $currentSelection, - isShowingTooltip: $isShowingTooltip, - suppressUntil: $suppressUntil, - isPendingPurchase: $isPendingPurchase, - currentPlan: currentPlan, - sessionProPlans: viewModel.dataModel.plans, - actionButtonTitle: "updateAccess".put(key: "pro", value: Constants.pro).localized(), - actionType: "proUpdatingAction".localized(), - activationType: "", - purchaseAction: { updatePlan() }, - openTosPrivacyAction: { openTosPrivacy() } - ) - } else { - NoBillingAccessContent( - isRenewingPro: false, - originatingPlatform: originatingPlatform, - openProRoadmapAction: { openUrl(Constants.session_pro_roadmap) } - ) + case .renew(let originatingPlatform, billingAccess: false): + NoBillingAccessContent( + isRenewingPro: true, + originatingPlatform: originatingPlatform, + openProRoadmapAction: { openUrl(SNUIKit.urlStringProvider().proRoadmap) }, + openPlatformStoreWebsiteAction: { + openUrl(SNUIKit.proClientPlatformStringProvider(for: .iOS).updateSubscriptionUrl) } - } + ) - case .refund(let originatingPlatform, let isNonOriginatingAccount, let requestedAt): - if originatingPlatform == .iOS && isNonOriginatingAccount != true { - RequestRefundOriginatingPlatformContent( - requestRefundAction: { - Task { - await viewModel.requestRefund( - success: { - DispatchQueue.main.async { - host.controller?.navigationController?.popViewController(animated: true) - } - }, - failure: { - // TODO: [PRO] Request refund failure behaviour - } - ) - } + case .update(let currentPlan, let expiredOn, originatingPlatform: .iOS, let isAutoRenewing, isNonOriginatingAccount: true, _): + UpdatePlanNonOriginatingPlatformContent( + currentPlan: currentPlan, + currentPlanExpiredOn: expiredOn, + isAutoRenewing: isAutoRenewing, + originatingPlatform: .iOS, + openPlatformStoreWebsiteAction: { + openUrl(SNUIKit.proClientPlatformStringProvider(for: .iOS).updateSubscriptionUrl) + } + ) + + case .update(let currentPlan, let expiredOn, originatingPlatform: .android, let isAutoRenewing, _, _): + UpdatePlanNonOriginatingPlatformContent( + currentPlan: currentPlan, + currentPlanExpiredOn: expiredOn, + isAutoRenewing: isAutoRenewing, + originatingPlatform: .android, + openPlatformStoreWebsiteAction: { + openUrl(SNUIKit.proClientPlatformStringProvider(for: .android).updateSubscriptionUrl) + } + ) + + case .update(let currentPlan, _, _, _, _, billingAccess: true): + SessionProPlanPurchaseContent( + currentSelection: $currentSelection, + isShowingTooltip: $isShowingTooltip, + suppressUntil: $suppressUntil, + isPendingPurchase: $isPendingPurchase, + currentPlan: currentPlan, + sessionProPlans: viewModel.dataModel.plans, + actionButtonTitle: "updateAccess" + .put(key: "pro", value: Constants.pro) + .localized(), + actionType: "proUpdatingAction".localized(), + activationType: "", + purchaseAction: { + Task { @MainActor in + await updatePlan() } - ) - } else { - RequestRefundNonOriginatorContent( - originatingPlatform: originatingPlatform, - isNonOriginatingAccount: isNonOriginatingAccount, - requestedAt: requestedAt, - openPlatformStoreWebsiteAction: { - openUrl( - isNonOriginatingAccount == true ? - Constants.app_store_refund_support : - Constants.google_play_store_subscriptions_url - ) + }, + openTosPrivacyAction: { openTosPrivacy() } + ) + + case .update(_, _, let originatingPlatform, _, _, billingAccess: false): + NoBillingAccessContent( + isRenewingPro: false, + originatingPlatform: originatingPlatform, + openProRoadmapAction: { openUrl(SNUIKit.urlStringProvider().proRoadmap) } + ) + + case .refund(originatingPlatform: .iOS, isNonOriginatingAccount: true, let requestedAt): + RequestRefundNonOriginatorContent( + originatingPlatform: .iOS, + isNonOriginatingAccount: true, + requestedAt: requestedAt, + openPlatformStoreWebsiteAction: { + openUrl(SNUIKit.proClientPlatformStringProvider(for: .iOS).updateSubscriptionUrl) + } + ) + + case .refund(originatingPlatform: .iOS, _, _): + RequestRefundOriginatingPlatformContent( + requestRefundAction: { + Task { @MainActor [weak viewModel] in + do { + try await viewModel?.requestRefund(scene: host.controller?.view.window?.windowScene) + host.controller?.navigationController?.popViewController(animated: true) + } + catch { + // TODO: [PRO] Request refund failure behaviour + } } - ) - } + } + ) - case .cancel(let originatingPlatform): - if originatingPlatform == .iOS { - CancelPlanOriginatingPlatformContent( - cancelPlanAction: { - Task { - await viewModel.cancelPro( - success: { - DispatchQueue.main.async { - host.controller?.navigationController?.popViewController(animated: true) - } - }, - failure: { - // TODO: [PRO] Failed to cancel plan - } - ) + case .refund(originatingPlatform: .android, let isNonOriginatingAccount, let requestedAt): + RequestRefundNonOriginatorContent( + originatingPlatform: .android, + isNonOriginatingAccount: isNonOriginatingAccount, + requestedAt: requestedAt, + openPlatformStoreWebsiteAction: { + openUrl(SNUIKit.proClientPlatformStringProvider(for: .android).updateSubscriptionUrl) + } + ) + + case .cancel(originatingPlatform: .iOS): + CancelPlanOriginatingPlatformContent( + cancelPlanAction: { + Task { @MainActor [weak viewModel] in + do { + try await viewModel?.cancelPro(scene: host.controller?.view.window?.windowScene) + host.controller?.navigationController?.popViewController(animated: true) + } + catch { + // TODO: [PRO] Failed to cancel plan } } - ) - } else { - CancelPlanNonOriginatingPlatformContent( - originatingPlatform: originatingPlatform, - openPlatformStoreWebsiteAction: { openUrl(Constants.google_play_store_subscriptions_url) } - ) - } + } + ) + + case .cancel(let originatingPlatform): + CancelPlanNonOriginatingPlatformContent( + originatingPlatform: originatingPlatform, + openPlatformStoreWebsiteAction: { + openUrl(SNUIKit.proClientPlatformStringProvider(for: .android).updateSubscriptionUrl) + } + ) } } } - private func updatePlan() { + private func updatePlan() async { + let updatedPlan: SessionProPaymentScreenContent.SessionProPlanInfo = viewModel.dataModel.plans[currentSelection] isPendingPurchase = true - let updatedPlan = viewModel.dataModel.plans[currentSelection] + switch viewModel.dataModel.flow { - case .update(let currentPlan, let expiredOn, let isAutoRenewing, _, _, _): - if let updatedPlanExpiredOn = Calendar.current.date(byAdding: .month, value: updatedPlan.duration, to: expiredOn) { - let confirmationModal = ConfirmationModal( - info: .init( - title: "updateAccess" - .put(key: "pro", value: Constants.pro) - .localized(), - body: .attributedText( - isAutoRenewing ? - "proUpdateAccessDescription" - .put(key: "current_plan_length", value: currentPlan.durationString) - .put(key: "selected_plan_length", value: updatedPlan.durationString) - .put(key: "selected_plan_length_singular", value: updatedPlan.durationStringSingular) - .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.largeRegular) : - "proUpdateAccessExpireDescription" - .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) - .put(key: "selected_plan_length", value: updatedPlan.durationString) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.largeRegular), - scrollMode: .never - ), - confirmTitle: "update".localized(), - onConfirm: { _ in - Task { - await viewModel.purchase( - planInfo: updatedPlan, - success: { - Task { @MainActor in - onPaymentSuccess(expiredOn: updatedPlanExpiredOn) - } - }, - failure: { - Task { @MainActor in - onPaymentFailed() - } - } - ) - } - } - ) - ) - self.host.controller?.present(confirmationModal, animated: true) - } + case .refund, .cancel: break case .purchase, .renew: - Task { - await viewModel.purchase( - planInfo: updatedPlan, - success: { - Task { @MainActor in - onPaymentSuccess(expiredOn: nil) - } - }, - failure: { - Task { @MainActor in - onPaymentFailed() + do { + try await viewModel.purchase(planInfo: updatedPlan) + onPaymentSuccess(expiredOn: nil) + } + catch { + onPaymentFailed() + } + + case .update(let currentPlan, let expiredOn, _, let isAutoRenewing, _, _): + let updatedPlanExpiredOn: Date = (Calendar.current + .date(byAdding: .month, value: updatedPlan.duration, to: expiredOn) ?? + expiredOn) + + let confirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "updateAccess" + .put(key: "pro", value: Constants.pro) + .localized(), + body: .attributedText( + isAutoRenewing ? + "proUpdateAccessDescription" + .put(key: "current_plan_length", value: currentPlan.durationString) + .put(key: "selected_plan_length", value: updatedPlan.durationString) + .put(key: "selected_plan_length_singular", value: updatedPlan.durationStringSingular) + .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.largeRegular) : + "proUpdateAccessExpireDescription" + .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) + .put(key: "selected_plan_length", value: updatedPlan.durationString) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.largeRegular), + scrollMode: .never + ), + confirmTitle: "update".localized(), + onConfirm: { _ in + Task { @MainActor [weak viewModel] in + do { + try await viewModel?.purchase(planInfo: updatedPlan) + onPaymentSuccess(expiredOn: updatedPlanExpiredOn) + } + catch { + onPaymentFailed() + } } } ) - } - default: break + ) + + self.host.controller?.present(confirmationModal, animated: true) } } @@ -364,7 +380,7 @@ public struct SessionProPaymentScreen: View { // TODO: [PRO] Retry connecting to Pro backend }, onCancel: { _ in - self.openUrl(Constants.session_pro_support_url) + self.openUrl(SNUIKit.urlStringProvider().proSupport) } ) ) @@ -376,8 +392,8 @@ public struct SessionProPaymentScreen: View { let modal: ModalHostingViewController = ModalHostingViewController( modal: MutipleLinksModal( links: [ - Constants.session_pro_terms_url, - Constants.session_pro_privacy_url + SNUIKit.urlStringProvider().proTermsOfService, + SNUIKit.urlStringProvider().proPrivacyPolicy ], openURL: { url in if let extensionContext = self.host.controller?.extensionContext { diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPlanUpdatedScreen.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPlanUpdatedScreen.swift index ac94bdcb42..fcab836dfa 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPlanUpdatedScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPlanUpdatedScreen.swift @@ -12,12 +12,9 @@ public struct SessionProPlanUpdatedScreen: View { var blurSizeHeight: CGFloat { isFromBottomSheet ? 111 : blurSizeWidth } var dismissButtonTitle: String { switch flow { - case .purchase, .renew: - "proStartUsing".put(key: "pro", value: Constants.pro).localized() - case .update: - "theReturn".localized() - default: - "" + case .purchase, .renew: "proStartUsing".put(key: "pro", value: Constants.pro).localized() + case .update: "theReturn".localized() + default: "" } } var desription: ThemedAttributedString { diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProSettings+ProFeatures.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProSettings+ProFeatures.swift index 8a63d714ab..b1445cb437 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProSettings+ProFeatures.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProSettings+ProFeatures.swift @@ -8,7 +8,7 @@ import Lucide public struct ProFeaturesInfo { public enum ProState { - case none + case neverBeenPro case expired case active } @@ -23,35 +23,55 @@ public struct ProFeaturesInfo { return [ ProFeaturesInfo( icon: Lucide.image(icon: .messageSquare, size: IconSize.medium.size), - backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.blue), .explicitPrimary(.purple)], + backgroundColors: (proState == .expired ? + [ThemeValue.disabled] : + [.explicitPrimary(.blue), .explicitPrimary(.purple)] + ), title: "proLongerMessages".localized(), - description: ( - proState == .none ? - "nonProLongerMessagesDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular) : - "proLongerMessagesDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular) + description: (proState == .neverBeenPro ? + "nonProLongerMessagesDescription" + .localizedFormatted(baseFont: Fonts.Body.smallRegular) : + "proLongerMessagesDescription" + .localizedFormatted(baseFont: Fonts.Body.smallRegular) ), accessory: .none ), ProFeaturesInfo( icon: Lucide.image(icon: .pin, size: IconSize.medium.size), - backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.purple), .explicitPrimary(.pink)], + backgroundColors: (proState == .expired ? + [ThemeValue.disabled] : + [.explicitPrimary(.purple), .explicitPrimary(.pink)] + ), title: "proUnlimitedPins".localized(), - description: "proUnlimitedPinsDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular), + description: "proUnlimitedPinsDescription" + .localizedFormatted(baseFont: Fonts.Body.smallRegular), accessory: .none ), ProFeaturesInfo( icon: Lucide.image(icon: .squarePlay, size: IconSize.medium.size), - backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.pink), .explicitPrimary(.red)], + backgroundColors: (proState == .expired ? + [ThemeValue.disabled] : + [.explicitPrimary(.pink), .explicitPrimary(.red)] + ), title: "proAnimatedDisplayPictures".localized(), - description: "proAnimatedDisplayPicturesDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular), + description: "proAnimatedDisplayPicturesDescription" + .localizedFormatted(baseFont: Fonts.Body.smallRegular), accessory: .none ), ProFeaturesInfo( icon: Lucide.image(icon: .rectangleEllipsis, size: IconSize.medium.size), - backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.red), .explicitPrimary(.orange)], + backgroundColors: (proState == .expired ? + [ThemeValue.disabled] : + [.explicitPrimary(.red), .explicitPrimary(.orange)] + ), title: "proBadges".localized(), - description: "proBadgesDescription".put(key: "app_name", value: Constants.app_name).localizedFormatted(Fonts.Body.smallRegular), - accessory: .proBadgeLeading(themeBackgroundColor: (proState == .expired) ? .disabled : .primary) + description: "proBadgesDescription" + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(Fonts.Body.smallRegular), + accessory: .proBadgeLeading( + size: .mini, + themeBackgroundColor: (proState == .expired ? .disabled : .primary) + ) ) ] } @@ -59,13 +79,16 @@ public struct ProFeaturesInfo { public static func plusMoreFeatureInfo(proState: ProState) -> ProFeaturesInfo { ProFeaturesInfo( icon: Lucide.image(icon: .circlePlus, size: IconSize.medium.size), - backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.orange), .explicitPrimary(.yellow)], + backgroundColors: (proState == .expired ? + [ThemeValue.disabled] : + [.explicitPrimary(.orange), .explicitPrimary(.yellow)] + ), title: "plusLoadsMore".localized(), description: "plusLoadsMoreDescription" .put(key: "pro", value: Constants.pro) .put(key: "icon", value: Lucide.Icon.squareArrowUpRight) .localizedFormatted(Fonts.Body.smallRegular), - accessory: .proBadgeLeading(themeBackgroundColor: (proState == .expired) ? .disabled : .primary) + accessory: .none ) } } diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProUI.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProUI.swift new file mode 100644 index 0000000000..f2535f7a6e --- /dev/null +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProUI.swift @@ -0,0 +1,19 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum SessionProUI {} + +// MARK: - ClientPlatform + +public extension SessionProUI { + enum ClientPlatform: Sendable, Equatable, CaseIterable { + case iOS + case android + + public var device: String { SNUIKit.proClientPlatformStringProvider(for: self).device } + public var store: String { SNUIKit.proClientPlatformStringProvider(for: self).store } + public var platform: String { SNUIKit.proClientPlatformStringProvider(for: self).platform } + public var platformAccount: String { SNUIKit.proClientPlatformStringProvider(for: self).platformAccount } + } +} diff --git a/SessionUIKit/Style Guide/Constants+Apple.swift b/SessionUIKit/Style Guide/Constants+Apple.swift deleted file mode 100644 index 37ad7a68f4..0000000000 --- a/SessionUIKit/Style Guide/Constants+Apple.swift +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable -public extension Constants { - // TODO: These strings will be going to be defined in libSession - - // MARK: - URL - static let session_network_url = "https://docs.getsession.org/session-network" - static let session_staking_url = "https://docs.getsession.org/session-network/staking" - static let session_token_url = "https://token.getsession.org" - static let session_donations_url = "https://getsession.org/donate#app" - static let session_pro_roadmap = "https://getsession.org/pro-roadmap" - static let session_pro_faq_url = "https://getsession.org/faq#pro" - static let session_pro_support_url = "https://getsession.org/pro-form" - static let session_pro_recovery_support_url = "https://sessionapp.zendesk.com/hc/sections/4416517450649-Support" - static let session_feedback_url = "https://getsession.org/feedback" - static let app_store_refund_support = "https://support.apple.com/118224" - static let google_play_store_subscriptions_url = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger" - static let apple_store_subscriptions_url = "https://apps.apple.com/account/subscriptions" - static let session_pro_terms_url = "https://getsession.org/pro/terms" - static let session_pro_privacy_url = "https://getsession.org/pro/privacy" - - // MARK: - Names - static let platform_account = "Apple Account" - static let platform_store = "Apple App Store" - static let platform_name = "iOS" - static let platform = "Apple" - static let android_platform_account = "Google Account" - static let android_platform_store = "Google Play Store" - static let android_platform_name = "Android" - static let android_platform = "Google" - static let IPA = "IPA" -} diff --git a/SessionUIKit/Style Guide/ThemeManager.swift b/SessionUIKit/Style Guide/ThemeManager.swift index 36bd9374f1..733bf8f21a 100644 --- a/SessionUIKit/Style Guide/ThemeManager.swift +++ b/SessionUIKit/Style Guide/ThemeManager.swift @@ -227,17 +227,14 @@ public enum ThemeManager { } @MainActor public static func onThemeChange(observer: AnyObject, callback: @escaping @MainActor (Theme, Theme.PrimaryColor, (ThemeValue) -> UIColor?) -> ()) { - ThemeManager.uiRegistry.setObject( - ThemeApplier( - existingApplier: ThemeManager.get(for: observer), - info: [] - ) { theme in - callback(theme, syncState.state.primaryColor, { value -> UIColor? in - ThemeManager.color(for: value, in: theme, with: syncState.state.primaryColor) - }) - }, - forKey: observer - ) + ThemeManager.storeAndApply( + observer, + info: [] + ) { theme in + callback(theme, syncState.state.primaryColor, { value -> UIColor? in + ThemeManager.color(for: value, in: theme, with: syncState.state.primaryColor) + }) + } } internal static func color( @@ -317,28 +314,40 @@ public enum ThemeManager { } } + @MainActor internal static func storeAndApply( + _ view: T, + info: [ThemeApplier.Info], + applyTheme: @escaping @MainActor (Theme) -> () + ) { + let applier: ThemeApplier = ThemeApplier( + existingApplier: ThemeManager.get(for: view), + info: info, + applyTheme: applyTheme + ) + ThemeManager.uiRegistry.setObject(applier, forKey: view) + applier.performInitialApplicationIfNeeded() + } + @MainActor internal static func set( _ view: T, keyPath: ReferenceWritableKeyPath, - to value: ThemeValue? + to value: ThemeValue?, + as info: ThemeApplier.Info = .other ) { - ThemeManager.uiRegistry.setObject( - ThemeApplier( - existingApplier: ThemeManager.get(for: view), - info: [ keyPath ] - ) { [weak view] theme in - guard let value: ThemeValue = value else { - view?[keyPath: keyPath] = nil - return - } + ThemeManager.storeAndApply( + view, + info: [ .keyPath(keyPath), .color(value), info ] + ) { [weak view] theme in + guard let value: ThemeValue = value else { + view?[keyPath: keyPath] = nil + return + } - let currentState: ThemeState = syncState.state - view?[keyPath: keyPath] = ThemeManager.resolvedColor( - ThemeManager.color(for: value, in: currentState.theme, with: currentState.primaryColor) - ) - }, - forKey: view - ) + let currentState: ThemeState = syncState.state + view?[keyPath: keyPath] = ThemeManager.resolvedColor( + ThemeManager.color(for: value, in: currentState.theme, with: currentState.primaryColor) + ) + } } @MainActor internal static func remove( @@ -346,7 +355,7 @@ public enum ThemeManager { keyPath: ReferenceWritableKeyPath ) { // Note: Need to explicitly remove (setting to 'nil' won't actually remove it) - guard let updatedApplier: ThemeApplier = ThemeManager.get(for: view)?.removing(allWith: keyPath) else { + guard let updatedApplier: ThemeApplier = ThemeManager.get(for: view)?.removing(allWith: .keyPath(keyPath)) else { ThemeManager.uiRegistry.removeObject(forKey: view) return } @@ -357,25 +366,23 @@ public enum ThemeManager { @MainActor internal static func set( _ view: T, keyPath: ReferenceWritableKeyPath, - to value: ThemeValue? + to value: ThemeValue?, + as info: ThemeApplier.Info = .other ) { - ThemeManager.uiRegistry.setObject( - ThemeApplier( - existingApplier: ThemeManager.get(for: view), - info: [ keyPath ] - ) { [weak view] theme in - guard let value: ThemeValue = value else { - view?[keyPath: keyPath] = nil - return - } - - let currentState: ThemeState = syncState.state - view?[keyPath: keyPath] = ThemeManager.resolvedColor( - ThemeManager.color(for: value, in: currentState.theme, with: currentState.primaryColor) - )?.cgColor - }, - forKey: view - ) + ThemeManager.storeAndApply( + view, + info: [ .keyPath(keyPath), .color(value), info ] + ) { [weak view] theme in + guard let value: ThemeValue = value else { + view?[keyPath: keyPath] = nil + return + } + + let currentState: ThemeState = syncState.state + view?[keyPath: keyPath] = ThemeManager.resolvedColor( + ThemeManager.color(for: value, in: currentState.theme, with: currentState.primaryColor) + )?.cgColor + } } @MainActor internal static func remove( @@ -384,7 +391,7 @@ public enum ThemeManager { ) { ThemeManager.uiRegistry.setObject( ThemeManager.get(for: view)? - .removing(allWith: keyPath), + .removing(allWith: .keyPath(keyPath)), forKey: view ) } @@ -394,48 +401,89 @@ public enum ThemeManager { keyPath: ReferenceWritableKeyPath, to value: ThemedAttributedString? ) { - ThemeManager.uiRegistry.setObject( - ThemeApplier( - existingApplier: ThemeManager.get(for: view), - info: [ keyPath ] - ) { [weak view] theme in - guard let originalThemedString: ThemedAttributedString = value else { - view?[keyPath: keyPath] = nil - return - } + ThemeManager.storeAndApply( + view, + info: [ .keyPath(keyPath), .other ] + ) { [weak view] theme in + guard let originalThemedString: ThemedAttributedString = value else { + view?[keyPath: keyPath] = nil + return + } + + let newAttrString: NSMutableAttributedString = NSMutableAttributedString() + let originalAttrString: NSAttributedString = originalThemedString.attributedString + let fullRange: NSRange = NSRange(location: 0, length: originalAttrString.length) + let currentState: ThemeState = syncState.state + + originalAttrString.enumerateAttributes(in: fullRange, options: []) { attributes, range, _ in + var newAttributes: [NSAttributedString.Key: Any] = attributes + var foundTextColor: Bool = false - let newAttrString: NSMutableAttributedString = NSMutableAttributedString() - let fullRange: NSRange = NSRange(location: 0, length: originalThemedString.value.length) - let currentState: ThemeState = syncState.state + /// Retrieve our custom alpha multiplier attribute + let alphaMultiplier: CGFloat = ((newAttributes[.themeAlphaMultiplier] as? CGFloat) ?? 1.0) + newAttributes.removeValue(forKey: .themeAlphaMultiplier) - originalThemedString.value.enumerateAttributes(in: fullRange, options: []) { attributes, range, _ in - var newAttributes: [NSAttributedString.Key: Any] = attributes + /// Convert any of our custom attributes to their normal ones + NSAttributedString.Key.themedKeys.forEach { key in + guard let themeValue: ThemeValue = newAttributes[key] as? ThemeValue else { return } + newAttributes.removeValue(forKey: key) - /// Convert any of our custom attributes to their normal ones - NSAttributedString.Key.themedKeys.forEach { key in - guard let themeValue: ThemeValue = newAttributes[key] as? ThemeValue else { - return - } - - newAttributes.removeValue(forKey: key) - - guard - let originalKey = key.originalKey, - let color = ThemeManager.color(for: themeValue, in: currentState.theme, with: currentState.primaryColor) as UIColor? - else { return } - - newAttributes[originalKey] = ThemeManager.resolvedColor(color) - } + guard + let originalKey: NSAttributedString.Key = key.originalKey, + let color: UIColor = ThemeManager.color(for: themeValue, in: currentState.theme, with: currentState.primaryColor) + else { return } - /// Add the themed substring to `newAttrString` - let substring: String = originalThemedString.value.attributedSubstring(from: range).string - newAttrString.append(NSAttributedString(string: substring, attributes: newAttributes)) + foundTextColor = true + newAttributes[originalKey] = (alphaMultiplier < 1 ? + ThemeManager.resolvedColor(color)?.withAlphaComponent(alphaMultiplier): + ThemeManager.resolvedColor(color) + ) } - view?[keyPath: keyPath] = ThemedAttributedString(attributedString: newAttrString) - }, - forKey: view - ) + /// If we didn't find an explicit text color but have set `themeAlphaMultiplier` then we need to try to extract + /// the current text color from the component and add that + if !foundTextColor && alphaMultiplier < 1 { + let maybeThemeValue: ThemeValue? = view + .map { ThemeManager.get(for: $0) }? + .map { $0.allInfo } + .map { $0.first(where: { $0.contains(.textColor) }) }? + .map { $0.compactMap { $0.storedValue as? ThemeValue } }? + .first + + if + let originalKey: NSAttributedString.Key = .themeForegroundColor.originalKey, + let themeValue: ThemeValue = maybeThemeValue, + let color: UIColor = ThemeManager.color(for: themeValue, in: currentState.theme, with: currentState.primaryColor) + { + newAttributes[originalKey] = ThemeManager.resolvedColor(color)? + .withAlphaComponent(alphaMultiplier) + } + } + + let newAttrSubstring: NSAttributedString + let substring: String = originalAttrString.attributedSubstring(from: range).string + + /// Retrieve our custom user mention image attribute + /// + /// If this has been set then we actually want to entirely replace the tagged content with an image attachment + if let currentUserMentionImage: UIImage = newAttributes[.themeCurrentUserMentionImage] as? UIImage { + newAttrSubstring = MentionUtilities.currentUserMentionImageString( + substring: substring, + currentUserMentionImage: currentUserMentionImage + ) + newAttributes.removeValue(forKey: .themeCurrentUserMentionImage) + } + else { + /// Otherwise we can just extract the raw string from the original + newAttrSubstring = NSAttributedString(string: substring, attributes: newAttributes) + } + + /// Add the themed substring to `newAttrString` + newAttrString.append(newAttrSubstring) + } + + view?[keyPath: keyPath] = ThemedAttributedString(attributedString: newAttrString) + } } @MainActor internal static func set( @@ -511,18 +559,35 @@ private final class ThemeManagerSyncState { // MARK: - ThemeApplier internal class ThemeApplier { - enum InfoKey: String { - case keyPath - case controlState + enum Info: Equatable { + case keyPath(AnyHashable) + case state(UIControl.State) + case color(ThemeValue?) + case textColor + case backgroundColor + case other + + public var storedValue: Any? { + switch self { + case .keyPath(let value): return value + case .color(let value): return value + case .state(let value): return value + case .textColor, .backgroundColor, .other: return nil + } + } } private let applyTheme: @MainActor (Theme) -> () - private let info: [AnyHashable] + private let info: [Info] private var otherAppliers: [ThemeApplier]? + public var allInfo: [[Info]] { + return [info] + (otherAppliers?.flatMap { $0.allInfo } ?? []) + } + @MainActor init( existingApplier: ThemeApplier?, - info: [AnyHashable], + info: [Info], applyTheme: @escaping @MainActor (Theme) -> () ) { self.applyTheme = applyTheme @@ -535,16 +600,11 @@ internal class ThemeApplier { .appending(contentsOf: existingApplier?.otherAppliers) .compactMap { $0?.clearingOtherAppliers() } .filter { $0.info != info } - - // Automatically apply the theme immediately (if the database has been setup) - if SNUIKit.config?.isStorageValid == true || ThemeManager.syncState.hasLoadedTheme { - apply(theme: ThemeManager.syncState.state.theme, isInitialApplication: true) - } } // MARK: - Functions - public func removing(allWith info: AnyHashable) -> ThemeApplier? { + public func removing(allWith info: Info) -> ThemeApplier? { let remainingAppliers: [ThemeApplier] = [self] .appending(contentsOf: self.otherAppliers) .filter { applier in !applier.info.contains(info) } @@ -569,6 +629,13 @@ internal class ThemeApplier { return self } + @MainActor fileprivate func performInitialApplicationIfNeeded() { + // Only perform the initial application if both the database and theme have been setup + if SNUIKit.config?.isStorageValid == true || ThemeManager.syncState.hasLoadedTheme { + apply(theme: ThemeManager.syncState.state.theme, isInitialApplication: true) + } + } + @MainActor fileprivate func apply(theme: Theme, isInitialApplication: Bool = false) { self.applyTheme(theme) diff --git a/SessionUIKit/Style Guide/Themes/Theme.swift b/SessionUIKit/Style Guide/Themes/Theme.swift index bd5739234d..170bf27b8c 100644 --- a/SessionUIKit/Style Guide/Themes/Theme.swift +++ b/SessionUIKit/Style Guide/Themes/Theme.swift @@ -113,7 +113,7 @@ public protocol ThemedNavigation { // MARK: - ThemeValue -public indirect enum ThemeValue: Hashable, Equatable { +public indirect enum ThemeValue: Sendable, Hashable, Equatable { case value(ThemeValue, alpha: CGFloat) case explicitPrimary(Theme.PrimaryColor) case dynamicForInterfaceStyle(light: ThemeValue, dark: ThemeValue) diff --git a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift index 805ab5265f..7701c948f2 100644 --- a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift +++ b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift @@ -6,7 +6,12 @@ import UIKit public extension NSAttributedString.Key { internal static let themedKeys: Set = [ - .themeForegroundColor, .themeBackgroundColor, .themeStrokeColor, .themeUnderlineColor, .themeStrikethroughColor + .themeForegroundColor, .themeBackgroundColor, .themeStrokeColor, .themeUnderlineColor, + .themeStrikethroughColor + ] + + internal static let specialKeys: Set = [ + .themeAlphaMultiplier, .themeCurrentUserMentionImage ] static let themeForegroundColor = NSAttributedString.Key("org.getsession.themeForegroundColor") @@ -14,6 +19,8 @@ public extension NSAttributedString.Key { static let themeStrokeColor = NSAttributedString.Key("org.getsession.themeStrokeColor") static let themeUnderlineColor = NSAttributedString.Key("org.getsession.themeUnderlineColor") static let themeStrikethroughColor = NSAttributedString.Key("org.getsession.themeStrikethroughColor") + static let themeAlphaMultiplier = NSAttributedString.Key("org.getsession.themeAlphaMultiplier") + static let themeCurrentUserMentionImage = NSAttributedString.Key("org.getsession.themeCurrentUserMentionImage") internal var originalKey: NSAttributedString.Key? { switch self { @@ -29,36 +36,40 @@ public extension NSAttributedString.Key { // MARK: - ThemedAttributedString -public class ThemedAttributedString: Equatable, Hashable { - internal var value: NSMutableAttributedString { - if let (image, accessibilityLabel) = imageAttachmentGenerator?() { - let attachment: NSTextAttachment = NSTextAttachment(image: image) - attachment.accessibilityLabel = accessibilityLabel /// Ensure it's still visible to accessibility inspectors - - if let font = imageAttachmentReferenceFont { - attachment.bounds = CGRect( - x: 0, - y: font.capHeight / 2 - image.size.height / 2, - width: image.size.width, - height: image.size.height - ) - } - - return NSMutableAttributedString(attachment: attachment) +public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hashable { + /// `NSMutableAttributedString` is not `Sendable` so we need to manually manage access via an `NSLock` to ensure + /// thread safety + private let lock: NSLock = NSLock() + private let _attributedString: NSMutableAttributedString + + internal var attributedString: NSAttributedString { + lock.lock() + defer { lock.unlock() } + return _attributedString + } + + public var string: String { attributedString.string } + public var allAttributes: [(attributes: [NSAttributedString.Key: Any], range: NSRange)] { + var result: [(attributes: [NSAttributedString.Key: Any], range: NSRange)] = [] + let attrString: NSAttributedString = attributedString + let fullRange: NSRange = NSRange(location: 0, length: attrString.length) + + attrString.enumerateAttributes(in: fullRange) { attributes, range, _ in + result.append((attributes, range)) } - return attributedString + + return result } - public var string: String { value.string } - public var length: Int { value.length } /// It seems that a number of UI elements don't properly check the `NSTextAttachment.accessibilityLabel` when /// constructing their accessibility label, as such we need to construct our own which includes that content public var constructedAccessibilityLabel: String { let result: NSMutableString = NSMutableString() - let rawString: String = value.string - let fullRange: NSRange = NSRange(location: 0, length: self.length) + let attrString: NSAttributedString = attributedString + let rawString: String = attrString.string + let fullRange: NSRange = NSRange(location: 0, length: attrString.length) - value.enumerateAttributes( + attrString.enumerateAttributes( in: fullRange, options: [] ) { attributes, range, stop in @@ -80,44 +91,37 @@ public class ThemedAttributedString: Equatable, Hashable { return result as String } - internal var imageAttachmentGenerator: (() -> (UIImage, String?)?)? - internal var imageAttachmentReferenceFont: UIFont? - internal var attributedString: NSMutableAttributedString - public init() { - self.attributedString = NSMutableAttributedString() + self._attributedString = NSMutableAttributedString() } public init(attributedString: ThemedAttributedString) { - self.attributedString = attributedString.attributedString - self.imageAttachmentGenerator = attributedString.imageAttachmentGenerator + self._attributedString = attributedString._attributedString } public init(attributedString: NSAttributedString) { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - self.attributedString = NSMutableAttributedString(attributedString: attributedString) + self._attributedString = NSMutableAttributedString(attributedString: attributedString) } public init(string: String, attributes: [NSAttributedString.Key: Any] = [:]) { #if DEBUG ThemedAttributedString.validateAttributes(attributes) #endif - self.attributedString = NSMutableAttributedString(string: string, attributes: attributes) + self._attributedString = NSMutableAttributedString(string: string, attributes: attributes) } public init(attachment: NSTextAttachment, attributes: [NSAttributedString.Key: Any] = [:]) { #if DEBUG ThemedAttributedString.validateAttributes(attributes) #endif - self.attributedString = NSMutableAttributedString(attachment: attachment) + self._attributedString = NSMutableAttributedString(attachment: attachment) } - public init(imageAttachmentGenerator: @escaping (() -> (UIImage, String?)?), referenceFont: UIFont?) { - self.attributedString = NSMutableAttributedString() - self.imageAttachmentGenerator = imageAttachmentGenerator - self.imageAttachmentReferenceFont = referenceFont + public init(imageAttachmentGenerator: @escaping (@Sendable () -> (UIImage, String?)?), referenceFont: UIFont?) { + self._attributedString = NSMutableAttributedString() } required init?(coder: NSCoder) { @@ -125,24 +129,41 @@ public class ThemedAttributedString: Equatable, Hashable { } public static func == (lhs: ThemedAttributedString, rhs: ThemedAttributedString) -> Bool { - return lhs.value == rhs.value + return lhs.attributedString == rhs.attributedString } public func hash(into hasher: inout Hasher) { - value.hash(into: &hasher) + attributedString.hash(into: &hasher) } // MARK: - Forwarded Functions public func attributedSubstring(from range: NSRange) -> ThemedAttributedString { - return ThemedAttributedString(attributedString: value.attributedSubstring(from: range)) + return ThemedAttributedString(attributedString: attributedString.attributedSubstring(from: range)) + } + + public func insert(_ attributedString: NSAttributedString, at location: Int) { + #if DEBUG + ThemedAttributedString.validateAttributes(attributedString) + #endif + lock.lock() + defer { lock.unlock() } + self._attributedString.insert(attributedString, at: location) + } + + public func insert(_ other: ThemedAttributedString, at location: Int) { + lock.lock() + defer { lock.unlock() } + self._attributedString.insert(other.attributedString, at: location) } public func appending(string: String, attributes: [NSAttributedString.Key: Any]? = nil) -> ThemedAttributedString { #if DEBUG ThemedAttributedString.validateAttributes(attributes ?? [:]) #endif - self.attributedString.append(NSAttributedString(string: string, attributes: attributes)) + lock.lock() + defer { lock.unlock() } + self._attributedString.append(NSAttributedString(string: string, attributes: attributes)) return self } @@ -150,40 +171,52 @@ public class ThemedAttributedString: Equatable, Hashable { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - self.attributedString.append(attributedString) + lock.lock() + defer { lock.unlock() } + self._attributedString.append(attributedString) } - public func append(_ attributedString: ThemedAttributedString) { - self.attributedString.append(attributedString.value) + public func append(_ other: ThemedAttributedString) { + lock.lock() + defer { lock.unlock() } + self._attributedString.append(other.attributedString) } public func appending(_ attributedString: NSAttributedString) -> ThemedAttributedString { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - self.attributedString.append(attributedString) + lock.lock() + defer { lock.unlock() } + self._attributedString.append(attributedString) return self } - public func appending(_ attributedString: ThemedAttributedString) -> ThemedAttributedString { - self.attributedString.append(attributedString.value) + public func appending(_ other: ThemedAttributedString) -> ThemedAttributedString { + lock.lock() + defer { lock.unlock() } + self._attributedString.append(other.attributedString) return self } public func addAttribute(_ name: NSAttributedString.Key, value attrValue: Any, range: NSRange? = nil) { #if DEBUG - ThemedAttributedString.validateAttributes([name: value]) + ThemedAttributedString.validateAttributes([name: attributedString]) #endif - let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - self.attributedString.addAttribute(name, value: attrValue, range: targetRange) + let targetRange: NSRange = (range ?? NSRange(location: 0, length: attributedString.length)) + lock.lock() + defer { lock.unlock() } + self._attributedString.addAttribute(name, value: attrValue, range: targetRange) } public func addingAttribute(_ name: NSAttributedString.Key, value attrValue: Any, range: NSRange? = nil) -> ThemedAttributedString { #if DEBUG - ThemedAttributedString.validateAttributes([name: value]) + ThemedAttributedString.validateAttributes([name: attributedString]) #endif - let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - self.attributedString.addAttribute(name, value: attrValue, range: targetRange) + let targetRange: NSRange = (range ?? NSRange(location: 0, length: attributedString.length)) + lock.lock() + defer { lock.unlock() } + self._attributedString.addAttribute(name, value: attrValue, range: targetRange) return self } @@ -191,16 +224,20 @@ public class ThemedAttributedString: Equatable, Hashable { #if DEBUG ThemedAttributedString.validateAttributes(attrs) #endif - let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - self.attributedString.addAttributes(attrs, range: targetRange) + let targetRange: NSRange = (range ?? NSRange(location: 0, length: attributedString.length)) + lock.lock() + defer { lock.unlock() } + self._attributedString.addAttributes(attrs, range: targetRange) } public func addingAttributes(_ attrs: [NSAttributedString.Key: Any], range: NSRange? = nil) -> ThemedAttributedString { #if DEBUG ThemedAttributedString.validateAttributes(attrs) #endif - let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - self.attributedString.addAttributes(attrs, range: targetRange) + let targetRange: NSRange = (range ?? NSRange(location: 0, length: attributedString.length)) + lock.lock() + defer { lock.unlock() } + self._attributedString.addAttributes(attrs, range: targetRange) return self } @@ -209,7 +246,16 @@ public class ThemedAttributedString: Equatable, Hashable { } public func replaceCharacters(in range: NSRange, with attributedString: NSAttributedString) { - self.attributedString.replaceCharacters(in: range, with: attributedString) + lock.lock() + defer { lock.unlock() } + self._attributedString.replaceCharacters(in: range, with: attributedString) + } + + public func replacingCharacters(in range: NSRange, with attributedString: NSAttributedString) -> ThemedAttributedString { + lock.lock() + defer { lock.unlock() } + self._attributedString.replaceCharacters(in: range, with: attributedString) + return self } // MARK: - Convenience @@ -250,3 +296,25 @@ public class ThemedAttributedString: Equatable, Hashable { } #endif } + +public extension ThemedAttributedString { + convenience init( + image: UIImage, + accessibilityLabel: String?, + font: UIFont? = nil + ) { + let attachment: NSTextAttachment = NSTextAttachment(image: image) + attachment.accessibilityLabel = accessibilityLabel /// Ensure it's still visible to accessibility inspectors + + if let font { + attachment.bounds = CGRect( + x: 0, + y: font.capHeight / 2 - image.size.height / 2, + width: image.size.width, + height: image.size.height + ) + } + + self.init(attachment: attachment) + } +} diff --git a/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift b/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift index 42c8e018e0..e9a7052395 100644 --- a/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift +++ b/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift @@ -7,7 +7,7 @@ public extension UIView { set { // First we should remove any gradient that had been added self.layer.sublayers?.first(where: { $0 is CAGradientLayer })?.removeFromSuperlayer() - ThemeManager.set(self, keyPath: \.backgroundColor, to: newValue) + ThemeManager.set(self, keyPath: \.backgroundColor, to: newValue, as: .backgroundColor) } get { return nil } } @@ -78,7 +78,7 @@ public extension UIView { public extension UILabel { var themeTextColor: ThemeValue? { - set { ThemeManager.set(self, keyPath: \.textColor, to: newValue) } + set { ThemeManager.set(self, keyPath: \.textColor, to: newValue, as: .textColor) } get { return nil } } @@ -104,7 +104,7 @@ public extension UILabel { public extension UITextView { var themeTextColor: ThemeValue? { - set { ThemeManager.set(self, keyPath: \.textColor, to: newValue) } + set { ThemeManager.set(self, keyPath: \.textColor, to: newValue, as: .textColor) } get { return nil } } @@ -130,7 +130,7 @@ public extension UITextView { public extension UITextField { var themeTextColor: ThemeValue? { - set { ThemeManager.set(self, keyPath: \.textColor, to: newValue) } + set { ThemeManager.set(self, keyPath: \.textColor, to: newValue, as: .textColor) } get { return nil } } @@ -158,26 +158,25 @@ public extension UIButton { func setThemeBackgroundColor(_ value: ThemeValue?, for state: UIControl.State) { let keyPath: KeyPath = \.imageView?.image - ThemeManager.set( + ThemeManager.storeAndApply( self, - to: ThemeApplier( - existingApplier: ThemeManager.get(for: self), - info: [ - keyPath, - state.rawValue - ] - ) { [weak self] theme in - guard - let value: ThemeValue = value, - let color: UIColor = ThemeManager.resolvedColor(ThemeManager.color(for: value, in: theme)) - else { - self?.setBackgroundImage(nil, for: state) - return - } - - self?.setBackgroundImage(color.toImage(), for: state) + info: [ + .keyPath(keyPath), + .backgroundColor, + .color(value), + .state(state) + ] + ) { [weak self] theme in + guard + let value: ThemeValue = value, + let color: UIColor = ThemeManager.resolvedColor(ThemeManager.color(for: value, in: theme)) + else { + self?.setBackgroundImage(nil, for: state) + return } - ) + + self?.setBackgroundImage(color.toImage(), for: state) + } } @MainActor func setThemeBackgroundColorForced(_ newValue: ForcedThemeValue?, for state: UIControl.State) { @@ -187,7 +186,7 @@ public extension UIButton { ThemeManager.set( self, to: ThemeManager.get(for: self)? - .removing(allWith: keyPath) + .removing(allWith: .keyPath(keyPath)) ) switch newValue { @@ -206,26 +205,25 @@ public extension UIButton { func setThemeTitleColor(_ value: ThemeValue?, for state: UIControl.State) { let keyPath: KeyPath = \.titleLabel?.textColor - ThemeManager.set( + ThemeManager.storeAndApply( self, - to: ThemeApplier( - existingApplier: ThemeManager.get(for: self), - info: [ - keyPath, - state.rawValue - ] - ) { [weak self] theme in - guard let value: ThemeValue = value else { - self?.setTitleColor(nil, for: state) - return - } - - self?.setTitleColor( - ThemeManager.resolvedColor(ThemeManager.color(for: value, in: theme)), - for: state - ) + info: [ + .keyPath(keyPath), + .textColor, + .color(value), + .state(state) + ] + ) { [weak self] theme in + guard let value: ThemeValue = value else { + self?.setTitleColor(nil, for: state) + return } - ) + + self?.setTitleColor( + ThemeManager.resolvedColor(ThemeManager.color(for: value, in: theme)), + for: state + ) + } } @MainActor func setThemeTitleColorForced(_ newValue: ForcedThemeValue?, for state: UIControl.State) { @@ -235,7 +233,7 @@ public extension UIButton { ThemeManager.set( self, to: ThemeManager.get(for: self)? - .removing(allWith: keyPath) + .removing(allWith: .keyPath(keyPath)) ) switch newValue { @@ -359,30 +357,27 @@ public extension GradientView { // First we should clear out any dynamic setting ThemeManager.remove(self, keyPath: \.backgroundColor) - ThemeManager.set( + ThemeManager.storeAndApply( self, - to: ThemeApplier( - existingApplier: ThemeManager.get(for: self), - info: [keyPath] - ) { [weak self] theme in - // First we should remove any gradient that had been added - self?.layer.sublayers?.first(where: { $0 is CAGradientLayer })?.removeFromSuperlayer() - - let maybeColors: [CGColor]? = newValue?.compactMap { - ThemeManager.color(for: $0, in: theme).cgColor - } - - guard let colors: [CGColor] = maybeColors, colors.count == newValue?.count else { - self?.backgroundColor = nil - return - } - - let layer: CAGradientLayer = CAGradientLayer() - layer.frame = (self?.bounds ?? .zero) - layer.colors = colors - self?.layer.insertSublayer(layer, at: 0) + info: [.keyPath(keyPath)] + ) { [weak self] theme in + // First we should remove any gradient that had been added + self?.layer.sublayers?.first(where: { $0 is CAGradientLayer })?.removeFromSuperlayer() + + let maybeColors: [CGColor]? = newValue?.compactMap { + ThemeManager.color(for: $0, in: theme).cgColor } - ) + + guard let colors: [CGColor] = maybeColors, colors.count == newValue?.count else { + self?.backgroundColor = nil + return + } + + let layer: CAGradientLayer = CAGradientLayer() + layer.frame = (self?.bounds ?? .zero) + layer.colors = colors + self?.layer.insertSublayer(layer, at: 0) + } } get { return nil } } @@ -440,7 +435,7 @@ public extension CAShapeLayer { public extension CALayer { @MainActor var themeBackgroundColor: ThemeValue? { - set { ThemeManager.set(self, keyPath: \.backgroundColor, to: newValue) } + set { ThemeManager.set(self, keyPath: \.backgroundColor, to: newValue, as: .backgroundColor) } get { return nil } } @@ -476,7 +471,7 @@ public extension CALayer { public extension CATextLayer { @MainActor var themeForegroundColor: ThemeValue? { - set { ThemeManager.set(self, keyPath: \.foregroundColor, to: newValue) } + set { ThemeManager.set(self, keyPath: \.foregroundColor, to: newValue, as: .textColor) } get { return nil } } } @@ -501,7 +496,7 @@ extension DirectAttributedTextAssignable { extension AttributedTextAssignable { private var themeAttributedTextValue: ThemedAttributedString? { get { attributedTextValue.map { ThemedAttributedString(attributedString: $0) } } - set { attributedTextValue = newValue?.value } + set { attributedTextValue = newValue?.attributedString } } @MainActor public var themeAttributedText: ThemedAttributedString? { set { ThemeManager.set(self, keyPath: \.themeAttributedTextValue, to: newValue) } @@ -513,7 +508,7 @@ extension UILabel: DirectAttributedTextAssignable {} extension UITextField: DirectAttributedTextAssignable { private var themeAttributedPlaceholderValue: ThemedAttributedString? { get { attributedPlaceholder.map { ThemedAttributedString(attributedString: $0) } } - set { attributedPlaceholder = newValue?.value } + set { attributedPlaceholder = newValue?.attributedString } } @MainActor public var themeAttributedPlaceholder: ThemedAttributedString? { set { ThemeManager.set(self, keyPath: \.themeAttributedPlaceholderValue, to: newValue) } diff --git a/SessionUIKit/Types/BuildVariant.swift b/SessionUIKit/Types/BuildVariant.swift new file mode 100644 index 0000000000..6491964539 --- /dev/null +++ b/SessionUIKit/Types/BuildVariant.swift @@ -0,0 +1,50 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum BuildVariant: Sendable, Equatable, CaseIterable, CustomStringConvertible { + case appStore + case development + case testFlight + case ipa + + /// Non-iOS variants (may be used for copy) + case apk + case fDroid + case huawei + + // stringlint:ignore_contents + public static var current: BuildVariant { +#if DEBUG + return .development +#else + + let hasProvisioningProfile: Bool = (Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") != nil) + let receiptUrl: URL? = Bundle.main.appStoreReceiptURL + let hasSandboxReceipt: Bool = (receiptUrl?.lastPathComponent == "sandboxReceipt") + + if !hasProvisioningProfile { + return .appStore + } + + if hasSandboxReceipt { + return .testFlight + } + + return .ipa +#endif + } + + public var description: String { + switch self { + case .appStore: return SNUIKit.buildVariantStringProvider().appStore + case .development: return SNUIKit.buildVariantStringProvider().development + case .testFlight: return SNUIKit.buildVariantStringProvider().testFlight + case .ipa: return SNUIKit.buildVariantStringProvider().ipa + + case .apk: return SNUIKit.buildVariantStringProvider().apk + case .fDroid: return SNUIKit.buildVariantStringProvider().fDroid + case .huawei: return SNUIKit.buildVariantStringProvider().huawei + } + } +} diff --git a/SessionUIKit/Types/Localization.swift b/SessionUIKit/Types/Localization.swift index c183087a3d..51223a3b3f 100644 --- a/SessionUIKit/Types/Localization.swift +++ b/SessionUIKit/Types/Localization.swift @@ -114,19 +114,56 @@ final public class LocalizationHelper: CustomStringConvertible { public extension LocalizationHelper { func localizedDeformatted() -> String { - return ThemedAttributedString(stringWithHTMLTags: localized(), font: .systemFont(ofSize: 14)).string + return ThemedAttributedString( + stringWithHTMLTags: localized(), + font: .systemFont(ofSize: 14), + attributes: [:], + mentionColor: nil, + currentUserMentionImage: nil + ).string } - func localizedFormatted(baseFont: UIFont) -> ThemedAttributedString { - return ThemedAttributedString(stringWithHTMLTags: localized(), font: baseFont) + func localizedFormatted( + baseFont: UIFont, + attributes: [NSAttributedString.Key: Any] = [:], + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) -> ThemedAttributedString { + return ThemedAttributedString( + stringWithHTMLTags: localized(), + font: baseFont, + attributes: attributes, + mentionColor: mentionColor, + currentUserMentionImage: currentUserMentionImage + ) } - func localizedFormatted(in view: FontAccessible) -> ThemedAttributedString { - return localizedFormatted(baseFont: (view.fontValue ?? .systemFont(ofSize: 14))) + func localizedFormatted( + in view: FontAccessible, + attributes: [NSAttributedString.Key: Any] = [:], + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) -> ThemedAttributedString { + return localizedFormatted( + baseFont: (view.fontValue ?? .systemFont(ofSize: 14)), + attributes: attributes, + mentionColor: mentionColor, + currentUserMentionImage: currentUserMentionImage + ) } - func localizedFormatted(_ font: UIFont = .systemFont(ofSize: 14)) -> ThemedAttributedString { - return localizedFormatted(baseFont: font) + func localizedFormatted( + _ font: UIFont = .systemFont(ofSize: 14), + attributes: [NSAttributedString.Key: Any] = [:], + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) -> ThemedAttributedString { + return localizedFormatted( + baseFont: font, + attributes: attributes, + mentionColor: mentionColor, + currentUserMentionImage: currentUserMentionImage + ) } } @@ -175,13 +212,13 @@ public extension String { // Arabic (also covers Persian, Urdu, Pashto, Sorani Kurdish) case 0x0600...0x06FF, // Arabic + Persian/Urdu/Pashto extensions - 0x0750...0x077F, // Arabic Supplement - 0x08A0...0x08FF: // Arabic Extended-A + 0x0750...0x077F, // Arabic Supplement + 0x08A0...0x08FF: // Arabic Extended-A return true // Presentation forms (used by all Arabic-script languages) case 0xFB1D...0xFDFF, // Hebrew + Arabic presentation forms - 0xFE70...0xFEFE: // Arabic Presentation Forms-B + 0xFE70...0xFEFE: // Arabic Presentation Forms-B return true default: return false diff --git a/SessionUIKit/Types/SessionProUIManagerType.swift b/SessionUIKit/Types/SessionProUIManagerType.swift new file mode 100644 index 0000000000..0bae159731 --- /dev/null +++ b/SessionUIKit/Types/SessionProUIManagerType.swift @@ -0,0 +1,98 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public protocol SessionProUIManagerType: Actor { + nonisolated var characterLimit: Int { get } + nonisolated var pinnedConversationLimit: Int { get } + nonisolated var currentUserIsCurrentlyPro: Bool { get } + nonisolated var currentUserIsPro: AsyncStream { get } + + nonisolated func numberOfCharactersLeft(for content: String) -> Int + + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + onConfirm: (() -> Void)?, + onCancel: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool + + @MainActor func showSessionProBottomSheetIfNeeded( + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) + + func purchasePro(productId: String) async throws +} + +// MARK: - Convenience + +public extension SessionProUIManagerType { + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType = .recursive, + onConfirm: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil, + afterClosed: (() -> Void)? = nil, + presenting: ((UIViewController) -> Void)? = nil + ) -> Bool { + showSessionProCTAIfNeeded( + variant, + dismissType: dismissType, + onConfirm: onConfirm, + onCancel: onCancel, + afterClosed: afterClosed, + presenting: presenting + ) + } + + @MainActor func showSessionProBottomSheetIfNeeded(presenting: ((UIViewController) -> Void)?) { + showSessionProBottomSheetIfNeeded( + afterClosed: nil, + presenting: presenting + ) + } +} + +// MARK: - Noop + +internal actor NoopSessionProUIManager: SessionProUIManagerType { + private let isPro: Bool + nonisolated public let characterLimit: Int + nonisolated public let pinnedConversationLimit: Int + nonisolated public let currentUserIsCurrentlyPro: Bool + nonisolated public var currentUserIsPro: AsyncStream { + AsyncStream(unfolding: { return self.isPro }) + } + + init( + isPro: Bool = false, + characterLimit: Int = 2000, + pinnedConversationLimit: Int = 5 + ) { + self.isPro = isPro + self.characterLimit = characterLimit + self.pinnedConversationLimit = pinnedConversationLimit + self.currentUserIsCurrentlyPro = isPro + } + + nonisolated public func numberOfCharactersLeft(for content: String) -> Int { 0 } + + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + return false + } + + @MainActor func showSessionProBottomSheetIfNeeded( + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) {} + + public func purchasePro(productId: String) async throws {} +} diff --git a/SessionUIKit/Types/StringProviders.swift b/SessionUIKit/Types/StringProviders.swift new file mode 100644 index 0000000000..23f88d9a30 --- /dev/null +++ b/SessionUIKit/Types/StringProviders.swift @@ -0,0 +1,107 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum StringProvider {} + +// MARK: - String Providers + +public extension StringProvider { + protocol Url { + var donations: String { get } + var donationsApp: String { get } + var download: String { get } + var faq: String { get } + var feedback: String { get } + var network: String { get } + var privacyPolicy: String { get } + var proAccessNotFound: String { get } + var proFaq: String { get } + var proPrivacyPolicy: String { get } + var proRoadmap: String { get } + var proSupport: String { get } + var proTermsOfService: String { get } + var staking: String { get } + var support: String { get } + var survey: String { get } + var termsOfService: String { get } + var token: String { get } + var translate: String { get } + } + + protocol BuildVariant { + var apk: String { get } + var appStore: String { get } + var development: String { get } + var fDroid: String { get } + var huawei: String { get } + var ipa: String { get } + var testFlight: String { get } + } + + protocol ClientPlatform { + var device: String { get } + var store: String { get } + var platform: String { get } + var platformAccount: String { get } + + var refundPlatformUrl: String { get } + var refundSupportUrl: String { get } + var refundStatusUrl: String { get } + var updateSubscriptionUrl: String { get } + var cancelSubscriptionUrl: String { get } + } +} + +// MARK: - String Provider Fallbacks + +// stringlint:ignore_contents +internal extension StringProvider { + /// This type should not be used where possible as it's values aren't maintained (proper values are sourced from `libSession`) + struct FallbackUrlStringProvider: StringProvider.Url { + let donations: String = "https://getsession.org/donate" + let donationsApp: String = "https://getsession.org/donate#app" + let download: String = "https://getsession.org/download" + let faq: String = "https://getsession.org/faq" + let feedback: String = "https://getsession.org/feedback" + let network: String = "https://docs.getsession.org/session-network" + let privacyPolicy: String = "https://getsession.org/privacy-policy" + let proAccessNotFound: String = "https://sessionapp.zendesk.com/hc/sections/4416517450649-Support" + let proFaq: String = "https://getsession.org/faq#pro" + let proPrivacyPolicy: String = "https://getsession.org/pro/privacy" + let proRoadmap: String = "https://getsession.org/pro-roadmap" + let proSupport: String = "https://getsession.org/pro-form" + let proTermsOfService: String = "https://getsession.org/pro/terms" + let staking: String = "https://docs.getsession.org/session-network/staking" + let support: String = "https://getsession.org/support" + let survey: String = "https://getsession.org/survey" + let termsOfService: String = "https://getsession.org/terms-of-service" + let token: String = "https://token.getsession.org" + let translate: String = "https://getsession.org/translate" + } + + /// This type should not be used where possible as it's values aren't maintained (proper values are sourced from `libSession`) + struct FallbackBuildVariantStringProvider: StringProvider.BuildVariant { + let apk: String = "APK" + let appStore: String = "Apple App Store" + let development: String = "Development" + let fDroid: String = "F-Droid Store" + let huawei: String = "Huawei App Gallery" + let ipa: String = "IPA" + let testFlight: String = "TestFlight" + } + + /// This type should not be used where possible as it's values aren't maintained (proper values are sourced from `libSession`) + struct FallbackClientPlatformStringProvider: StringProvider.ClientPlatform { + let device: String = "iOS" + let store: String = "Apple App Store" + let platform: String = "Apple" + let platformAccount: String = "Apple Account" + + let refundPlatformUrl: String = "https://support.apple.com/118223" + let refundSupportUrl: String = "https://support.apple.com/118223" + let refundStatusUrl: String = "https://support.apple.com/118224" + let updateSubscriptionUrl: String = "https://apps.apple.com/account/subscriptions" + let cancelSubscriptionUrl: String = "https://account.apple.com/account/manage/section/subscriptions" + } +} diff --git a/SessionUIKit/Utilities/Localization+Style.swift b/SessionUIKit/Utilities/Localization+Style.swift index 43dce7671e..5a95f788f9 100644 --- a/SessionUIKit/Utilities/Localization+Style.swift +++ b/SessionUIKit/Utilities/Localization+Style.swift @@ -23,6 +23,9 @@ public extension ThemedAttributedString { case warningTheme = "warn" case dangerTheme = "error" case disabledTheme = "disabled" + case faded = "faded" + case mention = "mention" + case userMention = "userMention" // MARK: - Functions @@ -37,7 +40,11 @@ public extension ThemedAttributedString { ).map { ($0, isCloseTag) } } - func format(with font: UIFont) -> [NSAttributedString.Key: Any] { + func format( + with font: UIFont, + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) -> [NSAttributedString.Key: Any] { /// **Note:** Constructing a `UIFont` with a `size`of `0` will preserve the textSize switch self { case .bold: return [ @@ -59,11 +66,38 @@ public extension ThemedAttributedString { case .warningTheme: return [.themeForegroundColor: ThemeValue.warning] case .dangerTheme: return [.themeForegroundColor: ThemeValue.danger] case .disabledTheme: return [.themeForegroundColor: ThemeValue.disabled] + case .faded: return [.themeAlphaMultiplier: Values.lowOpacity] + case .mention: + guard let mentionColor: ThemeValue = mentionColor else { return [:] } + + return [ + .font: UIFont( + descriptor: (font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor), + size: 0 + ), + .themeForegroundColor: mentionColor + ] + + case .userMention: + guard let currentUserMentionImage: UIImage = currentUserMentionImage else { return [:] } + + return [.themeCurrentUserMentionImage: currentUserMentionImage] } } } - convenience init(stringWithHTMLTags: String?, font: UIFont) { + convenience init( + stringWithHTMLTags: String?, + font: UIFont, + attributes: [NSAttributedString.Key: Any] = [:], + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) { + let standardAttributes: [NSAttributedString.Key: Any] = [.font: font].merging( + attributes, + uniquingKeysWith: { _, new in new } + ) + guard let targetString: String = stringWithHTMLTags, let expression: NSRegularExpression = try? NSRegularExpression( @@ -71,7 +105,7 @@ public extension ThemedAttributedString { options: [.caseInsensitive, .dotMatchesLineSeparators] ) else { - self.init(string: (stringWithHTMLTags ?? "")) + self.init(string: (stringWithHTMLTags ?? ""), attributes: standardAttributes) return } @@ -79,7 +113,10 @@ public extension ThemedAttributedString { /// /// **Note:** We use an `NSAttributedString` for retrieving string ranges because if we don't then emoji characters /// can cause odd behaviours with accessing ranges so this simplifies the logic - let attrString: ThemedAttributedString = ThemedAttributedString(string: targetString) + let attrString: ThemedAttributedString = ThemedAttributedString( + string: targetString, + attributes: standardAttributes + ) let stringLength: Int = targetString.utf16.count var partsAndTags: [(part: String, tags: [HTMLTag])] = [] var openTags: [HTMLTag: Int] = [:] @@ -129,7 +166,7 @@ public extension ThemedAttributedString { /// If we don't have a `lastMatch` value then we weren't able to get a single valid tag match so just stop here are return the `targetString` guard let finalMatch: NSTextCheckingResult = lastMatch else { - self.init(string: targetString) + self.init(string: targetString, attributes: standardAttributes) return } @@ -144,7 +181,19 @@ public extension ThemedAttributedString { /// Lastly we should construct the attributed string, applying the desired formatting self.init( attributedString: partsAndTags.reduce(into: ThemedAttributedString()) { result, next in - result.append(ThemedAttributedString(string: next.part, attributes: next.tags.format(with: font))) + let partAttributes: [NSAttributedString.Key: Any] = next.tags.format( + with: font, + mentionColor: mentionColor, + currentUserMentionImage: currentUserMentionImage + ) + + result.append( + ThemedAttributedString( + string: next.part, + attributes: standardAttributes + .merging(partAttributes, uniquingKeysWith: { _, new in new }) + ) + ) } ) } @@ -164,7 +213,11 @@ public extension ThemedAttributedString { } private extension Collection where Element == ThemedAttributedString.HTMLTag { - func format(with font: UIFont) -> [NSAttributedString.Key: Any] { + func format( + with font: UIFont, + mentionColor: ThemeValue?, + currentUserMentionImage: UIImage? + ) -> [NSAttributedString.Key: Any] { func fontWith(_ font: UIFont, traits: UIFontDescriptor.SymbolicTraits) -> UIFont { /// **Note:** Constructing a `UIFont` with a `size`of `0` will preserve the textSize return UIFont( @@ -193,6 +246,17 @@ private extension Collection where Element == ThemedAttributedString.HTMLTag { case .warningTheme: result[.themeForegroundColor] = ThemeValue.warning case .dangerTheme: result[.themeForegroundColor] = ThemeValue.danger case .disabledTheme: result[.themeForegroundColor] = ThemeValue.disabled + case .faded: result[.themeAlphaMultiplier] = Values.lowOpacity + case .mention: + guard let mentionColor: ThemeValue = mentionColor else { return } + + result[.font] = fontWith(font, traits: [.traitBold]) + result[.themeForegroundColor] = mentionColor + + case .userMention: + guard let currentUserMentionImage: UIImage = currentUserMentionImage else { return } + + result[.themeCurrentUserMentionImage] = currentUserMentionImage } } } @@ -222,12 +286,34 @@ extension UITextField: DirectFontAccessible {} extension UITextView: DirectFontAccessible {} public extension String { - func formatted(in view: FontAccessible) -> ThemedAttributedString { - return ThemedAttributedString(stringWithHTMLTags: self, font: (view.fontValue ?? .systemFont(ofSize: 14))) + func formatted( + in view: FontAccessible, + attributes: [NSAttributedString.Key: Any] = [:], + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) -> ThemedAttributedString { + return ThemedAttributedString( + stringWithHTMLTags: self, + font: (view.fontValue ?? .systemFont(ofSize: 14)), + attributes: attributes, + mentionColor: mentionColor, + currentUserMentionImage: currentUserMentionImage + ) } - func formatted(baseFont: UIFont) -> ThemedAttributedString { - return ThemedAttributedString(stringWithHTMLTags: self, font: baseFont) + func formatted( + baseFont: UIFont, + attributes: [NSAttributedString.Key: Any] = [:], + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) -> ThemedAttributedString { + return ThemedAttributedString( + stringWithHTMLTags: self, + font: baseFont, + attributes: attributes, + mentionColor: mentionColor, + currentUserMentionImage: currentUserMentionImage + ) } func formatted() -> ThemedAttributedString { @@ -235,7 +321,13 @@ public extension String { } func deformatted() -> String { - return ThemedAttributedString(stringWithHTMLTags: self, font: .systemFont(ofSize: 14)).string + return ThemedAttributedString( + stringWithHTMLTags: self, + font: .systemFont(ofSize: 14), + attributes: [:], + mentionColor: nil, + currentUserMentionImage: nil + ).string } } diff --git a/SessionUIKit/Utilities/MentionUtilities.swift b/SessionUIKit/Utilities/MentionUtilities.swift index 032eb5be74..829aa57282 100644 --- a/SessionUIKit/Utilities/MentionUtilities.swift +++ b/SessionUIKit/Utilities/MentionUtilities.swift @@ -3,9 +3,14 @@ import Foundation import UIKit +public typealias DisplayNameRetriever = (_ sessionId: String, _ inMessageBody: Bool) -> String? + public enum MentionUtilities { private static let currentUserCacheKey: String = "Mention.CurrentUser" // stringlint:ignore private static let pubkeyRegex: NSRegularExpression = try! NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) + private static let mentionCharacterSet: CharacterSet = CharacterSet(["@"]) // stringlint:ignore + private static let mentionFont: UIFont = .boldSystemFont(ofSize: Values.smallFontSize) + private static let currentUserMentionImageSizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) public enum MentionLocation { case incomingMessage @@ -16,16 +21,48 @@ public enum MentionUtilities { case styleFree } + public static func allPubkeys(in string: String) -> Set { + guard !string.isEmpty else { return [] } + + return Set(pubkeyRegex + .matches(in: string, range: NSRange(string.startIndex..., in: string)) + .compactMap { match in + Range(match.range, in: string).map { range in + /// Need to remove the leading `@` as this should just retrieve the pubkeys + String(string[range]).trimmingCharacters(in: mentionCharacterSet) + } + }) + } + + @MainActor public static func generateCurrentUserMentionImage(textColor: ThemeValue) -> UIImage { + return UIView.image( + for: .themedKey( + MentionUtilities.currentUserCacheKey, + themeBackgroundColor: .primary + ), + generator: { + HighlightMentionView( + mentionText: "@\("you".localized())", // stringlint:ignore + font: mentionFont, + themeTextColor: .dynamicForInterfaceStyle(light: textColor, dark: .black), + themeBackgroundColor: .primary, + backgroundCornerRadius: (8 * currentUserMentionImageSizeDiff), + backgroundPadding: (3 * currentUserMentionImageSizeDiff) + ) + } + ) + } + public static func getMentions( in string: String, currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) -> (String, [(range: NSRange, profileId: String, isCurrentUser: Bool)]) { /// In `Localization` we manually insert RTL isolate markers to ensure mixked RTL/LTR strings var workingString: String = string let hasRLIPrefix: Bool = workingString.hasPrefix("\u{2067}") let hasPDISuffix: Bool = workingString.hasSuffix("\u{2069}") - + if hasRLIPrefix { workingString = String(workingString.dropFirst()) } @@ -34,157 +71,159 @@ public enum MentionUtilities { workingString = String(workingString.dropLast()) } - var string: String = workingString - var lastMatchEnd: Int = 0 + var nsString: NSString = (workingString as NSString) + let fullRange = NSRange(location: 0, length: nsString.length) + + let resultString: NSMutableString = NSMutableString() var mentions: [(range: NSRange, profileId: String, isCurrentUser: Bool)] = [] + var lastSearchLocation: Int = 0 - while let match: NSTextCheckingResult = pubkeyRegex.firstMatch( - in: string, - options: .withoutAnchoringBounds, - range: NSRange(location: lastMatchEnd, length: string.utf16.count - lastMatchEnd) - ) { - guard let range: Range = Range(match.range, in: string) else { break } + pubkeyRegex.enumerateMatches(in: workingString, options: [], range: fullRange) { match, _, _ in + guard let match else { return } + + /// Append everything before this match + let rangeBefore: NSRange = NSRange( + location: lastSearchLocation, + length: (match.range.location - lastSearchLocation) + ) + resultString.append(nsString.substring(with: rangeBefore)) - let sessionId: String = String(string[range].dropFirst()) // Drop the @ + let sessionId: String = String(nsString.substring(with: match.range).dropFirst()) /// Drop the @ let isCurrentUser: Bool = currentUserSessionIds.contains(sessionId) - let maybeTargetString: String? = { - guard !isCurrentUser else { return "you".localized() } - guard let displayName: String = displayNameRetriever(sessionId, true) else { - lastMatchEnd = (match.range.location + match.range.length) - return nil - } - - return displayName - }() + let displayName: String - guard let targetString: String = maybeTargetString else { continue } + if isCurrentUser { + displayName = "you".localized() + } + else if let retrievedName: String = displayNameRetriever(sessionId, true) { + displayName = retrievedName + } else { + /// If we can't get a proper display name then we should just truncate the pubkey + displayName = sessionId.truncated() + } - string = string.replacingCharacters(in: range, with: "@\(targetString)") // stringlint:ignore - lastMatchEnd = (match.range.location + targetString.utf16.count) + /// Append the resolved mame + let replacement: String = "@\(displayName)" // stringlint:ignore + let startLocation: Int = resultString.length + resultString.append(replacement) + /// Record the mention mentions.append(( - // + 1 to include the @ - range: NSRange(location: match.range.location, length: targetString.utf16.count + 1), + range: NSRange(location: startLocation, length: (replacement as NSString).length), profileId: sessionId, isCurrentUser: isCurrentUser )) + + lastSearchLocation = (match.range.location + match.range.length) + } + + /// Append any remaining string + if lastSearchLocation < nsString.length { + let remainingRange = NSRange(location: lastSearchLocation, length: nsString.length - lastSearchLocation) + resultString.append(nsString.substring(with: remainingRange)) } /// Need to add the RTL isolate markers back if we had them + let finalStringRaw: String = (resultString as String) let finalString: String = (string.containsRTL ? - "\(LocalizationHelper.forceRTLLeading)\(string)\(LocalizationHelper.forceRTLTrailing)" : - string + "\(LocalizationHelper.forceRTLLeading)\(finalStringRaw)\(LocalizationHelper.forceRTLTrailing)" : + finalStringRaw ) return (finalString, mentions) } - public static func highlightMentionsNoAttributes( + // stringlint:ignore_contents + public static func taggingMentions( in string: String, + location: MentionLocation, currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) -> String { - let (string, _) = getMentions( + let (mentionReplacedString, mentions) = getMentions( in: string, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever ) - return string + guard !mentions.isEmpty else { return mentionReplacedString } + + let result: NSMutableString = NSMutableString(string: mentionReplacedString) + + /// Iterate in reverse so index ranges remain valid while replacing + for mention in mentions.sorted(by: { $0.range.location > $1.range.location }) { + let mentionText: String = (result as NSString).substring(with: mention.range) + let tag: String = (mention.isCurrentUser && location == .incomingMessage ? + ThemedAttributedString.HTMLTag.userMention.rawValue : /// Only use for incoming + ThemedAttributedString.HTMLTag.mention.rawValue + ) + + result.replaceCharacters( + in: mention.range, + with: "<\(tag)>\(mentionText)" + ) + } + + return (result as String) + } + + public static func mentionColor( + textColor: ThemeValue, + location: MentionLocation + ) -> ThemeValue { + switch location { + case .incomingMessage: return .dynamicForInterfaceStyle(light: textColor, dark: .primary) + case .outgoingMessage: return .dynamicForInterfaceStyle(light: textColor, dark: .black) + case .outgoingQuote: return .dynamicForInterfaceStyle(light: textColor, dark: .black) + case .incomingQuote: return .dynamicForInterfaceStyle(light: textColor, dark: .primary) + case .quoteDraft, .styleFree: return .dynamicForInterfaceStyle(light: textColor, dark: textColor) + } } - public static func highlightMentions( + public static func currentUserMentionImageString( + substring: String, + currentUserMentionImage: UIImage? + ) -> NSAttributedString { + guard let currentUserMentionImage else { return NSAttributedString(string: substring) } + + /// Set the `accessibilityLabel` to ensure it's still visible to accessibility inspectors + let attachment: NSTextAttachment = NSTextAttachment() + attachment.accessibilityLabel = substring + + let offsetY: CGFloat = ((mentionFont.capHeight - currentUserMentionImage.size.height) / 2) + attachment.image = currentUserMentionImage + attachment.bounds = CGRect( + x: 0, + y: offsetY, + width: currentUserMentionImage.size.width, + height: currentUserMentionImage.size.height + ) + + return NSMutableAttributedString(attachment: attachment) + } +} + +public extension MentionUtilities { + static func resolveMentions( in string: String, currentUserSessionIds: Set, - location: MentionLocation, - textColor: ThemeValue, - attributes: [NSAttributedString.Key: Any], - displayNameRetriever: (String, Bool) -> String? - ) -> ThemedAttributedString { - let (string, mentions) = getMentions( + displayNameRetriever: DisplayNameRetriever + ) -> String { + return MentionUtilities.taggingMentions( in: string, + location: .outgoingMessage, /// If we are replacing then we don't want to use the image currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever - ) - - let sizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) - let result = ThemedAttributedString(string: string, attributes: attributes) - let mentionFont = UIFont.boldSystemFont(ofSize: Values.smallFontSize) - // Iterate in reverse so index ranges remain valid while replacing - for mention in mentions.sorted(by: { $0.range.location > $1.range.location }) { - if mention.isCurrentUser && location == .incomingMessage { - // Build the rendered chip image - let image: UIImage = UIView.image( - for: .themedKey( - MentionUtilities.currentUserCacheKey, - themeBackgroundColor: .primary - ), - generator: { - HighlightMentionView( - mentionText: (result.string as NSString).substring(with: mention.range), - font: mentionFont, - themeTextColor: .dynamicForInterfaceStyle(light: textColor, dark: .black), - themeBackgroundColor: .primary, - backgroundCornerRadius: (8 * sizeDiff), - backgroundPadding: (3 * sizeDiff) - ) - } - ) - - /// Set the `accessibilityLabel` to ensure it's still visible to accessibility inspectors - let attachment: NSTextAttachment = NSTextAttachment() - attachment.accessibilityLabel = (result.string as NSString).substring(with: mention.range) - - let offsetY: CGFloat = (mentionFont.capHeight - image.size.height) / 2 - attachment.image = image - attachment.bounds = CGRect( - x: 0, - y: offsetY, - width: image.size.width, - height: image.size.height - ) - - let attachmentString = NSMutableAttributedString(attachment: attachment) - - // Replace the mention text with the image attachment - result.replaceCharacters(in: mention.range, with: attachmentString) - - let insertIndex = mention.range.location + attachmentString.length - if insertIndex < result.length { - result.addAttribute(.kern, value: (3 * sizeDiff), range: NSRange(location: insertIndex, length: 1)) - } - continue - } - - result.addAttribute(.font, value: mentionFont, range: mention.range) - - var targetColor: ThemeValue = textColor - switch location { - case .incomingMessage: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) - case .outgoingMessage: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - case .outgoingQuote: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - case .incomingQuote: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) - case .quoteDraft, .styleFree: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: textColor) - } - - result.addAttribute(.themeForegroundColor, value: targetColor, range: mention.range) - } - - return result + ).deformatted() } } public extension String { func replacingMentions( currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) -> String { - return MentionUtilities.highlightMentionsNoAttributes( + return MentionUtilities.resolveMentions( in: self, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever diff --git a/SessionUIKit/Utilities/Notifications+Utilities.swift b/SessionUIKit/Utilities/Notifications+Utilities.swift new file mode 100644 index 0000000000..2ad9173ffb --- /dev/null +++ b/SessionUIKit/Utilities/Notifications+Utilities.swift @@ -0,0 +1,38 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Lucide + +public enum NotificationsUI { + public static let mutePrefix: Lucide.Icon = Lucide.Icon.volumeX + public static let mentionPrefix: Lucide.Icon = Lucide.Icon.atSign +} + +public extension ThemedAttributedString { + func stylingNotificationPrefixesIfNeeded(fontSize: CGFloat) -> ThemedAttributedString { + if self.string.starts(with: NotificationsUI.mutePrefix.rawValue) { + return addingAttributes( + Lucide.attributes(for: .systemFont(ofSize: fontSize)), + range: NSRange(location: 0, length: NotificationsUI.mutePrefix.rawValue.count) + ) + } + else if self.string.starts(with: NotificationsUI.mentionPrefix.rawValue) { + let imageAttachment: NSTextAttachment = NSTextAttachment() + imageAttachment.image = UIImage(named: "NotifyMentions.png")? + .withRenderingMode(.alwaysTemplate) + imageAttachment.bounds = CGRect( + x: 0, + y: -2, + width: fontSize, + height: fontSize + ) + + return self.replacingCharacters( + in: NSRange(location: 0, length: NotificationsUI.mutePrefix.rawValue.count), + with: NSAttributedString(attachment: imageAttachment) + ) + } + + return self + } +} diff --git a/SessionUIKit/Utilities/Number+Utilities.swift b/SessionUIKit/Utilities/Number+Utilities.swift index 4b1a6d2f10..dd18545c03 100644 --- a/SessionUIKit/Utilities/Number+Utilities.swift +++ b/SessionUIKit/Utilities/Number+Utilities.swift @@ -5,7 +5,7 @@ import Foundation public enum NumberFormat { case abbreviated(decimalPlaces: Int, omitZeroDecimal: Bool) case decimal - case currency(decimal: Bool, withLocalSymbol: Bool) + case currency(decimal: Bool, withLocalSymbol: Bool, roundingMode: NumberFormatter.RoundingMode) case abbreviatedCurrency(decimalPlaces: Int, omitZeroDecimal: Bool) } @@ -31,7 +31,7 @@ public extension NumberFormat { formatter.numberStyle = .decimal return formatter.string(from: NSNumber(value: value)) ?? "\(value)" - case .currency(let decimal, let withLocalSymbol): + case .currency(let decimal, let withLocalSymbol, let roundingMode): let formatter = NumberFormatter() formatter.numberStyle = .currency if !withLocalSymbol { @@ -41,7 +41,7 @@ public extension NumberFormat { formatter.minimumFractionDigits = 0 formatter.maximumFractionDigits = 0 } - formatter.roundingMode = .floor + formatter.roundingMode = roundingMode return formatter.string(from: NSNumber(value: value)) ?? "\(value)" case .abbreviatedCurrency(let decimalPlaces, let omitZeroDecimal): diff --git a/SessionUIKit/Utilities/QRCode.swift b/SessionUIKit/Utilities/QRCode.swift index ee7482b0b5..b4e3594b0d 100644 --- a/SessionUIKit/Utilities/QRCode.swift +++ b/SessionUIKit/Utilities/QRCode.swift @@ -85,8 +85,8 @@ public enum QRCode { size: CGSize? = nil, insets: UIEdgeInsets = .zero ) -> UIImage { - var backgroundColor: UIColor = .white - var tintColor: UIColor = .classicDark1 + let backgroundColor: UIColor = .white + let tintColor: UIColor = .classicDark1 let outputSize = size ?? image.size let renderer = UIGraphicsImageRenderer(size: outputSize) diff --git a/SessionUIKit/Utilities/String+Utilities.swift b/SessionUIKit/Utilities/String+Utilities.swift index 3c4159564d..167061030c 100644 --- a/SessionUIKit/Utilities/String+Utilities.swift +++ b/SessionUIKit/Utilities/String+Utilities.swift @@ -79,6 +79,7 @@ public extension String.StringInterpolation { } public extension String { + // stringlint:ignore_contents static func formattedDuration( _ duration: TimeInterval, format: TimeInterval.DurationFormat = .short, diff --git a/SessionUIKit/Utilities/UILabel+Utilities.swift b/SessionUIKit/Utilities/UILabel+Utilities.swift index 6ef30de1d8..699fd6675f 100644 --- a/SessionUIKit/Utilities/UILabel+Utilities.swift +++ b/SessionUIKit/Utilities/UILabel+Utilities.swift @@ -4,7 +4,7 @@ import UIKit public extension UILabel { /// Appends a rendered snapshot of `view` as an inline image attachment. - func attachTrailing( + @MainActor func attachTrailing( cacheKey: CachedImageKey?, accessibilityLabel: String? = nil, viewGenerator: (() -> UIView)?, @@ -15,20 +15,20 @@ public extension UILabel { let base = ThemedAttributedString() if let existing = attributedText, existing.length > 0 { base.append(existing) - } else if let t = text { + } + else if let t = text { base.append(NSAttributedString(string: t, attributes: [.font: font as Any, .foregroundColor: textColor as Any])) } + let image: UIImage = UIView.image(for: cacheKey, generator: viewGenerator) base.append(NSAttributedString(string: spacing)) - base.append(ThemedAttributedString( - imageAttachmentGenerator: { - ( - UIView.image(for: cacheKey, generator: viewGenerator), - accessibilityLabel - ) - }, - referenceFont: font - )) + base.append( + ThemedAttributedString( + image: image, + accessibilityLabel: accessibilityLabel, + font: font + ) + ) themeAttributedText = base numberOfLines = 0 diff --git a/SessionUIKit/Utilities/UIView+Utilities.swift b/SessionUIKit/Utilities/UIView+Utilities.swift index 4c584100eb..e454ff70ba 100644 --- a/SessionUIKit/Utilities/UIView+Utilities.swift +++ b/SessionUIKit/Utilities/UIView+Utilities.swift @@ -18,7 +18,7 @@ public extension UIView { } } - static func image( + @MainActor static func image( for key: CachedImageKey, generator: () -> UIView ) -> UIImage { diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift index b951355d89..06447b3737 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -184,3 +184,15 @@ extension AnyPublisher: @retroactive ExpressibleByArrayLiteral where Output: Ran self = Just(Output(elements)).setFailureType(to: Failure.self).eraseToAnyPublisher() } } + +public extension AnyPublisher where Failure == Error { + static func lazy(_ closure: @escaping () throws -> Output) -> Self { + return Deferred { + Future { promise in + do { promise(.success(try closure())) } + catch { promise(.failure(error)) } + } + } + .eraseToAnyPublisher() + } +} diff --git a/SessionUtilitiesKit/Crypto/Crypto+SessionUtilitiesKit.swift b/SessionUtilitiesKit/Crypto/Crypto+SessionUtilitiesKit.swift index a6df9b00ff..525d6f149b 100644 --- a/SessionUtilitiesKit/Crypto/Crypto+SessionUtilitiesKit.swift +++ b/SessionUtilitiesKit/Crypto/Crypto+SessionUtilitiesKit.swift @@ -126,9 +126,9 @@ public extension Crypto.Generator { } } - static func ed25519KeyPair(seed: [UInt8]) -> Crypto.Generator { + static func ed25519KeyPair(seed: I) -> Crypto.Generator { return Crypto.Generator(id: "ed25519KeyPair_Seed", args: [seed]) { - var cSeed: [UInt8] = seed + var cSeed: [UInt8] = Array(seed) var pubkey: [UInt8] = [UInt8](repeating: 0, count: 32) var seckey: [UInt8] = [UInt8](repeating: 0, count: 64) @@ -141,9 +141,9 @@ public extension Crypto.Generator { } } - static func ed25519Seed(ed25519SecretKey: [UInt8]) -> Crypto.Generator { + static func ed25519Seed(ed25519SecretKey: I) -> Crypto.Generator { return Crypto.Generator(id: "ed25519Seed", args: [ed25519SecretKey]) { - var cEd25519SecretKey: [UInt8] = ed25519SecretKey + var cEd25519SecretKey: [UInt8] = Array(ed25519SecretKey) var seed: [UInt8] = [UInt8](repeating: 0, count: 32) guard diff --git a/SessionUtilitiesKit/Crypto/CryptoError.swift b/SessionUtilitiesKit/Crypto/CryptoError.swift index 7ff3a652aa..f1ff169900 100644 --- a/SessionUtilitiesKit/Crypto/CryptoError.swift +++ b/SessionUtilitiesKit/Crypto/CryptoError.swift @@ -6,6 +6,7 @@ import Foundation public enum CryptoError: Error, CustomStringConvertible { case invalidSeed + case invalidKey case invalidPublicKey case keyGenerationFailed case randomGenerationFailed @@ -21,6 +22,7 @@ public enum CryptoError: Error, CustomStringConvertible { public var description: String { switch self { case .invalidSeed: return "CryptoError: Invalid seed" + case .invalidKey: return "CryptoError: Invalid key" case .invalidPublicKey: return "CryptoError: Invalid public key" case .keyGenerationFailed: return "CryptoError: Key generation failed" case .randomGenerationFailed: return "CryptoError: Random generation failed" diff --git a/SessionUtilitiesKit/Crypto/KeyPair.swift b/SessionUtilitiesKit/Crypto/KeyPair.swift index b324ce94bc..a6a9e5d646 100644 --- a/SessionUtilitiesKit/Crypto/KeyPair.swift +++ b/SessionUtilitiesKit/Crypto/KeyPair.swift @@ -2,7 +2,7 @@ import Foundation -public struct KeyPair: Codable, Equatable { +public struct KeyPair: Sendable, Codable, Equatable, Hashable { public static let empty: KeyPair = KeyPair(publicKey: [], secretKey: []) public let publicKey: [UInt8] diff --git a/SessionUtilitiesKit/Database/Types/FetchablePair.swift b/SessionUtilitiesKit/Database/Types/FetchablePair.swift new file mode 100644 index 0000000000..300f5b61df --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/FetchablePair.swift @@ -0,0 +1,17 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public typealias FetchablePairConformance = (Sendable & Codable & Equatable & Hashable) + +public struct FetchablePair: FetchablePairConformance, FetchableRecord, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case first + case second + } + + public let first: First + public let second: Second +} diff --git a/SessionUtilitiesKit/Database/Types/FetchableTriple.swift b/SessionUtilitiesKit/Database/Types/FetchableTriple.swift new file mode 100644 index 0000000000..b4d0ec030c --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/FetchableTriple.swift @@ -0,0 +1,19 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public typealias FetchableTripleConformance = (Sendable & Codable & Equatable & Hashable) + +public struct FetchableTriple: FetchableTripleConformance, FetchableRecord, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case first + case second + case third + } + + public let first: First + public let second: Second + public let third: Third +} diff --git a/SessionUtilitiesKit/Database/Types/Migration.swift b/SessionUtilitiesKit/Database/Types/Migration.swift index 4609edfbdd..51f645916b 100644 --- a/SessionUtilitiesKit/Database/Types/Migration.swift +++ b/SessionUtilitiesKit/Database/Types/Migration.swift @@ -11,7 +11,7 @@ public extension Log.Category { // MARK: - Migration -public protocol Migration { +public protocol Migration: Sendable { static var identifier: String { get } static var minExpectedRunDuration: TimeInterval { get } static var createdTables: [(TableRecord & FetchableRecord).Type] { get } diff --git a/SessionUtilitiesKit/Database/Types/PagedData.swift b/SessionUtilitiesKit/Database/Types/PagedData.swift index bb72d6d5bd..88db0d6d33 100644 --- a/SessionUtilitiesKit/Database/Types/PagedData.swift +++ b/SessionUtilitiesKit/Database/Types/PagedData.swift @@ -61,6 +61,23 @@ public extension PagedData { self.firstPageOffset = firstPageOffset self.currentIds = currentIds } + + public func with(filterSQL: SQL) -> LoadedInfo { + return LoadedInfo( + queryInfo: QueryInfo( + tableName: queryInfo.tableName, + idColumnName: queryInfo.idColumnName, + requiredJoinSQL: queryInfo.requiredJoinSQL, + filterSQL: filterSQL, + groupSQL: queryInfo.groupSQL, + orderSQL: queryInfo.orderSQL + ), + pageSize: pageSize, + totalCount: totalCount, + firstPageOffset: firstPageOffset, + currentIds: currentIds + ) + } } struct LoadResult { @@ -71,6 +88,26 @@ public extension PagedData { self.info = info self.newIds = newIds } + + public static func createInvalid() -> LoadResult { + LoadResult( + info: PagedData.LoadedInfo( + queryInfo: PagedData.QueryInfo( + tableName: "", + idColumnName: "", + requiredJoinSQL: nil, + filterSQL: "", + groupSQL: nil, + orderSQL: "" + ), + pageSize: 0, + totalCount: 0, + firstPageOffset: 0, + currentIds: [] + ), + newIds: [] + ) + } } @available(*, deprecated, message: "This type was used with the PagedDatabaseObserver but that is deprecated, use the ObservationBuilder instead and PagedData.LoadedInfo") @@ -225,6 +262,12 @@ public extension PagedData { /// This will attempt to load a page of data after the last item in the cache case pageAfter + /// This will attempt to load the next `count` items before the first item in the cache + case numberBefore(count: Int) + + /// This will attempt to load the next `count` items after the last item in the cache + case numberAfter(count: Int) + /// This will jump to the specified id, loading a page around it and clearing out any /// data that was previously cached /// @@ -232,9 +275,15 @@ public extension PagedData { /// cached data (plus the padding amount) then it'll load up to that data (plus padding) case jumpTo(id: ID, padding: Int) - /// This will refetched all of the currently fetched data + /// This will refetch all of the currently fetched data case reloadCurrent(insertedIds: Set, deletedIds: Set) + /// This will load the new items added in a specific update + /// + /// **Note:** This `Target` should not be used if existing items can be modified as a result of other items being inserted + /// or deleted + case newItems(insertedIds: Set, deletedIds: Set) + public var reloadCurrent: Target { .reloadCurrent(insertedIds: [], deletedIds: []) } public static func reloadCurrent(insertedIds: Set) -> Target { return .reloadCurrent(insertedIds: insertedIds, deletedIds: []) @@ -267,6 +316,7 @@ public extension PagedData.LoadedInfo { var newLimit: Int var newFirstPageOffset: Int var mergeStrategy: ([ID], [ID]) -> [ID] + var newIdStrategy: ([ID], [ID]) -> [ID] let newTotalCount: Int = PagedData.totalCount( db, tableName: queryInfo.tableName, @@ -280,18 +330,35 @@ public extension PagedData.LoadedInfo { newLimit = pageSize newFirstPageOffset = 0 mergeStrategy = { _, new in new } // Replace old with new + newIdStrategy = { _, new in new } // Only newly fetched case .pageBefore: newLimit = min(firstPageOffset, pageSize) newOffset = max(0, firstPageOffset - newLimit) newFirstPageOffset = newOffset mergeStrategy = { old, new in (new + old) } // Prepend new page + newIdStrategy = { _, new in new } // Only newly fetched case .pageAfter: newOffset = firstPageOffset + currentIds.count newLimit = pageSize newFirstPageOffset = firstPageOffset mergeStrategy = { old, new in (old + new) } // Append new page + newIdStrategy = { _, new in new } // Only newly fetched + + case .numberBefore(let count): + newLimit = count + newOffset = max(0, firstPageOffset - newLimit) + newFirstPageOffset = newOffset + mergeStrategy = { old, new in (new + old) } // Prepend new items + newIdStrategy = { _, new in new } // Only newly fetched + + case .numberAfter(let count): + newOffset = firstPageOffset + currentIds.count + newLimit = count + newFirstPageOffset = firstPageOffset + mergeStrategy = { old, new in (old + new) } // Append new items + newIdStrategy = { _, new in new } // Only newly fetched case .initialPageAround(let id): let maybeIndex: Int? = PagedData.index( @@ -312,7 +379,8 @@ public extension PagedData.LoadedInfo { newOffset = max(0, targetIndex - halfPage) newLimit = pageSize newFirstPageOffset = newOffset - mergeStrategy = { _, new in new } // Replace old with new + mergeStrategy = { _, new in new } // Replace old with new + newIdStrategy = { _, new in new } // Only newly fetched case .jumpTo(let targetId, let padding): /// If it's already loaded then no need to do anything @@ -345,12 +413,14 @@ public extension PagedData.LoadedInfo { newLimit = firstPageOffset - newOffset newFirstPageOffset = newOffset mergeStrategy = { old, new in (new + old) } // Prepend new page + newIdStrategy = { _, new in new } // Only newly fetched } else if isCloseAfter { newOffset = lastIndex + 1 newLimit = (targetIndex - lastIndex) + padding newFirstPageOffset = firstPageOffset mergeStrategy = { old, new in (old + new) } // Append new page + newIdStrategy = { _, new in new } // Only newly fetched } else { /// The target is too far away so we need to do a new fetch @@ -362,7 +432,16 @@ public extension PagedData.LoadedInfo { newOffset = self.firstPageOffset newLimit = max(pageSize, finalSet.count) newFirstPageOffset = self.firstPageOffset - mergeStrategy = { _, new in new } // Replace old with new + mergeStrategy = { _, new in new } // Replace old with new + newIdStrategy = { _, fetchedIds in fetchedIds } // Consider all as new + + case .newItems(let insertedIds, let deletedIds): + let finalSet: Set = Set(currentIds).union(insertedIds).subtracting(deletedIds) + newOffset = self.firstPageOffset + newLimit = max(pageSize, finalSet.count) + newFirstPageOffset = self.firstPageOffset + mergeStrategy = { _, new in new } // Replace old with new + newIdStrategy = { old, new in new.filter { !old.contains($0) } } // Only newly fetched } /// Now that we have the limit and offset actually load the data @@ -386,7 +465,7 @@ public extension PagedData.LoadedInfo { firstPageOffset: newFirstPageOffset, currentIds: mergeStrategy(currentIds, newIds) ), - newIds: newIds + newIds: newIdStrategy(currentIds, newIds) ) } } diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index d35f2e279c..7cdfe43a85 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -683,6 +683,28 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs ), nil ) + + case .numberBefore(let count): + let updatedOffset: Int = max(0, (currentPageInfo.pageOffset - count)) + + return ( + ( + count, + updatedOffset, + updatedOffset + ), + nil + ) + + case .numberAfter(let count): + return ( + ( + count, + (currentPageInfo.pageOffset + currentPageInfo.currentCount), + currentPageInfo.pageOffset + ), + nil + ) case .untilInclusive(let targetId, let padding): // If we want to focus on a specific item then we need to find it's index in @@ -794,6 +816,17 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs ), nil ) + + case .newItems: + Log.error(.cat, "Used `.newItems` when in PagedDatabaseObserver which is not supported") + return ( + ( + currentPageInfo.currentCount, + currentPageInfo.pageOffset, + currentPageInfo.pageOffset + ), + nil + ) } }() @@ -945,8 +978,11 @@ private extension PagedData { case initialPageAround(id: SQLExpression) case pageBefore case pageAfter + case numberBefore(Int) + case numberAfter(Int) case jumpTo(id: SQLExpression, paddingForInclusive: Int) case reloadCurrent + case newItems /// This will be used when `jumpTo` is called and the `id` is within a single `pageSize` of the currently /// cached data (plus the padding amount) @@ -964,11 +1000,14 @@ private extension PagedData.Target { case .initialPageAround(let id): return .initialPageAround(id: id.sqlExpression) case .pageBefore: return .pageBefore case .pageAfter: return .pageAfter + case .numberBefore(let count): return .numberBefore(count) + case .numberAfter(let count): return .numberAfter(count) case .jumpTo(let id, let padding): return .jumpTo(id: id.sqlExpression, paddingForInclusive: padding) case .reloadCurrent: return .reloadCurrent + case .newItems: return .newItems } } } diff --git a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift index d2e476fcd8..2b7e52036f 100644 --- a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift +++ b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift @@ -13,7 +13,7 @@ public class Dependencies { @ThreadSafeObject private static var cachedIsRTLRetriever: (requiresMainThread: Bool, retriever: () -> Bool) = (false, { false }) @ThreadSafeObject private var storage: DependencyStorage = DependencyStorage() - private typealias DependencyChange = (Dependencies.DependencyStorage.Key, DependencyStorage.Value?) + private typealias DependencyChange = (Dependencies.Key, DependencyStorage.Value?) private let dependencyChangeStream: CancellationAwareAsyncStream = CancellationAwareAsyncStream() // MARK: - Subscript Access @@ -125,22 +125,28 @@ public class Dependencies { // MARK: - Instance management public func has(singleton: SingletonConfig) -> Bool { - let key: DependencyStorage.Key = DependencyStorage.Key.Variant.singleton.key(singleton.identifier) + let key: Dependencies.Key = Key.Variant.singleton.key(singleton.identifier) return (_storage.performMap({ $0.instances[key]?.value(as: S.self) }) != nil) } - public func warmCache(cache: CacheConfig) { + public func warm(singleton: SingletonConfig) { + _ = getOrCreate(singleton) + } + + public func warm(cache: CacheConfig) { _ = getOrCreate(cache) } public func set(singleton: SingletonConfig, to instance: S) { - setValue(instance, typedStorage: .singleton(instance), key: singleton.identifier) + let isNoop: Bool = (instance is NoopDependency) + setValue(instance, typedStorage: .singleton(instance, isNoop: isNoop), key: singleton.identifier) } public func set(cache: CacheConfig, to instance: M) { let value: ThreadSafeObject = ThreadSafeObject(cache.mutableInstance(instance)) - setValue(value, typedStorage: .cache(value), key: cache.identifier) + let isNoop: Bool = (instance is NoopDependency) + setValue(value, typedStorage: .cache(value, isNoop: isNoop), key: cache.identifier) } public func remove(cache: CacheConfig) { @@ -151,7 +157,7 @@ public class Dependencies { _cachedIsRTLRetriever.set(to: (requiresMainThread, isRTLRetriever)) } - private func waitUntilInitialised(targetKey: Dependencies.DependencyStorage.Key) async throws { + private func waitUntilInitialised(targetKey: Dependencies.Key) async throws { /// If we already have an instance (which isn't a `NoopDependency`) then no need to observe the stream guard !_storage.performMap({ $0.instances[targetKey]?.isNoop == false }) else { return } @@ -164,11 +170,11 @@ public class Dependencies { } public func waitUntilInitialised(singleton: SingletonConfig) async throws { - try await waitUntilInitialised(targetKey: DependencyStorage.Key.Variant.singleton.key(singleton.identifier)) + try await waitUntilInitialised(targetKey: Key.Variant.singleton.key(singleton.identifier)) } public func waitUntilInitialised(cache: CacheConfig) async throws { - try await waitUntilInitialised(targetKey: DependencyStorage.Key.Variant.cache.key(cache.identifier)) + try await waitUntilInitialised(targetKey: Key.Variant.cache.key(cache.identifier)) } } @@ -187,8 +193,7 @@ private extension ThreadSafeObject { public extension Dependencies { func hasSet(feature: FeatureConfig) -> Bool { - let key: Dependencies.DependencyStorage.Key = DependencyStorage.Key.Variant.feature - .key(feature.identifier) + let key: Dependencies.Key = Key.Variant.feature.key(feature.identifier) /// Use a `readLock` to check if a value has been set guard @@ -200,8 +205,7 @@ public extension Dependencies { } func set(feature: FeatureConfig, to updatedFeature: T) { - let key: Dependencies.DependencyStorage.Key = DependencyStorage.Key.Variant.feature - .key(feature.identifier) + let key: Dependencies.Key = Key.Variant.feature.key(feature.identifier) let typedValue: DependencyStorage.Value? = _storage.performMap { $0.instances[key] } /// Update the cached & in-memory values @@ -209,8 +213,9 @@ public extension Dependencies { typedValue?.value(as: Feature.self) ?? feature.createInstance(self) ) + let isNoop: Bool = (instance is NoopDependency) instance.setValue(to: updatedFeature, using: self) - setValue(instance, typedStorage: .feature(instance), key: feature.identifier) + setValue(instance, typedStorage: .feature(instance, isNoop: isNoop), key: feature.identifier) /// Notify observers notifyAsync(events: [ @@ -220,8 +225,7 @@ public extension Dependencies { } func reset(feature: FeatureConfig) { - let key: Dependencies.DependencyStorage.Key = DependencyStorage.Key.Variant.feature - .key(feature.identifier) + let key: Dependencies.Key = Key.Variant.feature.key(feature.identifier) /// Reset the cached and in-memory values _storage.perform { storage in @@ -232,8 +236,6 @@ public extension Dependencies { removeValue(feature.identifier, of: .feature) /// Notify observers - - Task { await dependencyChangeStream.send((key, nil)) } notifyAsync(events: [ ObservedEvent(key: .feature(feature), value: nil), ObservedEvent(key: .featureGroup(feature), value: nil) @@ -253,45 +255,46 @@ public enum DependenciesError: Error { // MARK: - Storage Management +public extension Dependencies { + struct Key: Hashable, CustomStringConvertible { + public enum Variant: String { + case singleton + case cache + case userDefaults + case feature + + public func key(_ identifier: String) -> Key { + return Key(identifier, of: self) + } + } + + public let identifier: String + public let variant: Variant + public var description: String { "\(variant): \(identifier)" } + + fileprivate init(_ identifier: String, of variant: Variant) { + self.identifier = identifier + self.variant = variant + } + } +} + private extension Dependencies { class DependencyStorage { var initializationLocks: [Key: NSLock] = [:] var instances: [Key: Value] = [:] - struct Key: Hashable, CustomStringConvertible { - enum Variant: String { - case singleton - case cache - case userDefaults - case feature - - func key(_ identifier: String) -> Key { - return Key(identifier, of: self) - } - } - - let identifier: String - let variant: Variant - var description: String { "\(variant): \(identifier)" } - - init(_ identifier: String, of variant: Variant) { - self.identifier = identifier - self.variant = variant - } - } - enum Value { - case singleton(Any) - case cache(ThreadSafeObject) - case userDefaults(UserDefaultsType) - case feature(any FeatureType) + case singleton(Any, isNoop: Bool) + case cache(ThreadSafeObject, isNoop: Bool) + case userDefaults(UserDefaultsType, isNoop: Bool) + case feature(any FeatureType, isNoop: Bool) var isNoop: Bool { switch self { - case .singleton(let value): return value is NoopDependency - case .userDefaults(let value): return value is NoopDependency - case .feature(let value): return value is NoopDependency - case .cache(let value): return value.performMap { $0 is NoopDependency } + case .singleton(_, let isNoop), .userDefaults(_, let isNoop), + .feature(_, let isNoop), .cache(_, let isNoop): + return isNoop } } @@ -306,10 +309,10 @@ private extension Dependencies { func value(as type: T.Type) -> T? { switch self { - case .singleton(let value): return value as? T - case .cache(let value): return value as? T - case .userDefaults(let value): return value as? T - case .feature(let value): return value as? T + case .singleton(let value, _): return value as? T + case .cache(let value, _): return value as? T + case .userDefaults(let value, _): return value as? T + case .feature(let value, _): return value as? T } } } @@ -351,7 +354,7 @@ private extension Dependencies { identifier: String, constructor: DependencyStorage.Constructor ) -> Value { - let key: Dependencies.DependencyStorage.Key = constructor.variant.key(identifier) + let key: Dependencies.Key = constructor.variant.key(identifier) /// If we already have an instance then just return that (need to get a `writeLock` here because accessing values on a class /// isn't thread safe so we need to block during access) @@ -391,7 +394,7 @@ private extension Dependencies { /// Convenience method to store a dependency instance in memory in a thread-safe way @discardableResult private func setValue(_ value: T, typedStorage: DependencyStorage.Value, key: String) -> T { - let finalKey: DependencyStorage.Key = typedStorage.distinctKey(for: key) + let finalKey: Key = typedStorage.distinctKey(for: key) let result: T = _storage.performUpdateAndMap { storage in storage.instances[finalKey] = typedStorage return (storage, value) @@ -407,8 +410,8 @@ private extension Dependencies { } /// Convenience method to remove a dependency instance from memory in a thread-safe way - private func removeValue(_ key: String, of variant: DependencyStorage.Key.Variant) { - let finalKey: DependencyStorage.Key = variant.key(key) + private func removeValue(_ key: String, of variant: Key.Variant) { + let finalKey: Key = variant.key(key) _storage.performUpdate { storage in storage.instances.removeValue(forKey: finalKey) return storage @@ -422,39 +425,91 @@ private extension Dependencies { private extension Dependencies.DependencyStorage { struct Constructor { - let variant: Key.Variant + let variant: Dependencies.Key.Variant let create: () -> (typedStorage: Dependencies.DependencyStorage.Value, value: T) static func singleton(_ constructor: @escaping () -> T) -> Constructor { return Constructor(variant: .singleton) { let instance: T = constructor() + let isNoop: Bool = (instance is NoopDependency) - return (.singleton(instance), instance) + return (.singleton(instance, isNoop: isNoop), instance) } } static func cache(_ constructor: @escaping () -> T) -> Constructor where T: ThreadSafeObject { return Constructor(variant: .cache) { + /// We need to peek at the wrapped value to check if it's a `NoopDependency` so use `performMap` to access + /// it safely let instance: T = constructor() + let isNoop: Bool = instance.performMap { $0 is NoopDependency } - return (.cache(instance), instance) + return (.cache(instance, isNoop: isNoop), instance) } } static func userDefaults(_ constructor: @escaping () -> T) -> Constructor where T == UserDefaultsType { return Constructor(variant: .userDefaults) { let instance: T = constructor() + let isNoop: Bool = (instance is NoopDependency) - return (.userDefaults(instance), instance) + return (.userDefaults(instance, isNoop: isNoop), instance) } } static func feature(_ constructor: @escaping () -> T) -> Constructor where T: FeatureType { return Constructor(variant: .feature) { let instance: T = constructor() + let isNoop: Bool = (instance is NoopDependency) - return (.feature(instance), instance) + return (.feature(instance, isNoop: isNoop), instance) } } } } + +// MARK: - Async/Await + +public extension Dependencies { + /// This function builds without issue on iOS 15 but unfortunately it ends up crashing due to the incomplete async/await implementation + /// that was included in that version. Everything works without issues (or crashes) on iOS 16 and above though hence the `@available` + /// restrictions in place + @available(iOS 16.0, *) + private func stream(key: Dependencies.Key, initialValueRetriever: (@escaping () -> T?)) -> AsyncStream { + return dependencyChangeStream.stream + .filter { changedKey, _ in changedKey == key } + .compactMap { _, changedValue in changedValue?.value(as: T.self) } + .prepend(initialValueRetriever()) + .asAsyncStream() + } + + @available(iOS 16.0, *) + func stream(key: Dependencies.Key, of type: T.Type) -> AsyncStream { + return stream(key: key, initialValueRetriever: { nil }) + } + + @available(iOS 16.0, *) + func stream(singleton: SingletonConfig) -> AsyncStream { + let key = Dependencies.Key.Variant.singleton.key(singleton.identifier) + + return stream(key: key, initialValueRetriever: { [weak self] in self?[singleton: singleton] }) + } + + @available(iOS 16.0, *) + func stream(cache: CacheConfig) -> AsyncStream { + let key = Dependencies.Key.Variant.cache.key(cache.identifier) + + return stream(key: key, initialValueRetriever: { [weak self] in self?[cache: cache] }) + } + + @available(iOS 16.0, *) + func stream(feature: FeatureConfig) -> AsyncStream { + let key = Dependencies.Key.Variant.feature.key(feature.identifier) + + return dependencyChangeStream.stream + .filter { changedKey, _ in changedKey == key } + .compactMap { [weak self] _, _ in self?[feature: feature] } + .prepend(self[feature: feature]) + .asAsyncStream() + } +} diff --git a/SessionUtilitiesKit/General/Authentication.swift b/SessionUtilitiesKit/General/Authentication.swift index 62b3662bde..f44a3a1d78 100644 --- a/SessionUtilitiesKit/General/Authentication.swift +++ b/SessionUtilitiesKit/General/Authentication.swift @@ -4,11 +4,20 @@ import Foundation import GRDB public enum Authentication {} -public protocol AuthenticationMethod: SignatureGenerator { +public protocol AuthenticationMethod: Sendable, SignatureGenerator { var info: Authentication.Info { get } } public extension AuthenticationMethod { + var isInvalid: Bool { + switch info { + case .standard(let sessionId, let ed25519PublicKey): + return (sessionId == .invalid || ed25519PublicKey.isEmpty) + + default: return false + } + } + var swarmPublicKey: String { get throws { switch info { @@ -21,6 +30,30 @@ public extension AuthenticationMethod { } } +public extension Authentication { + static let invalid: AuthenticationMethod = Invalid() + + struct Invalid: AuthenticationMethod { + public var info: Authentication.Info = .standard(sessionId: .invalid, ed25519PublicKey: []) + + public func generateSignature(with verificationBytes: [UInt8], using dependencies: Dependencies) throws -> Authentication.Signature { + throw CryptoError.invalidAuthentication + } + } +} + +public struct EquatableAuthenticationMethod: Sendable, Equatable { + public let value: AuthenticationMethod + + public init(value: AuthenticationMethod) { + self.value = value + } + + public static func ==(lhs: EquatableAuthenticationMethod, rhs: EquatableAuthenticationMethod) -> Bool { + return (lhs.value.info == rhs.value.info) + } +} + // MARK: - SignatureGenerator public protocol SignatureGenerator { @@ -54,7 +87,7 @@ public extension Authentication { // MARK: - Authentication.Info public extension Authentication { - enum Info: Equatable { + enum Info: Sendable, Equatable { /// Used when interacting as the current user case standard(sessionId: SessionId, ed25519PublicKey: [UInt8]) diff --git a/SessionUtilitiesKit/General/Collection+Utilities.swift b/SessionUtilitiesKit/General/Collection+Utilities.swift deleted file mode 100644 index 2c6e60cac5..0000000000 --- a/SessionUtilitiesKit/General/Collection+Utilities.swift +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public extension Collection where Element == String { - func withUnsafeCStrArray( - _ body: (UnsafeBufferPointer?>) throws -> R - ) throws -> R { - let pointerArray = UnsafeMutablePointer?>.allocate(capacity: self.count) - var allocatedCStrings: [UnsafeMutablePointer?] = [] - allocatedCStrings.reserveCapacity(self.count) - defer { - for ptr in allocatedCStrings { - free(ptr) /// Need to use `free` for memory allocated by `strdup` - } - pointerArray.deallocate() - } - - var currentPtr: UnsafeMutablePointer?> = pointerArray - for element in self { - /// `strdup` allocates memory and copies the C string (inc. null terminator), it returns NULL on allocation failure. - guard let cString: UnsafeMutablePointer = strdup(element) else { - throw LibSessionError.invalidCConversion - } - - allocatedCStrings.append(cString) /// Track for cleanup - currentPtr.pointee = cString /// Store pointer in the array - currentPtr += 1 /// Move to next slot in pointer array - } - - let mutableBuffer = UnsafeBufferPointer(start: pointerArray, count: self.count) - - return try mutableBuffer.withMemoryRebound(to: UnsafePointer?.self) { immutableBuffer in - try body(immutableBuffer) - } - } -} - -public extension Collection where Element == [UInt8]? { - func withUnsafeUInt8CArray( - _ body: (UnsafeBufferPointer?>) throws -> R - ) throws -> R { - let pointerArray = UnsafeMutablePointer?>.allocate(capacity: self.count) - var allocatedByteArrays: [UnsafeMutableRawPointer?] = [] - allocatedByteArrays.reserveCapacity(self.count) - - defer { - for ptr in allocatedByteArrays { - free(ptr) /// Need to use `free` for memory allocated by `malloc` - } - - pointerArray.deallocate() - } - - var currentPtr: UnsafeMutablePointer?> = pointerArray - for maybeBytes in self { - if let bytes = maybeBytes { - guard let allocatedMemory: UnsafeMutableRawPointer = malloc(bytes.count) else { - throw LibSessionError.invalidCConversion - } - - allocatedByteArrays.append(allocatedMemory) /// Track for cleanup - memcpy(allocatedMemory, bytes, bytes.count) /// Copy bytes into the allocated memory - currentPtr.pointee = allocatedMemory.assumingMemoryBound(to: UInt8.self) /// Store in array - } else { - currentPtr.pointee = nil /// Store nil in array - } - currentPtr += 1 /// Move to next slot in pointer array - } - - let mutableBuffer = UnsafeBufferPointer(start: pointerArray, count: self.count) - - return try mutableBuffer.withMemoryRebound(to: UnsafePointer?.self) { immutableBuffer in - try body(immutableBuffer) - } - } -} diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index e873a41200..89532e555d 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -21,11 +21,11 @@ public extension FeatureStorage { static let truncatePubkeysInLogs: FeatureConfig = Dependencies.create( identifier: "truncatePubkeysInLogs", defaultOption: { - #if DEBUG +#if DEBUG return false - #else +#else return true - #endif +#endif }() ) @@ -98,55 +98,24 @@ public extension FeatureStorage { identifier: "sessionPro" ) - static let mockCurrentUserSessionProState: FeatureConfig = Dependencies.create( - identifier: "mockCurrentUserSessionProState" + static let proBadgeEverywhere: FeatureConfig = Dependencies.create( + identifier: "proBadgeEverywhere" ) - static let mockCurrentUserSessionProExpiry: FeatureConfig = Dependencies.create( - identifier: "mockCurrentUserSessionProExpiry" + static let fakeAppleSubscriptionForDev: FeatureConfig = Dependencies.create( + identifier: "fakeAppleSubscriptionForDev" ) - static let mockCurrentUserSessionProLoadingState: FeatureConfig = Dependencies.create( - identifier: "mockCurrentUserSessionProLoadingState" + static let forceMessageFeatureProBadge: FeatureConfig = Dependencies.create( + identifier: "forceMessageFeatureProBadge" ) - static let proPlanOriginatingPlatform: FeatureConfig = Dependencies.create( - identifier: "proPlanOriginatingPlatform" + static let forceMessageFeatureLongMessage: FeatureConfig = Dependencies.create( + identifier: "forceMessageFeatureLongMessage" ) - static let mockNonOriginatingAccount: FeatureConfig = Dependencies.create( - identifier: "mockNonOriginatingAccount", - defaultOption: false - ) - - static let mockExpiredOverThirtyDays: FeatureConfig = Dependencies.create( - identifier: "mockExpiredOverThirtyDays", - defaultOption: false - ) - - static let mockInstalledFromIPA: FeatureConfig = Dependencies.create( - identifier: "mockInstalledFromIPA", - defaultOption: false - ) - - static let proPlanToRecover: FeatureConfig = Dependencies.create( - identifier: "proPlanToRecover" - ) - - static let allUsersSessionPro: FeatureConfig = Dependencies.create( - identifier: "allUsersSessionPro" - ) - - static let messageFeatureProBadge: FeatureConfig = Dependencies.create( - identifier: "messageFeatureProBadge" - ) - - static let messageFeatureLongMessage: FeatureConfig = Dependencies.create( - identifier: "messageFeatureLongMessage" - ) - - static let messageFeatureAnimatedAvatar: FeatureConfig = Dependencies.create( - identifier: "messageFeatureAnimatedAvatar" + static let forceMessageFeatureAnimatedAvatar: FeatureConfig = Dependencies.create( + identifier: "forceMessageFeatureAnimatedAvatar" ) static let shortenFileTTL: FeatureConfig = Dependencies.create( @@ -303,6 +272,83 @@ public struct Feature: FeatureType { } } +// MARK: - MockableFeature + +public protocol MockableFeatureValue: RawRepresentable, Sendable, Hashable, Equatable, CaseIterable where RawValue == Int { + var title: String { get } + var subtitle: String { get } +} + +extension MockableFeatureValue { + public var rawValue: Int { + let targetId: String = String(reflecting: self) + + for (index, element) in Self.allCases.enumerated() { + if String(reflecting: element) == targetId { + return index + 1 /// The `rawValue` is 1-indexed whereas the array is 0-indexed + } + } + + return 0 /// Should theoretically never happen if self is in `allCases` + } + + public init?(rawValue: Int) { + /// The `rawValue` is 1-indexed whereas the array is 0-indexed + let index: Int = (rawValue - 1) + let all: [Self] = Array(Self.allCases) + + guard all.indices.contains(index) else { return nil } + + self = all[index] + } +} + +public enum MockableFeature: Sendable, FeatureOption, CaseIterable { + public static var allCases: [MockableFeature] { [.useActual] + T.allCases.map { .simulate($0) } } + + case useActual + case simulate(T) + + public typealias RawValue = Int + + public var rawValue: Int { + switch self { + case .useActual: return -1 + case .simulate(let value): return value.rawValue + } + } + + + public init?(rawValue: Int) { + guard rawValue != -1 else { + self = .useActual + return + } + + guard let val: T = T(rawValue: rawValue) else { + return nil + } + + self = .simulate(val) + } + + public static var defaultOption: MockableFeature { .useActual } + + public var title: String { + switch self { + case .useActual: return "None" + case .simulate(let value): return value.title + } + } + + public var subtitle: String? { + switch self { + case .useActual: return "Use the actual calculated state." + case .simulate(let value): return value.subtitle + } + } +} + // MARK: - Convenience public struct FeatureValue { diff --git a/SessionUtilitiesKit/General/ReusableView.swift b/SessionUtilitiesKit/General/ReusableView.swift new file mode 100644 index 0000000000..032b624c6d --- /dev/null +++ b/SessionUtilitiesKit/General/ReusableView.swift @@ -0,0 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public protocol ReusableView: AnyObject { + static var defaultReuseIdentifier: String { get } +} + +public extension ReusableView where Self: UIView { + static var defaultReuseIdentifier: String { + return String(describing: self.self) + } +} + +extension UICollectionReusableView: ReusableView {} +extension UITableViewCell: ReusableView {} +extension UITableViewHeaderFooterView: ReusableView {} diff --git a/SessionUtilitiesKit/General/ScreenLock.swift b/SessionUtilitiesKit/General/ScreenLock.swift index 09843f1586..df49c37435 100644 --- a/SessionUtilitiesKit/General/ScreenLock.swift +++ b/SessionUtilitiesKit/General/ScreenLock.swift @@ -218,6 +218,10 @@ public enum ScreenLock { Log.error(.screenLock, "Context not interactive.") return .unexpectedFailure(error: defaultErrorDescription) + case .companionNotAvailable: + Log.error(.screenLock, "Companion not available.") + return .unexpectedFailure(error: defaultErrorDescription) + @unknown default: return .failure(error: defaultErrorDescription) } diff --git a/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift b/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift index bb0cb7ab73..e01f648422 100644 --- a/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift +++ b/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift @@ -81,6 +81,10 @@ public enum ObservationContext { // MARK: - Convenience +public extension ObservingDatabase { + var lastInsertedRowID: Int64 { originalDb.lastInsertedRowID } +} + public extension FetchableRecord where Self: TableRecord { static func fetchAll(_ db: ObservingDatabase) throws -> [Self] { return try self.fetchAll(db.originalDb) @@ -111,6 +115,12 @@ public extension FetchableRecord where Self: TableRecord, Self: Identifiable, Se } } +public extension FetchRequest { + func fetchCount(_ db: ObservingDatabase) throws -> Int { + return try self.fetchCount(db.originalDb) + } +} + public extension FetchRequest where Self.RowDecoder: FetchableRecord { func fetchCursor(_ db: ObservingDatabase) throws -> RecordCursor { return try self.fetchCursor(db.originalDb) diff --git a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift index 300b7f7748..929b82312e 100644 --- a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift +++ b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift @@ -3,6 +3,7 @@ // stringlint:disable import Foundation +import SessionUtil // MARK: - String @@ -56,51 +57,101 @@ public extension Array where Element == String { } } +public extension Collection where Element == String { + func withUnsafeCStrArray( + _ body: (UnsafeBufferPointer?>) throws -> R + ) throws -> R { + var allocatedBuffers: [UnsafeMutableBufferPointer] = [] + allocatedBuffers.reserveCapacity(self.count) + defer { allocatedBuffers.forEach { $0.deallocate() } } + + var pointers: [UnsafePointer?] = [] + pointers.reserveCapacity(self.count) + + for string in self { + let utf8: [CChar] = Array(string.utf8CString) /// Includes null terminator + let buffer = UnsafeMutableBufferPointer.allocate(capacity: utf8.count) + _ = buffer.initialize(from: utf8) + allocatedBuffers.append(buffer) + pointers.append(UnsafePointer(buffer.baseAddress)) + } + + return try pointers.withUnsafeBufferPointer { buffer in + try body(buffer) + } + } +} -// MARK: - CAccessible +public extension Collection where Element == [UInt8]? { + func withUnsafeUInt8CArray( + _ body: (UnsafeBufferPointer?>) throws -> R + ) throws -> R { + var allocatedBuffers: [UnsafeMutableBufferPointer] = [] + allocatedBuffers.reserveCapacity(self.count) + defer { allocatedBuffers.forEach { $0.deallocate() } } + + var pointers: [UnsafePointer?] = [] + pointers.reserveCapacity(self.count) + + for maybeBytes in self { + if let bytes: [UInt8] = maybeBytes { + let buffer = UnsafeMutableBufferPointer.allocate(capacity: bytes.count) + _ = buffer.initialize(from: bytes) + allocatedBuffers.append(buffer) + pointers.append(UnsafePointer(buffer.baseAddress)) + } else { + pointers.append(nil) + } + } + + return try pointers.withUnsafeBufferPointer { buffer in + try body(buffer) + } + } +} -public protocol CAccessible { - // General types - - func get(_ keyPath: KeyPath) -> T - - // String variants - - func get(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> String - - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - - // Data variants - - func get(_ keyPath: KeyPath) -> Data - func get(_ keyPath: KeyPath) -> [UInt8] - func getHex(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> Data - func get(_ keyPath: KeyPath) -> [UInt8] - func getHex(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> Data - func get(_ keyPath: KeyPath) -> [UInt8] - func getHex(_ keyPath: KeyPath) -> String - - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? +public extension Collection where Element: DataProtocol { + func withUnsafeSpanOfSpans(_ body: (UnsafePointer?, Int) throws -> Result) rethrows -> Result { + var allocatedBuffers: [UnsafeMutableBufferPointer] = [] + allocatedBuffers.reserveCapacity(self.count) + defer { allocatedBuffers.forEach { $0.deallocate() } } + + var spans: [span_u8] = [] + spans.reserveCapacity(self.count) + + for data in self { + let bytes: [UInt8] = Array(data) + let buffer = UnsafeMutableBufferPointer.allocate(capacity: bytes.count) + _ = buffer.initialize(from: bytes) + allocatedBuffers.append(buffer) + + var span: span_u8 = span_u8() + span.data = buffer.baseAddress + span.size = bytes.count + spans.append(span) + } + + return try spans.withUnsafeBufferPointer { spanBuffer in + try body(spanBuffer.baseAddress, spanBuffer.count) + } + } +} + +public extension DataProtocol { + func withUnsafeSpan(_ body: (span_u8) throws -> Result) rethrows -> Result { + try Data(self).withUnsafeBytes { bytes in + var span: span_u8 = span_u8() + span.data = UnsafeMutablePointer(mutating: bytes.baseAddress?.assumingMemoryBound(to: UInt8.self)) + span.size = self.count + + return try body(span) + } + } } +// MARK: - CAccessible + +public protocol CAccessible {} public extension CAccessible { // General types @@ -111,22 +162,34 @@ public extension CAccessible { func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } + func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } + } + + func get(_ keyPath: KeyPath) -> String { + withUnsafePointer(to: self) { $0.get(keyPath) } + } + + func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } @@ -135,12 +198,21 @@ public extension CAccessible { func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } + func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } + func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } + func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } + func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } + func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } + func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } + func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } + func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } + func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } @@ -151,6 +223,15 @@ public extension CAccessible { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } + } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } @@ -169,30 +250,29 @@ public extension CAccessible { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } + } } // MARK: - CMutable -public protocol CMutable { - // General types - - mutating func set(_ keyPath: WritableKeyPath, to value: T) - - // String variants - - mutating func set(_ keyPath: WritableKeyPath, to value: String?) - mutating func set(_ keyPath: WritableKeyPath, to value: String?) - mutating func set(_ keyPath: WritableKeyPath, to value: String?) - mutating func set(_ keyPath: WritableKeyPath, to value: String?) - mutating func set(_ keyPath: WritableKeyPath, to value: String?) - - // Data variants - - mutating func set(_ keyPath: WritableKeyPath, to value: T?) - mutating func set(_ keyPath: WritableKeyPath, to value: T?) - mutating func set(_ keyPath: WritableKeyPath, to value: T?) -} - +public protocol CMutable {} public extension CMutable { // General types @@ -206,6 +286,10 @@ public extension CMutable { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } + mutating func set(_ keyPath: WritableKeyPath, to value: T?) { + withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } + } + mutating func set(_ keyPath: WritableKeyPath, to value: T?) { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } @@ -214,6 +298,10 @@ public extension CMutable { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } + mutating func set(_ keyPath: WritableKeyPath, to value: D?) { + withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } + } + // String variants mutating func set(_ keyPath: WritableKeyPath, to value: String?) { @@ -228,6 +316,10 @@ public extension CMutable { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } + mutating func set(_ keyPath: WritableKeyPath, to value: String?) { + withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } + } + mutating func set(_ keyPath: WritableKeyPath, to value: String?) { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } @@ -239,287 +331,322 @@ public extension CMutable { // MARK: - Pointer Convenience -public extension UnsafeMutablePointer { +public protocol ReadablePointer { + associatedtype Pointee + var ptr: Pointee { get } +} + +extension UnsafePointer: ReadablePointer { + public var ptr: Pointee { pointee } +} +extension UnsafeMutablePointer: ReadablePointer { + public var ptr: Pointee { pointee } +} + +public extension ReadablePointer { // General types - func get(_ keyPath: KeyPath) -> T { UnsafePointer(self).get(keyPath) } + func get(_ keyPath: KeyPath) -> T { ptr[keyPath: keyPath] } // String variants - func get(_ keyPath: KeyPath) -> String { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> String { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> String { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> String { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> String { UnsafePointer(self).get(keyPath) } + func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } + func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } + func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } + func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } + func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } + func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + + func get(_ keyPath: KeyPath) -> String { + getCString(keyPath) + } + + func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty) } // Data variants - func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } - func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } - func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } - func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } - func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } - func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } + + func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty) } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { Array($0) } } func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + getData(keyPath, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + getData(keyPath, nullIfEmpty: nullIfEmpty).map { Array($0) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty) } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { Array($0) } } func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty) } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { Array($0) } } func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + getData(keyPath, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + getData(keyPath, nullIfEmpty: nullIfEmpty).map { Array($0) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + getData(keyPath, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + getData(keyPath, nullIfEmpty: nullIfEmpty).map { Array($0) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } } } public extension UnsafeMutablePointer { // General types - func set(_ keyPath: WritableKeyPath, to value: T) { pointee[keyPath: keyPath] = value } + func set(_ keyPath: WritableKeyPath, to value: T) { + var mutablePointee: Pointee = pointee + mutablePointee[keyPath: keyPath] = value + pointee = mutablePointee + } // String variants - func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value, maxLength: 65) } - func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value, maxLength: 67) } - func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value, maxLength: 101) } - func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value, maxLength: 224) } - func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value, maxLength: 268) } + func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value) } + func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value) } + func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value) } + func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value) } + func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value) } + func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value) } // Data variants func set(_ keyPath: WritableKeyPath, to value: T?) { - setData(keyPath, value.map { Data($0) }, length: 32) + setData(keyPath, value.map { Data($0) }) + } + + func set(_ keyPath: WritableKeyPath, to value: T?) { + setData(keyPath, value.map { Data($0) }) } func set(_ keyPath: WritableKeyPath, to value: T?) { - setData(keyPath, value.map { Data($0) }, length: 64) + setData(keyPath, value.map { Data($0) }) } func set(_ keyPath: WritableKeyPath, to value: T?) { - setData(keyPath, value.map { Data($0) }, length: 100) + setData(keyPath, value.map { Data($0) }) } -} - -public extension UnsafePointer { - // General types - func get(_ keyPath: KeyPath) -> T { pointee[keyPath: keyPath] } - - // String variants - - func get(_ keyPath: KeyPath) -> String { getCString(keyPath, maxLength: 65) } - func get(_ keyPath: KeyPath) -> String { getCString(keyPath, maxLength: 67) } - func get(_ keyPath: KeyPath) -> String { getCString(keyPath, maxLength: 101) } - func get(_ keyPath: KeyPath) -> String { getCString(keyPath, maxLength: 224) } - func get(_ keyPath: KeyPath) -> String { getCString(keyPath, maxLength: 268) } - - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, maxLength: 65, nullIfEmpty: nullIfEmpty) + func set(_ keyPath: WritableKeyPath, to value: D?) { + setData(keyPath, value.map { Data($0) }) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, maxLength: 67, nullIfEmpty: nullIfEmpty) +} + +// MARK: - Internal Logic + +private extension ReadablePointer { + func _getData(_ byteArray: T) -> Data { + return withUnsafeBytes(of: byteArray) { Data($0) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, maxLength: 101, nullIfEmpty: nullIfEmpty) + + private func _getData(_ span: span_u8) -> Data { + guard let data: UnsafeMutablePointer = span.data else { return Data() } + + return Data(bytes: data, count: span.size) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, maxLength: 224, nullIfEmpty: nullIfEmpty) + + func _getData(_ byteArray: T, nullIfEmpty: Bool) -> Data? { + let result: Data = _getData(byteArray) + + return (!nullIfEmpty || result.contains(where: { $0 != 0 }) ? result : nil) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, maxLength: 268, nullIfEmpty: nullIfEmpty) + + func _getData(_ span: span_u8, nullIfEmpty: Bool) -> Data? { + let result: Data = _getData(span) + + return (!nullIfEmpty || result.contains(where: { $0 != 0 }) ? result : nil) } - // Data variants + func _string(from value: T, explicitLength: Int? = nil) -> String { + withUnsafeBytes(of: value) { rawBufferPointer in + guard let buffer = rawBufferPointer.baseAddress?.assumingMemoryBound(to: CChar.self) else { + return "" + } + + if let length: Int = explicitLength { + return (String(pointer: buffer, length: length) ?? "") + } + + /// If we weren't given an explicit length then assume the string is null-terminated + return String(cString: buffer) + } + } - func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 32) } - func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 32)) } - func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 32).toHexString() } - func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 64) } - func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 64)) } - func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 64).toHexString() } - func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 100) } - func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 100)) } - func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 100).toHexString() } + func getData(_ keyPath: KeyPath) -> Data { + return _getData(ptr[keyPath: keyPath]) + } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty) + func getData(_ keyPath: KeyPath) -> Data { + return _getData(ptr[keyPath: keyPath]) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty).map { Array($0) } + + func getData(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? { + return _getData(ptr[keyPath: keyPath], nullIfEmpty: nullIfEmpty) } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + + func getData(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? { + return _getData(ptr[keyPath: keyPath], nullIfEmpty: nullIfEmpty) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty) + + func getData(_ keyPath: KeyPath) -> Data { + return _getData(ptr[keyPath: keyPath].data) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty).map { Array($0) } + + func getData(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? { + return _getData(ptr[keyPath: keyPath].data, nullIfEmpty: nullIfEmpty) } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + + func getCString(_ keyPath: KeyPath) -> String { + return _string(from: ptr[keyPath: keyPath]) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - getData(keyPath, length: 100, nullIfEmpty: nullIfEmpty) + + func getCString(_ keyPath: KeyPath, nullIfEmpty: Bool, explicitLength: Int?) -> String? { + let result: String = _string(from: ptr[keyPath: keyPath], explicitLength: explicitLength) + + return (!nullIfEmpty || !result.isEmpty ? result : nil) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - getData(keyPath, length: 100, nullIfEmpty: nullIfEmpty).map { Array($0) } + + func getCString(_ keyPath: KeyPath) -> String { + let stringPtr: string8 = ptr[keyPath: keyPath] + + return (String(pointer: stringPtr.data, length: stringPtr.size) ?? "") } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getData(keyPath, length: 100, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + + func getCString(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? { + let stringPtr: string8 = ptr[keyPath: keyPath] + let result: String = (String(pointer: stringPtr.data, length: stringPtr.size) ?? "") + + return (!nullIfEmpty || !result.isEmpty ? result : nil) } } -// MARK: - Internal Logic - private extension UnsafeMutablePointer { - private func getData(_ keyPath: KeyPath, length: Int) -> Data { - return UnsafePointer(self).getData(keyPath, length: length) - } - - private func getData(_ keyPath: KeyPath, length: Int, nullIfEmpty: Bool) -> Data? { - return UnsafePointer(self).getData(keyPath, length: length, nullIfEmpty: nullIfEmpty) - } - - private func setData(_ keyPath: WritableKeyPath, _ value: Data?, length: Int) { - if let value: Data = value, value.count > length { - Log.warn("Setting \(keyPath) to data with \(value.count) length, expected: \(length), value will be truncated.") - } - + private func setData(_ keyPath: WritableKeyPath, _ value: Data?) { var mutableSelf = pointee withUnsafeMutableBytes(of: &mutableSelf[keyPath: keyPath]) { rawBufferPointer in - guard let baseAddress = rawBufferPointer.baseAddress else { return } - - let buffer = baseAddress.assumingMemoryBound(to: UInt8.self) - guard let value: Data = value else { - // Zero-fill the data - memset(buffer, 0, length) - return - } - - value.copyBytes(to: buffer, count: min(length, value.count)) + rawBufferPointer.initializeMemory(as: UInt8.self, repeating: 0) - if value.count < length { - // Zero-fill any remaining bytes - memset(buffer.advanced(by: value.count), 0, length - value.count) + if + let value: Data = value, + let buffer = rawBufferPointer.baseAddress?.assumingMemoryBound(to: UInt8.self) + { + if value.count > rawBufferPointer.count { + Log.warn("Setting \(keyPath) to data with \(value.count) length, expected: \(rawBufferPointer.count), value will be truncated.") + } + + let copyCount: Int = min(rawBufferPointer.count, value.count) + value.copyBytes(to: buffer, count: copyCount) } } pointee = mutableSelf } - private func getCString(_ keyPath: KeyPath, maxLength: Int) -> String { - return UnsafePointer(self).getCString(keyPath, maxLength: maxLength) - } - - private func getCString(_ keyPath: KeyPath, maxLength: Int, nullIfEmpty: Bool) -> String? { - return UnsafePointer(self).getCString(keyPath, maxLength: maxLength, nullIfEmpty: nullIfEmpty) - } - - private func setCString(_ keyPath: WritableKeyPath, _ value: String?, maxLength: Int) { + private func setCString(_ keyPath: WritableKeyPath, _ value: String?) { var mutableSelf = pointee withUnsafeMutableBytes(of: &mutableSelf[keyPath: keyPath]) { rawBufferPointer in - guard let baseAddress = rawBufferPointer.baseAddress else { return } + rawBufferPointer.initializeMemory(as: UInt8.self, repeating: 0) - let buffer: UnsafeMutablePointer = baseAddress.assumingMemoryBound(to: CChar.self) - guard let value: String = value else { - // Zero-fill the data - memset(buffer, 0, maxLength) - return + if + let value: String = value, + let buffer = rawBufferPointer.baseAddress?.assumingMemoryBound(to: UInt8.self), + let cData: Data = value.data(using: .utf8) + { + let copyCount: Int = min(rawBufferPointer.count - 1, cData.count) + cData.copyBytes(to: buffer, count: copyCount) } - guard let nullTerminatedString: [CChar] = value.cString(using: .utf8) else { return } - - let copyLength: Int = min(maxLength - 1, nullTerminatedString.count - 1) - strncpy(buffer, nullTerminatedString, copyLength) - buffer[copyLength] = 0 // Ensure null termination } pointee = mutableSelf } } -private extension UnsafePointer { - func getData(_ keyPath: KeyPath, length: Int) -> Data { - let byteArray = pointee[keyPath: keyPath] - return withUnsafeBytes(of: byteArray) { rawBufferPointer in - guard let baseAddress = rawBufferPointer.baseAddress else { return Data() } - - return Data(bytes: baseAddress, count: length) - } - } - - func getData(_ keyPath: KeyPath, length: Int, nullIfEmpty: Bool) -> Data? { - let byteArray = pointee[keyPath: keyPath] - return withUnsafeBytes(of: byteArray) { rawBufferPointer in - guard let baseAddress = rawBufferPointer.baseAddress else { return nil } - - let result: Data = Data(bytes: baseAddress, count: length) - - // If all of the values are 0 then return the data as null - guard !nullIfEmpty || result.contains(where: { $0 != 0 }) else { return nil } - - return result - } - } - - func getCString(_ keyPath: KeyPath, maxLength: Int) -> String { - let charArray = pointee[keyPath: keyPath] - return withUnsafeBytes(of: charArray) { rawBufferPointer in - guard let baseAddress = rawBufferPointer.baseAddress else { return "" } - - let buffer = baseAddress.assumingMemoryBound(to: CChar.self) - return String(cString: buffer) - } - } - - func getCString(_ keyPath: KeyPath, maxLength: Int, nullIfEmpty: Bool) -> String? { - let charArray = pointee[keyPath: keyPath] - return withUnsafeBytes(of: charArray) { rawBufferPointer in - guard let baseAddress = rawBufferPointer.baseAddress else { return nil } - - let buffer = baseAddress.assumingMemoryBound(to: CChar.self) - let result: String = String(cString: buffer) - - guard !nullIfEmpty || !result.isEmpty else { return nil } - - return result - } - } +// MARK: - Explicit C Struct Types + +public protocol CTupleWrapper { + associatedtype TupleType + var data: TupleType { get set } +} + +extension bytes32: CTupleWrapper { + public typealias TupleType = CUChar32 +} + +extension bytes33: CTupleWrapper { + public typealias TupleType = CUChar33 +} + +extension bytes64: CTupleWrapper { + public typealias TupleType = CUChar64 } // MARK: - Fixed Length Types @@ -531,6 +658,13 @@ public typealias CUChar32 = ( UInt8, UInt8 ) +public typealias CUChar33 = ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8 +) + public typealias CUChar64 = ( UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, @@ -588,6 +722,22 @@ public typealias CChar101 = ( CChar ) +public typealias CChar128 = ( + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar +) + public typealias CChar224 = ( CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, diff --git a/SessionUtilitiesKit/Observations/DebounceTaskManager.swift b/SessionUtilitiesKit/Observations/DebounceTaskManager.swift index 3a85fa0119..9b10c3ab81 100644 --- a/SessionUtilitiesKit/Observations/DebounceTaskManager.swift +++ b/SessionUtilitiesKit/Observations/DebounceTaskManager.swift @@ -31,6 +31,7 @@ public actor DebounceTaskManager { debounceTask?.cancel() debounceTask = Task { [weak self] in guard let self = self else { return } + guard !Task.isCancelled else { return } do { /// Only debounce if we want to @@ -40,7 +41,12 @@ public actor DebounceTaskManager { guard !Task.isCancelled else { return } let eventsToProcess: [Event] = await self.clearPendingEvents() - await self.action?(eventsToProcess) + + /// Execute the `action` in a detached task so that it avoids inheriting any potential cancelled state from the calling + /// task, since we capture `self` weakly we don't need to worry about it outliving the owning object either + Task.detached { [weak self] in + await self?.action?(eventsToProcess) + } } catch { // Task was cancelled so no need to do anything } @@ -52,9 +58,15 @@ public actor DebounceTaskManager { debounceTask = Task { [weak self] in guard let self = self else { return } + guard !Task.isCancelled else { return } let eventsToProcess: [Event] = await self.clearPendingEvents() - await self.action?(eventsToProcess) + + /// Execute the `action` in a detached task so that it avoids inheriting any potential cancelled state from the calling + /// task, since we capture `self` weakly we don't need to worry about it outliving the owning object either + Task.detached { [weak self] in + await self?.action?(eventsToProcess) + } } } diff --git a/SessionUtilitiesKit/Observations/ObservableKey.swift b/SessionUtilitiesKit/Observations/ObservableKey.swift index c0383a8ea6..81b5d22ccc 100644 --- a/SessionUtilitiesKit/Observations/ObservableKey.swift +++ b/SessionUtilitiesKit/Observations/ObservableKey.swift @@ -5,21 +5,42 @@ import Foundation public struct GenericObservableKey: Setting.Key, Sendable { public let rawValue: String public init(_ rawValue: String) { self.rawValue = rawValue } - public init(_ original: ObservableKey) { self.rawValue = original.rawValue } + public init(_ original: ObservableKey) { self.rawValue = original.generic.rawValue } } public struct ObservableKey: Setting.Key, Sendable { public let rawValue: String public let generic: GenericObservableKey + internal let streamSource: ExternalStreamSource? public init(_ rawValue: String) { self.rawValue = rawValue self.generic = GenericObservableKey(rawValue) + self.streamSource = nil } public init(_ rawValue: String, _ generic: GenericObservableKey?) { self.rawValue = rawValue self.generic = (generic ?? GenericObservableKey(rawValue)) + self.streamSource = nil + } + + private init(rawValue: String, generic: GenericObservableKey, streamSource: ExternalStreamSource) { + self.rawValue = rawValue + self.generic = generic + self.streamSource = streamSource + } + + public static func stream( + key: String, + generic: GenericObservableKey, + _ streamProvider: @escaping @Sendable () async -> AsyncStream? + ) -> ObservableKey { + return ObservableKey( + rawValue: key, + generic: generic, + streamSource: ExternalStreamSource(id: key, stream: streamProvider) + ) } } @@ -34,6 +55,11 @@ public struct ObservedEvent: Hashable, Sendable { self.storedValue = value.map { AnySendableHashable($0) } } + public init(key: ObservableKey, value: AnySendableHashable) { + self.key = key + self.storedValue = value + } + public init(key: ObservableKey, value: None?) { self.key = key self.storedValue = value.map { AnySendableHashable($0) } @@ -88,3 +114,36 @@ public struct AnySendableHashable: Hashable, Sendable { } } } + +public struct ExternalStreamSource: Sendable, Hashable { + public let id: String + internal let makeStream: @Sendable () async -> AsyncStream? + + public init( + id: String, + stream: @escaping @Sendable () async -> AsyncStream? + ) { + self.id = id + self.makeStream = { + guard let concreteStream = await stream() else { return nil } + + return AsyncStream { continuation in + let task = Task { + for await value in concreteStream { + continuation.yield(AnySendableHashable(value)) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + } + + public static func == (lhs: ExternalStreamSource, rhs: ExternalStreamSource) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/SessionUtilitiesKit/Observations/ObservationBuilder.swift b/SessionUtilitiesKit/Observations/ObservationBuilder.swift index 77145b6683..b07ab3c354 100644 --- a/SessionUtilitiesKit/Observations/ObservationBuilder.swift +++ b/SessionUtilitiesKit/Observations/ObservationBuilder.swift @@ -7,6 +7,14 @@ import Combine public protocol ObservableKeyProvider: Sendable, Equatable { var observedKeys: Set { get } + + func observedKeys(using dependencies: Dependencies) -> Set +} + +public extension ObservableKeyProvider { + func observedKeys(using dependencies: Dependencies) -> Set { + return observedKeys + } } // MARK: - ObservationBuilder DSL @@ -229,7 +237,7 @@ private actor QueryRunner { /// Capture the updated data and new keys to observe let newResult: Output = await self.query(previousValueForQuery, eventsToProcess, isInitialQuery, dependencies) - let newKeys: Set = newResult.observedKeys + let newKeys: Set = newResult.observedKeys(using: dependencies) /// If the keys have changed then we need to restart the observation if newKeys != activeKeys { @@ -242,7 +250,7 @@ private actor QueryRunner { oldListenerTask?.cancel() } - /// Only yielf the new result if the value has changed to prevent redundant updates + /// Only yield the new result if the value has changed to prevent redundant updates if isInitialQuery || newResult != self.lastValue { self.lastValue = newResult continuation.yield(newResult) @@ -264,15 +272,28 @@ private actor QueryRunner { guard let self = self else { return } do { - let stream = await self.observationManager.observe(key) - - for await event in stream { - try Task.checkCancellation() + if let source = key.streamSource { + if let stream = await source.makeStream() { + for await value in stream { + try Task.checkCancellation() + + let event = ObservedEvent(key: key, value: value) + await self.debouncer.signal(event: event) + } + } + } + else { + let stream = await self.observationManager.observe(key) - switch event.priority { - case .standard: await self.debouncer.signal(event: event.event) - case .immediate: await self.debouncer.flush(event: event.event) + for await event in stream { + try Task.checkCancellation() + + switch event.priority { + case .standard: await self.debouncer.signal(event: event.event) + case .immediate: await self.debouncer.flush(event: event.event) + } } + } } catch { diff --git a/SessionUtilitiesKit/Observations/ObservationManager.swift b/SessionUtilitiesKit/Observations/ObservationManager.swift index e0b8388818..99d4e0cc43 100644 --- a/SessionUtilitiesKit/Observations/ObservationManager.swift +++ b/SessionUtilitiesKit/Observations/ObservationManager.swift @@ -100,14 +100,38 @@ public extension ObservationManager { // MARK: - Convenience public extension Dependencies { + func notify( + priority: ObservationManager.Priority = .standard, + events: [ObservedEvent?] + ) async { + guard let events: [ObservedEvent] = events.compactMap({ $0 }).nullIfEmpty else { return } + + await self[singleton: .observationManager].notify(priority: priority, events: events) + } + + func notify( + priority: ObservationManager.Priority = .standard, + key: ObservableKey?, + value: T? + ) async { + guard let event: ObservedEvent = key.map({ ObservedEvent(key: $0, value: value) }) else { return } + + await notify(priority: priority, events: [event]) + } + + func notify( + priority: ObservationManager.Priority = .standard, + key: ObservableKey + ) async { + await notify(priority: priority, events: [ObservedEvent(key: key, value: nil)]) + } + @discardableResult func notifyAsync( priority: ObservationManager.Priority = .standard, events: [ObservedEvent?] ) -> Task { - guard let events: [ObservedEvent] = events.compactMap({ $0 }).nullIfEmpty else { return Task {} } - - return Task(priority: priority.taskPriority) { [observationManager = self[singleton: .observationManager]] in - await observationManager.notify(priority: priority, events: events) + return Task(priority: priority.taskPriority) { [weak self] in + await self?.notify(priority: priority, events: events) } } @@ -116,9 +140,7 @@ public extension Dependencies { key: ObservableKey?, value: T? ) -> Task { - guard let event: ObservedEvent = key.map({ ObservedEvent(key: $0, value: value) }) else { return Task {} } - - return notifyAsync(priority: priority, events: [event]) + return notifyAsync(priority: priority, events: [key.map { ObservedEvent(key: $0, value: value) }]) } @discardableResult func notifyAsync( diff --git a/SessionUtilitiesKit/Observations/ObservationUtilities.swift b/SessionUtilitiesKit/Observations/ObservationUtilities.swift new file mode 100644 index 0000000000..8abaa8f6b2 --- /dev/null +++ b/SessionUtilitiesKit/Observations/ObservationUtilities.swift @@ -0,0 +1,167 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct EventHandlingStrategy: OptionSet, Hashable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let none: EventHandlingStrategy = [] + public static let databaseQuery: EventHandlingStrategy = EventHandlingStrategy(rawValue: 1 << 0) + public static let libSessionQuery: EventHandlingStrategy = EventHandlingStrategy(rawValue: 1 << 1) + public static let directCacheUpdate: EventHandlingStrategy = EventHandlingStrategy(rawValue: 1 << 2) +} + +public struct EventChangeset { + public static let empty: EventChangeset = EventChangeset(eventsByKey: [:], eventsByStrategy: [:]) + + private let eventsByKey: [GenericObservableKey: [ObservedEvent]] + private let eventsByStrategy: [EventHandlingStrategy: Set] + + fileprivate init( + eventsByKey: [GenericObservableKey: [ObservedEvent]], + eventsByStrategy: [EventHandlingStrategy: Set] + ) { + self.eventsByKey = eventsByKey + self.eventsByStrategy = eventsByStrategy + } + + // MARK: - Generic Event Accessors + + public func events(matching strategy: EventHandlingStrategy) -> Set { + var result: Set = [] + + eventsByStrategy.forEach { key, events in + if key.contains(strategy) { + result.formUnion(events) + } + } + + return result + } + + public var databaseEvents: Set { + return events(matching: .databaseQuery) + } + + public var libSessionEvents: Set { + return events(matching: .libSessionQuery) + } + + /// Checks if any event matches the generic key + public func containsGeneric(_ key: GenericObservableKey) -> Bool { + return containsAnyGeneric(key) + } + + public func containsAnyGeneric(_ keys: GenericObservableKey...) -> Bool { + return !Set(eventsByKey.keys).isDisjoint(with: Set(keys)) + } + + /// Returns the most recent value for a specific key, cast to T + public func latestGeneric(_ key: GenericObservableKey, as type: T.Type = T.self) -> T? { + return eventsByKey[key]?.last?.value as? T /// The `last` event should be the newest + } + + /// Returns the most recent value for a specific key, cast to T that matches the condition + public func latestGeneric(_ key: GenericObservableKey, as type: T.Type = T.self, where condition: (T) -> Bool) -> T? { + return eventsByKey[key]? + .reversed() /// The `last` event should be the newest so iterate backwards + .first(where: { + guard let value: T = $0.value as? T else { return false } + + return condition(value) + }) as? T + } + + /// Iterates over all events matching the key, casting them to T + public func forEach( + _ key: GenericObservableKey, + as type: T.Type = T.self, + _ body: (T) -> Void + ) { + eventsByKey[key]?.forEach { event in + if let value = event.value as? T { + body(value) + } + } + } + + /// Iterates over events matching the key, providing the full event (useful if you need the specific key ID) + public func forEachEvent( + _ key: GenericObservableKey, + as valueType: T.Type = T.self, + _ body: (ObservedEvent, T) -> Void + ) { + eventsByKey[key]?.forEach { event in + if let value = event.value as? T { + body(event, value) + } + } + } + + // MARK: - Explicit Event Accessors + + /// Checks if any event matches the generic key + public func contains(_ key: ObservableKey) -> Bool { + return containsAny(key) + } + + public func containsAny(_ keys: ObservableKey...) -> Bool { + return keys.contains { key in + eventsByKey[key.generic]?.first(where: { $0.key == key }) != nil + } + } + + /// Returns the most recent value for a specific key, cast to T + public func latest(_ key: ObservableKey, as type: T.Type = T.self) -> T? { + return eventsByKey[key.generic]? + .reversed() /// The `last` event should be the newest + .first(where: { $0.key == key })? + .value as? T + } + + /// Returns the most recent value for a specific key, cast to T that matches the condition + public func latest(_ key: ObservableKey, as type: T.Type = T.self, where condition: (T) -> Bool) -> T? { + return eventsByKey[key.generic]? + .reversed() /// The `last` event should be the newest so iterate backwards + .first(where: { + guard + $0.key == key, + let value: T = $0.value as? T + else { return false } + + return condition(value) + }) as? T + } +} + +public extension Collection where Element == ObservedEvent { + func split() -> EventChangeset { + var allEvents: [GenericObservableKey: [ObservedEvent]] = [:] + + for event in self { + allEvents[event.key.generic, default: []].append(event) + } + + return EventChangeset(eventsByKey: allEvents, eventsByStrategy: [:]) + } + + func split( + by classifier: (ObservedEvent) -> EventHandlingStrategy + ) -> EventChangeset { + var allEvents: [GenericObservableKey: [ObservedEvent]] = [:] + var eventsByStrategy: [EventHandlingStrategy: Set] = [:] + + for event in self { + allEvents[event.key.generic, default: []].append(event) + + let strategy: EventHandlingStrategy = classifier(event) + eventsByStrategy[strategy, default: []].insert(event) + } + + return EventChangeset(eventsByKey: allEvents, eventsByStrategy: eventsByStrategy) + } +} diff --git a/SessionUtilitiesKit/Types/AnyCodable.swift b/SessionUtilitiesKit/Types/AnyCodable.swift new file mode 100644 index 0000000000..15615aa56f --- /dev/null +++ b/SessionUtilitiesKit/Types/AnyCodable.swift @@ -0,0 +1,62 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct AnyCodable: Codable { + public let value: Any + + public init(_ value: Any) { + self.value = value + } + + public init(from decoder: Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + + if let bool = try? container.decode(Bool.self) { + value = bool + } + else if let int = try? container.decode(Int.self) { + value = int + } + else if let double = try? container.decode(Double.self) { + value = double + } + else if let string = try? container.decode(String.self) { + value = string + } + else if let array = try? container.decode([AnyCodable].self) { + value = array.map(\.value) + } + else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict.mapValues(\.value) + } + else if container.decodeNil() { + value = NSNull() + } + else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported JSON type" // stringlint:disable + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container: SingleValueEncodingContainer = encoder.singleValueContainer() + + switch value { + case let bool as Bool: try container.encode(bool) + case let int as Int: try container.encode(int) + case let double as Double: try container.encode(double) + case let string as String: try container.encode(string) + case let array as [Any]: try container.encode(array.map { AnyCodable($0) }) + case let dict as [String: Any]: try container.encode(dict.mapValues { AnyCodable($0) }) + case is NSNull: try container.encodeNil() + default: + throw EncodingError.invalidValue( + value, + EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type") + ) + } + } +} diff --git a/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift b/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift index b2f9f13e1a..0be805aa3a 100644 --- a/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift +++ b/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift @@ -6,7 +6,7 @@ public actor CurrentValueAsyncStream: CancellationAwareStream private let lifecycleManager: StreamLifecycleManager = StreamLifecycleManager() /// This is the most recently emitted value - public private(set) var currentValue: Element + private var currentValue: Element // MARK: - Initialization @@ -15,7 +15,11 @@ public actor CurrentValueAsyncStream: CancellationAwareStream } // MARK: - Functions - + + public func getCurrent() async -> Element { + return currentValue + } + public func send(_ newValue: Element) async { currentValue = newValue lifecycleManager.send(newValue) diff --git a/SessionUtilitiesKit/Types/FileManager.swift b/SessionUtilitiesKit/Types/FileManager.swift index 2e97d01c5b..386364664e 100644 --- a/SessionUtilitiesKit/Types/FileManager.swift +++ b/SessionUtilitiesKit/Types/FileManager.swift @@ -15,7 +15,7 @@ public extension Singleton { // MARK: - FileManagerType -public protocol FileManagerType { +public protocol FileManagerType: Sendable { var temporaryDirectory: String { get } var documentsDirectoryPath: String { get } var appSharedDataDirectoryPath: String { get } @@ -142,20 +142,19 @@ public extension SessionFileManager { // MARK: - SessionFileManager -public class SessionFileManager: FileManagerType { +public final class SessionFileManager: FileManagerType { private static let temporaryDirectoryPrefix: String = "sesh_temp_" private let dependencies: Dependencies - private let fileManager: FileManager = .default - public var temporaryDirectory: String + public let temporaryDirectory: String public var documentsDirectoryPath: String { - return (fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path) + return (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.path) .defaulting(to: "") } public var appSharedDataDirectoryPath: String { - return (fileManager.containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.applicationGroup)?.path) + return (FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.applicationGroup)?.path) .defaulting(to: "") } @@ -184,7 +183,7 @@ public class SessionFileManager: FileManagerType { public func clearOldTemporaryDirectories() { /// We use the lowest priority queue for this, and wait N seconds to avoid interfering with app startup - DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + .seconds(3), using: dependencies) { [temporaryDirectory, fileManager, dependencies] in + DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + .seconds(3), using: dependencies) { [temporaryDirectory, dependencies] in /// Abort if app not active guard dependencies[singleton: .appContext].isAppForegroundAndActive else { return } @@ -193,7 +192,7 @@ public class SessionFileManager: FileManagerType { let currentTempDirName: String = URL(fileURLWithPath: temporaryDirectory).lastPathComponent let dirPath: String = NSTemporaryDirectory() - guard let fileNames: [String] = try? fileManager.contentsOfDirectory(atPath: dirPath) else { + guard let fileNames: [String] = try? FileManager.default.contentsOfDirectory(atPath: dirPath) else { return } @@ -210,14 +209,14 @@ public class SessionFileManager: FileManagerType { /// It's fine if we can't get the attributes (the file may have been deleted since we found it), also don't delete /// files which were created in the last N minutes guard - let attributes: [FileAttributeKey: Any] = try? fileManager.attributesOfItem(atPath: filePath), + let attributes: [FileAttributeKey: Any] = try? FileManager.default.attributesOfItem(atPath: filePath), let modificationDate: Date = attributes[.modificationDate] as? Date, modificationDate.timeIntervalSince1970 <= thresholdDate.timeIntervalSince1970 else { return } } /// This can happen if the app launches before the phone is unlocked, clean up will occur when app becomes active - try? fileManager.removeItem(atPath: filePath) + try? FileManager.default.removeItem(atPath: filePath) } } } @@ -225,8 +224,8 @@ public class SessionFileManager: FileManagerType { public func ensureDirectoryExists(at path: String, fileProtectionType: FileProtectionType) throws { var isDirectory: ObjCBool = false - if !fileManager.fileExists(atPath: path, isDirectory: &isDirectory) { - try fileManager.createDirectory( + if !FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) { + try FileManager.default.createDirectory( atPath: path, withIntermediateDirectories: true, attributes: nil @@ -237,9 +236,9 @@ public class SessionFileManager: FileManagerType { } public func protectFileOrFolder(at path: String, fileProtectionType: FileProtectionType) throws { - guard fileManager.fileExists(atPath: path) else { return } + guard FileManager.default.fileExists(atPath: path) else { return } - try fileManager.setAttributes( + try FileManager.default.setAttributes( [.protectionKey: fileProtectionType], ofItemAtPath: path ) @@ -251,7 +250,7 @@ public class SessionFileManager: FileManagerType { } public func fileSize(of path: String) -> UInt64? { - guard let attributes: [FileAttributeKey: Any] = try? fileManager.attributesOfItem(atPath: path) else { + guard let attributes: [FileAttributeKey: Any] = try? FileManager.default.attributesOfItem(atPath: path) else { return nil } @@ -294,11 +293,11 @@ public class SessionFileManager: FileManagerType { // MARK: - Forwarded NSFileManager - public var currentDirectoryPath: String { fileManager.currentDirectoryPath } + public var currentDirectoryPath: String { FileManager.default.currentDirectoryPath } public func urls(for directory: FileManager.SearchPathDirectory, in domains: FileManager.SearchPathDomainMask) -> [URL] { - return fileManager.urls(for: directory, in: domains) + return FileManager.default.urls(for: directory, in: domains) } public func enumerator( @@ -307,7 +306,7 @@ public class SessionFileManager: FileManagerType { options: FileManager.DirectoryEnumerationOptions, errorHandler: ((URL, Error) -> Bool)? ) -> FileManager.DirectoryEnumerator? { - return fileManager.enumerator( + return FileManager.default.enumerator( at: url, includingPropertiesForKeys: includingPropertiesForKeys, options: options, @@ -316,15 +315,15 @@ public class SessionFileManager: FileManagerType { } public func fileExists(atPath: String) -> Bool { - return fileManager.fileExists(atPath: atPath) + return FileManager.default.fileExists(atPath: atPath) } public func fileExists(atPath: String, isDirectory: UnsafeMutablePointer?) -> Bool { - return fileManager.fileExists(atPath: atPath, isDirectory: isDirectory) + return FileManager.default.fileExists(atPath: atPath, isDirectory: isDirectory) } public func contents(atPath: String) -> Data? { - return fileManager.contents(atPath: atPath) + return FileManager.default.contents(atPath: atPath) } public func contents(atPath: String) throws -> Data { @@ -332,16 +331,16 @@ public class SessionFileManager: FileManagerType { } public func contentsOfDirectory(at url: URL) throws -> [URL] { - return try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) + return try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) } public func contentsOfDirectory(atPath path: String) throws -> [String] { - return try fileManager.contentsOfDirectory(atPath: path) + return try FileManager.default.contentsOfDirectory(atPath: path) } public func isDirectoryEmpty(at url: URL) -> Bool { guard - let enumerator = fileManager.enumerator( + let enumerator = FileManager.default.enumerator( at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] @@ -356,11 +355,11 @@ public class SessionFileManager: FileManagerType { } public func createFile(atPath: String, contents: Data?, attributes: [FileAttributeKey: Any]?) -> Bool { - return fileManager.createFile(atPath: atPath, contents: contents, attributes: attributes) + return FileManager.default.createFile(atPath: atPath, contents: contents, attributes: attributes) } public func createDirectory(at url: URL, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]?) throws { - return try fileManager.createDirectory( + return try FileManager.default.createDirectory( at: url, withIntermediateDirectories: withIntermediateDirectories, attributes: attributes @@ -368,7 +367,7 @@ public class SessionFileManager: FileManagerType { } public func createDirectory(atPath: String, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]?) throws { - return try fileManager.createDirectory( + return try FileManager.default.createDirectory( atPath: atPath, withIntermediateDirectories: withIntermediateDirectories, attributes: attributes @@ -376,23 +375,23 @@ public class SessionFileManager: FileManagerType { } public func copyItem(atPath: String, toPath: String) throws { - return try fileManager.copyItem(atPath: atPath, toPath: toPath) + return try FileManager.default.copyItem(atPath: atPath, toPath: toPath) } public func copyItem(at fromUrl: URL, to toUrl: URL) throws { - return try fileManager.copyItem(at: fromUrl, to: toUrl) + return try FileManager.default.copyItem(at: fromUrl, to: toUrl) } public func moveItem(atPath: String, toPath: String) throws { - try fileManager.moveItem(atPath: atPath, toPath: toPath) + try FileManager.default.moveItem(atPath: atPath, toPath: toPath) } public func moveItem(at fromUrl: URL, to toUrl: URL) throws { - try fileManager.moveItem(at: fromUrl, to: toUrl) + try FileManager.default.moveItem(at: fromUrl, to: toUrl) } public func replaceItem(atPath originalItemPath: String, withItemAtPath newItemPath: String, backupItemName: String?, options: FileManager.ItemReplacementOptions) throws -> String? { - return try fileManager.replaceItemAt( + return try FileManager.default.replaceItemAt( URL(fileURLWithPath: originalItemPath), withItemAt: URL(fileURLWithPath: newItemPath), backupItemName: backupItemName, @@ -401,18 +400,18 @@ public class SessionFileManager: FileManagerType { } public func replaceItemAt(_ originalItemURL: URL, withItemAt newItemURL: URL, backupItemName: String?, options: FileManager.ItemReplacementOptions) throws -> URL? { - return try fileManager.replaceItemAt(originalItemURL, withItemAt: newItemURL, backupItemName: backupItemName, options: options) + return try FileManager.default.replaceItemAt(originalItemURL, withItemAt: newItemURL, backupItemName: backupItemName, options: options) } public func removeItem(atPath: String) throws { - return try fileManager.removeItem(atPath: atPath) + return try FileManager.default.removeItem(atPath: atPath) } public func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { - return try fileManager.attributesOfItem(atPath: path) + return try FileManager.default.attributesOfItem(atPath: path) } public func setAttributes(_ attributes: [FileAttributeKey: Any], ofItemAtPath path: String) throws { - return try fileManager.setAttributes(attributes, ofItemAtPath: path) + return try FileManager.default.setAttributes(attributes, ofItemAtPath: path) } } diff --git a/SessionUtilitiesKit/Types/SessionProManagerType.swift b/SessionUtilitiesKit/Types/SessionProManagerType.swift deleted file mode 100644 index 3c6ead80c5..0000000000 --- a/SessionUtilitiesKit/Types/SessionProManagerType.swift +++ /dev/null @@ -1,237 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine - -public protocol SessionProManagerType: AnyObject { - var sessionProStateSubject: CurrentValueSubject { get } - var sessionProStatePublisher: AnyPublisher { get } - var isSessionProActivePublisher: AnyPublisher { get } - var isSessionProExpired: Bool { get } - var sessionProPlans: [SessionProPlan] { get } - func upgradeToPro(plan: SessionProPlan, originatingPlatform: ClientPlatform, completion: ((_ result: Bool) -> Void)?) async - func cancelPro(completion: ((_ result: Bool) -> Void)?) async - func requestRefund(completion: ((_ result: Bool) -> Void)?) async - func expirePro(completion: ((_ result: Bool) -> Void)?) async - func recoverPro(completion: ((_ result: Bool) -> Void)?) async - // These functions are only for QA purpose - func updateOriginatingPlatform(_ newValue: ClientPlatform) - func updateProExpiry(_ expiryInSeconds: TimeInterval?) -} - -public enum SessionProPlanState: Equatable, Sendable { - case none - case active( - currentPlan: SessionProPlan, - expiredOn: Date, - isAutoRenewing: Bool, - originatingPlatform: ClientPlatform - ) - case expired( - expiredOn: Date, - originatingPlatform: ClientPlatform - ) - case refunding( - originatingPlatform: ClientPlatform, - requestedAt: Date? - ) - - public var originatingPlatform: ClientPlatform { - return switch(self) { - case .active(_, _, _, let originatingPlatform): originatingPlatform - case .expired(_, let originatingPlatform): originatingPlatform - case .refunding(let originatingPlatform, _): originatingPlatform - default: .iOS // FIXME: get the real originating platform - } - } - - public func with(originatingPlatform: ClientPlatform) -> SessionProPlanState { - switch self { - case .active(let plan, let expiredOn, let isAutoRenewing, _): - return .active( - currentPlan: plan, - expiredOn: expiredOn, - isAutoRenewing: isAutoRenewing, - originatingPlatform: originatingPlatform - ) - case .refunding(_, let requestedAt): - return .refunding( - originatingPlatform: originatingPlatform, - requestedAt: requestedAt - ) - case .expired(let expiredOn, _): - return .expired( - expiredOn: expiredOn, - originatingPlatform: originatingPlatform - ) - default: return self - } - } -} - -public struct SessionProPlan: Equatable, Sendable { - public enum Variant: Sendable { - case oneMonth, threeMonths, twelveMonths - - public static var allCases: [Variant] { [.twelveMonths, .threeMonths, .oneMonth] } - - public var duration: Int { - switch self { - case .oneMonth: return 1 - case .threeMonths: return 3 - case .twelveMonths: return 12 - } - } - - // MARK: - Mock - public var price: Double { - switch self { - case .oneMonth: return 5.99 - case .threeMonths: return 14.99 - case .twelveMonths: return 47.99 - } - } - - public var discountPercent: Int? { - switch self { - case .oneMonth: return nil - case .threeMonths: return 16 - case .twelveMonths: return 33 - } - } - } - - public let variant: Variant - - public init(variant: Variant) { - self.variant = variant - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.variant == rhs.variant - } -} - -// TODO: [PRO] Move these strings - -public enum ClientPlatform: String, Sendable { - case iOS - case Android - - public var store: String { - switch self { - case .iOS: return "Apple App" - case .Android: return "Google Play" - } - } - - public var account: String { - switch self { - case .iOS: return "Apple Account" - case .Android: return "Google Account" - } - } - - public var deviceType: String { - switch self { - case .iOS: return "iOS" - case .Android: return "Android" - } - } - - public var name: String { - switch self { - case .iOS: return "Apple" - case .Android: return "Google" - } - } -} - -// MARK: - Developer Settings - -public enum SessionProStateMock: String, Sendable, Codable, CaseIterable, FeatureOption { - case none - case active - case expiring - case expired - case refunding - - public static var defaultOption: SessionProStateMock = .none - - // stringlint:ignore_contents - public var title: String { - switch self { - case .none: return "None" - case .active: return "Active" - case .expiring: return "Expiring" - case .expired: return "Expired" - case .refunding: return "Refunding" - } - } - - // stringlint:ignore_contents - public var subtitle: String? { - switch self { - case .expiring: return "Active, no auto-renewing" - default: return nil - } - } -} - -public enum SessionProStateExpiryMock: String, Sendable, Codable, CaseIterable, FeatureOption { - case none - case twentyFourDaysPlusFiveMinute - case twentyFourHoursPlusFiveMinute - case twentyFourHoursMinusOneMinute - case tenSeconds - - public static var defaultOption: SessionProStateExpiryMock = .none - - // stringlint:ignore_contents - public var title: String { - switch self { - case .none: return "None" - case .twentyFourDaysPlusFiveMinute: return "24d+5m" - case .twentyFourHoursPlusFiveMinute: return "24h+5m" - case .twentyFourHoursMinusOneMinute: return "23h59m" - case .tenSeconds: return "10s" - } - } - - public var subtitle: String? { return nil } - - public var durationInSeconds: TimeInterval? { - switch self { - case .none: return nil - case .twentyFourDaysPlusFiveMinute: return 24 * 24 * 60 * 60 + 5 * 60 - case .twentyFourHoursPlusFiveMinute: return 24 * 60 * 60 + 5 * 60 - case .twentyFourHoursMinusOneMinute: return 24 * 60 * 60 - 60 - case .tenSeconds: return 10 - } - } -} - -public enum SessionProLoadingState: String, Sendable, Codable, CaseIterable, FeatureOption { - case loading - case error - case success - - public static var defaultOption: SessionProLoadingState = .success - - // stringlint:ignore_contents - public var title: String { - switch self { - case .loading: return "Loading" - case .error: return "Error" - case .success: return "Success" - } - } - - public var subtitle: String? { return nil } -} - -extension ClientPlatform: FeatureOption { - public static var defaultOption: ClientPlatform = .iOS - public var title: String { deviceType } - public var subtitle: String? { return nil } -} diff --git a/SessionUtilitiesKit/Utilities/ArraySection+Utilities.swift b/SessionUtilitiesKit/Utilities/ArraySection+Utilities.swift new file mode 100644 index 0000000000..e699ed53ab --- /dev/null +++ b/SessionUtilitiesKit/Utilities/ArraySection+Utilities.swift @@ -0,0 +1,17 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import DifferenceKit + +public extension ArraySection { + func appending(_ element: Element) -> ArraySection { + return appending(contentsOf: [element]) + } + + func appending(contentsOf elements: [Element]) -> ArraySection { + return ArraySection( + model: model, + elements: self.elements + elements + ) + } +} diff --git a/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift b/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift index 3992824965..69ca0d77ff 100644 --- a/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift @@ -1,5 +1,51 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +import Foundation + +public extension AsyncSequence { + func asAsyncStream() -> AsyncStream { + AsyncStream { continuation in + let task: Task = Task { + for try await element in self { + continuation.yield(element) + } + + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + /// Returns a new async sequence that emits the given initial element before emitting the elements from the upstream sequence + func prepend(_ initialElement: Element?) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + if let initialElement { + continuation.yield(initialElement) + } + + let observationTask: Task = Task { + do { + for try await element in self { + continuation.yield(element) + } + } + catch { + continuation.finish(throwing: error) + } + + continuation.finish() + } + + continuation.onTermination = { @Sendable _ in + observationTask.cancel() + } + } + } +} + public extension AsyncSequence where Element: Equatable { func removeDuplicates() -> AsyncThrowingStream { return AsyncThrowingStream { continuation in diff --git a/SessionUtilitiesKit/Utilities/OptionSet+Utilities.swift b/SessionUtilitiesKit/Utilities/OptionSet+Utilities.swift new file mode 100644 index 0000000000..42c0a020c1 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/OptionSet+Utilities.swift @@ -0,0 +1,19 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension OptionSet { + func inserting(_ other: Element?) -> Self { + guard let other: Element = other else { return self } + + var result: Self = self + result.insert(other) + return result + } + + func removing(_ other: Element) -> Self { + var result: Self = self + result.remove(other) + return result + } +} diff --git a/SessionUtilitiesKit/Utilities/Version.swift b/SessionUtilitiesKit/Utilities/Version.swift index 0ef50ac78a..cd1adc1ac2 100644 --- a/SessionUtilitiesKit/Utilities/Version.swift +++ b/SessionUtilitiesKit/Utilities/Version.swift @@ -57,7 +57,7 @@ public struct Version: Comparable { } } -public enum FeatureVersion: Int, Codable, Equatable, Hashable, DatabaseValueConvertible { +public enum FeatureVersion: Int, Sendable, Codable, Equatable, Hashable, DatabaseValueConvertible { case legacyDisappearingMessages case newDisappearingMessages } diff --git a/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift b/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift index 4eaa6a0480..426d368bc3 100644 --- a/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift +++ b/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift @@ -15,7 +15,7 @@ class GeneralCacheSpec: QuickSpec { @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( initialSetup: { crypto in crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), @@ -57,7 +57,7 @@ class GeneralCacheSpec: QuickSpec { // MARK: -- remains invalid when given a seckey that is too short it("remains invalid when given a seckey that is too short") { - mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(nil) + mockCrypto.when { $0.generate(.ed25519KeyPair(seed: Array.any)) }.thenReturn(nil) let cache: General.Cache = General.Cache(using: dependencies) cache.setSecretKey(ed25519SecretKey: [1, 2, 3]) @@ -68,7 +68,7 @@ class GeneralCacheSpec: QuickSpec { // MARK: -- remains invalid when ed key pair generation fails it("remains invalid when ed key pair generation fails") { - mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(nil) + mockCrypto.when { $0.generate(.ed25519KeyPair(seed: Array.any)) }.thenReturn(nil) let cache: General.Cache = General.Cache(using: dependencies) cache.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) @@ -79,7 +79,7 @@ class GeneralCacheSpec: QuickSpec { // MARK: -- remains invalid when x25519 pubkey generation fails it("remains invalid when x25519 pubkey generation fails") { - mockCrypto.when { $0.generate(.x25519(ed25519Pubkey: .any)) }.thenReturn(nil) + mockCrypto.when { $0.generate(.x25519(ed25519Pubkey: Array.any)) }.thenReturn(nil) let cache: General.Cache = General.Cache(using: dependencies) cache.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index bee98fe1be..6b17d94076 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -73,9 +73,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC private let initialMessageText: String private var quoteViewModel: QuoteViewModel? private let onQuoteCancelled: (() -> Void)? - private var isSessionPro: Bool { - dependencies[cache: .libSession].isSessionPro - } var isKeyboardVisible: Bool = false private let disableLinkPreviewImageDownload: Bool @@ -227,13 +224,9 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC private lazy var snInputView: InputView = { let result: InputView = InputView( delegate: self, - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: threadVariant, - using: dependencies - ), imageDataManager: dependencies[singleton: .imageDataManager], linkPreviewManager: dependencies[singleton: .linkPreviewManager], - sessionProStatePublisher: dependencies[singleton: .sessionProState].isSessionProActivePublisher, + sessionProManager: dependencies[singleton: .sessionProManager], onQuoteCancelled: onQuoteCancelled, didLoadLinkPreview: { [weak self] result in self?.didLoadLinkPreview?(result) @@ -700,11 +693,12 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC self.approvalDelegate?.attachmentApprovalDidCancel(self) } - @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { - guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .longerMessages(renew: dependencies[singleton: .sessionProState].isSessionProExpired), - onConfirm: { [weak self, dependencies] in - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + @MainActor func showModalForMessagesExceedingCharacterLimit() { + let manager: SessionProManagerType = dependencies[singleton: .sessionProManager] + let didShowCTAModal: Bool = manager.showSessionProCTAIfNeeded( + .longerMessages(renew: (manager.currentUserCurrentProState.status == .expired)), + onConfirm: { [weak self, manager] in + manager.showSessionProBottomSheetIfNeeded( afterClosed: { self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") }, @@ -719,16 +713,16 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC presenting: { [weak self] modal in self?.present(modal, animated: true) } - ) else { - return - } + ) + + guard didShowCTAModal else { return } let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "modalMessageCharacterTooLongTitle".localized(), body: .text( "modalMessageTooLongDescription" - .put(key: "limit", value: (isSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit)) + .put(key: "limit", value: dependencies[singleton: .sessionProManager].characterLimit) .localized(), scrollMode: .never ), @@ -755,10 +749,11 @@ extension AttachmentApprovalViewController: InputViewDelegate { public func cancelVoiceMessageRecording() {} public func handleCharacterLimitLabelTapped() { - guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .longerMessages(renew: dependencies[singleton: .sessionProState].isSessionProExpired), - onConfirm: { [weak self, dependencies] in - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + let manager: SessionProManagerType = dependencies[singleton: .sessionProManager] + let didShowCTAModal: Bool = manager.showSessionProCTAIfNeeded( + .longerMessages(renew: (manager.currentUserCurrentProState.status == .expired)), + onConfirm: { [weak self, manager] in + manager.showSessionProBottomSheetIfNeeded( afterClosed: { self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") }, @@ -773,16 +768,16 @@ extension AttachmentApprovalViewController: InputViewDelegate { presenting: { [weak self] modal in self?.present(modal, animated: true) } - ) else { - return - } + ) + + guard didShowCTAModal else { return } let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "modalMessageCharacterTooLongTitle".localized(), body: .text( "modalMessageTooLongDescription" - .put(key: "limit", value: (isSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit)) + .put(key: "limit", value: dependencies[singleton: .sessionProManager].characterLimit) .localized(), scrollMode: .never ), @@ -795,12 +790,11 @@ extension AttachmentApprovalViewController: InputViewDelegate { public func handleSendButtonTapped() { guard - LibSession.numberOfCharactersLeft( - for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: isSessionPro + dependencies[singleton: .sessionProManager].numberOfCharactersLeft( + for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines) ) >= 0 else { - showModalForMessagesExceedingCharacterLimit(isSessionPro: isSessionPro) + showModalForMessagesExceedingCharacterLimit() return } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift deleted file mode 100644 index 8ae5090e97..0000000000 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. - -import Foundation -import UIKit -import SessionUIKit -import SessionUtilitiesKit -import Combine - -// Coincides with Android's max text message length -let kMaxMessageBodyCharacterCount = 2000 - -protocol AttachmentTextToolbarDelegate: AnyObject { - @MainActor func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar) - @MainActor func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) - @MainActor func attachmentTextToolBarDidTapCharacterLimitLabel(_ attachmentTextToolbar: AttachmentTextToolbar) -} - -// MARK: - - -class AttachmentTextToolbar: UIView, UITextViewDelegate { - - private static let thresholdForCharacterLimit: Int = 200 - - // MARK: - Variables - - private var disposables: Set = Set() - public weak var delegate: AttachmentTextToolbarDelegate? - private let dependencies: Dependencies - private var sessionProState: SessionProManagerType? - - var text: String? { - get { inputTextView.text } - set { inputTextView.text = newValue } - } - - // MARK: - UI - - private var bottomStackView: UIStackView? - - private lazy var sendButton: InputViewButton = { - let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self) - result.accessibilityIdentifier = "Send message button" - result.accessibilityLabel = "Send message button" - result.isAccessibilityElement = true - - return result - }() - - private lazy var inputTextView: InputTextView = { - // HACK: When restoring a draft the input text view won't have a frame yet, and therefore it won't - // be able to calculate what size it should be to accommodate the draft text. As a workaround, we - // just calculate the max width that the input text view is allowed to be and pass it in. See - // setUpViewHierarchy() for why these values are the way they are. - let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2 - let maxWidth = UIScreen.main.bounds.width - InputViewButton.expandedSize - Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment) - let result = InputTextView(delegate: self, maxWidth: maxWidth) - result.accessibilityLabel = "contentDescriptionMessageComposition".localized() - result.accessibilityIdentifier = "Message input box" - result.isAccessibilityElement = true - - return result - }() - - private lazy var proStackView: UIStackView = { - let result = UIStackView(arrangedSubviews: [ characterLimitLabel, sessionProBadge ]) - result.axis = .vertical - result.spacing = Values.verySmallSpacing - result.alignment = .center - result.addGestureRecognizer(characterLimitLabelTapGestureRecognizer) - result.alpha = 0 - - return result - }() - private lazy var characterLimitLabelTapGestureRecognizer: UITapGestureRecognizer = { - let result: UITapGestureRecognizer = UITapGestureRecognizer() - result.addTarget(self, action: #selector(characterLimitLabelTapped)) - result.isEnabled = false - - return result - }() - - private lazy var characterLimitLabel: UILabel = { - let label: UILabel = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .systemFont(ofSize: Values.smallFontSize) - label.themeTextColor = .textPrimary - label.textAlignment = .center - - return label - }() - - private lazy var sessionProBadge: SessionProBadge = { - let result: SessionProBadge = SessionProBadge(size: .medium) - result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro - - return result - }() - - // MARK: - Initializers - - init(delegate: AttachmentTextToolbarDelegate, using dependencies: Dependencies) { - self.dependencies = dependencies - self.delegate = delegate - self.sessionProState = dependencies[singleton: .sessionProState] - - super.init(frame: CGRect.zero) - - setUpViewHierarchy() - - self.sessionProState?.sessionProStatePublisher - .subscribe(on: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink( - receiveValue: { [weak self] sessionProPlanState in - let isPro: Bool = { - switch sessionProPlanState { - case .active, .refunding : return true - case .none, .expired: return false - } - }() - self?.sessionProBadge.isHidden = isPro - self?.updateNumberOfCharactersLeft((self?.inputTextView.text ?? "")) - } - ) - .store(in: &disposables) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setUpViewHierarchy() { - autoresizingMask = .flexibleHeight - - // Background & blur - let backgroundView = UIView() - backgroundView.themeBackgroundColor = .clear - addSubview(backgroundView) - backgroundView.pin(to: self) - - // Separator - let separator = UIView() - separator.themeBackgroundColor = .borderSeparator - separator.set(.height, to: Values.separatorThickness) - addSubview(separator) - separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self) - - // Bottom stack view - let bottomStackView = UIStackView(arrangedSubviews: [ inputTextView, InputViewButton.container(for: sendButton) ]) - bottomStackView.axis = .horizontal - bottomStackView.spacing = Values.smallSpacing - bottomStackView.alignment = .center - self.bottomStackView = bottomStackView - - // Main stack view - let mainStackView = UIStackView(arrangedSubviews: [ bottomStackView ]) - mainStackView.axis = .vertical - mainStackView.isLayoutMarginsRelativeArrangement = true - - let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2 - mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment) - addSubview(mainStackView) - mainStackView.pin(.top, to: .bottom, of: separator) - mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) - mainStackView.pin(.bottom, to: .bottom, of: self) - - // Pro stack view - addSubview(proStackView) - proStackView.pin(.bottom, to: .bottom, of: inputTextView) - proStackView.center(.horizontal, in: sendButton) - } - - func updateNumberOfCharactersLeft(_ text: String) { - let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( - for: text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: dependencies[cache: .libSession].isSessionPro - ) - characterLimitLabel.text = "\(numberOfCharactersLeft.formatted(format: .abbreviated(decimalPlaces: 1)))" - characterLimitLabel.themeTextColor = (numberOfCharactersLeft < 0) ? .danger : .textPrimary - proStackView.alpha = (numberOfCharactersLeft <= Self.thresholdForCharacterLimit) ? 1 : 0 - characterLimitLabelTapGestureRecognizer.isEnabled = (numberOfCharactersLeft < Self.thresholdForCharacterLimit) - } - - // MARK: - Action - - @objc private func characterLimitLabelTapped() { - delegate?.attachmentTextToolBarDidTapCharacterLimitLabel(self) - } -} - -extension AttachmentTextToolbar: InputViewButtonDelegate { - func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) { - delegate?.attachmentTextToolbarDidTapSend(self) - } - func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) {} - func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) {} - func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch?) {} -} - -extension AttachmentTextToolbar: InputTextViewDelegate { - @MainActor func inputTextViewDidChangeSize(_ inputTextView: InputTextView) { - invalidateIntrinsicContentSize() - self.bottomStackView?.alignment = (inputTextView.contentSize.height > inputTextView.minHeight) ? .top : .center - } - - @MainActor func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { - updateNumberOfCharactersLeft(text ?? "") - delegate?.attachmentTextToolbarDidChange(self) - } - - @MainActor func didPasteImageDataFromPasteboard(_ inputTextView: InputTextView, imageData: Data) {} -} diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index a79cf88c3f..72a4657363 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -37,7 +37,6 @@ public enum AppSetup { SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) SessionEnvironment.shared = SessionEnvironment( - audioSession: OWSAudioSession(), proximityMonitoringManager: OWSProximityMonitoringManagerImpl(using: dependencies), windowManager: OWSWindowManager(default: ()) ) @@ -115,6 +114,11 @@ public enum AppSetup { unreadCount: userInfo.unreadCount ) + // FIXME: The launch process should be made async/await and this called correctly + Task.detached(priority: .medium) { + await dependencies[singleton: .communityManager].loadCacheIfNeeded() + } + Task.detached(priority: .medium) { dependencies[singleton: .extensionHelper].replicateAllConfigDumpsIfNeeded( userSessionId: userInfo.sessionId, diff --git a/_SharedTestUtilities/Mocked.swift b/_SharedTestUtilities/Mocked.swift index 8458834245..cf09d793d1 100644 --- a/_SharedTestUtilities/Mocked.swift +++ b/_SharedTestUtilities/Mocked.swift @@ -30,6 +30,7 @@ extension Mocked { static var any: Self { mock } } extension Int: Mocked { static var mock: Int { 0 } } extension Int64: Mocked { static var mock: Int64 { 0 } } +extension UInt64: Mocked { static var mock: UInt64 { 0 } } extension Dictionary: Mocked { static var mock: Self { [:] } } extension Array: Mocked { static var mock: Self { [] } } extension Set: Mocked { static var mock: Self { [] } }