From 53a5db0ea517beb771b6bb4c0f4ae807e34a13fd Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 23 Jun 2023 17:54:29 +1000 Subject: [PATCH] Fixed a number of issues found during internal testing Added copy for an unrecoverable startup case Added some additional logs to better debug ValueObservation query errors Increased the pageSize to 20 on iPad devices (to prevent it immediately loading a second page) Cleaned up a bunch of threading logic (try to avoid overriding subscribe/receive threads specified at subscription) Consolidated the 'sendMessage' and 'sendAttachments' functions Updated the various frameworks to use 'DAWRF with DSYM' to allow for better debugging during debug mode (at the cost of a longer build time) Updated the logic to optimistically insert messages when sending to avoid any database write delays Updated the logic to avoid sending notifications for messages which are already marked as read by the config Fixed an issue where multiple paths could incorrectly get built at the same time in some cases Fixed an issue where other job queues could be started before the blockingQueue finishes Fixed a potential bug with the snode version comparison (was just a string comparison which would fail when getting to double-digit values) Fixed a bug where you couldn't remove the last reaction on a message Fixed the broken media message zoom animations Fixed a bug where the last message read in a conversation wouldn't be correctly detected as already read Fixed a bug where the QuoteView had no line limits (resulting in the '@You' mention background highlight being incorrectly positioned in the quote preview) Fixed a bug where a large number of configSyncJobs could be scheduled (only one would run at a time but this could result in performance impacts) --- LibSession-Util | 2 +- Session.xcodeproj/project.pbxproj | 48 +- .../Context Menu/ContextMenuVC+Action.swift | 5 +- .../ConversationVC+Interaction.swift | 646 ++++++++---------- Session/Conversations/ConversationVC.swift | 1 + .../Conversations/ConversationViewModel.swift | 182 ++++- .../Conversations/Input View/InputView.swift | 1 + .../Content Views/QuoteView.swift | 56 +- ...isappearingMessagesSettingsViewModel.swift | 1 + .../Settings/ThreadSettingsViewModel.swift | 1 + Session/Home/HomeViewModel.swift | 3 +- .../MessageRequestsViewModel.swift | 2 +- Session/Home/New Conversation/NewDMVC.swift | 1 + .../GIFs/GifPickerViewController.swift | 3 + .../GIFs/GiphyAPI.swift | 2 - .../MediaGalleryViewModel.swift | 3 +- .../PhotoCapture.swift | 10 +- .../MediaDismissAnimationController.swift | 26 + .../MediaZoomAnimationController.swift | 25 + Session/Meta/AppDelegate.swift | 89 ++- .../Translations/de.lproj/Localizable.strings | 1 + .../Translations/en.lproj/Localizable.strings | 1 + .../Translations/es.lproj/Localizable.strings | 1 + .../Translations/fa.lproj/Localizable.strings | 1 + .../Translations/fi.lproj/Localizable.strings | 1 + .../Translations/fr.lproj/Localizable.strings | 1 + .../Translations/hi.lproj/Localizable.strings | 1 + .../Translations/hr.lproj/Localizable.strings | 1 + .../id-ID.lproj/Localizable.strings | 1 + .../Translations/it.lproj/Localizable.strings | 1 + .../Translations/ja.lproj/Localizable.strings | 1 + .../Translations/nl.lproj/Localizable.strings | 1 + .../Translations/pl.lproj/Localizable.strings | 1 + .../pt_BR.lproj/Localizable.strings | 1 + .../Translations/ru.lproj/Localizable.strings | 1 + .../Translations/si.lproj/Localizable.strings | 1 + .../Translations/sk.lproj/Localizable.strings | 1 + .../Translations/sv.lproj/Localizable.strings | 1 + .../Translations/th.lproj/Localizable.strings | 1 + .../vi-VN.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../zh_CN.lproj/Localizable.strings | 1 + Session/Notifications/AppNotifications.swift | 2 - .../PushRegistrationManager.swift | 3 +- .../UserNotificationsAdaptee.swift | 2 + Session/Onboarding/DisplayNameVC.swift | 2 +- Session/Onboarding/Onboarding.swift | 7 +- Session/Onboarding/PNModeVC.swift | 1 + .../Open Groups/OpenGroupSuggestionGrid.swift | 5 +- .../ConversationSettingsViewModel.swift | 1 + Session/Settings/HelpViewModel.swift | 1 + .../NotificationContentViewModel.swift | 1 + .../NotificationSettingsViewModel.swift | 1 + .../Settings/NotificationSoundViewModel.swift | 1 + Session/Settings/NukeDataModal.swift | 3 +- .../Settings/PrivacySettingsViewModel.swift | 1 + Session/Settings/SettingsViewModel.swift | 3 +- .../Shared/SessionTableViewController.swift | 15 +- Session/Utilities/BackgroundPoller.swift | 54 +- SessionMessagingKit/Calls/WebRTCSession.swift | 2 + .../Database/Models/Attachment.swift | 98 +-- .../DisappearingMessageConfiguration.swift | 2 +- .../Database/Models/Interaction.swift | 14 +- .../Database/Models/LinkPreview.swift | 7 +- .../File Server/FileServerAPI.swift | 1 - .../Jobs/Types/AttachmentDownloadJob.swift | 29 +- .../Jobs/Types/ConfigurationSyncJob.swift | 30 +- .../Jobs/Types/GroupLeavingJob.swift | 1 + .../Jobs/Types/MessageSendJob.swift | 1 + .../Jobs/Types/SendReadReceiptsJob.swift | 1 + .../Open Groups/OpenGroupAPI.swift | 521 +++++++------- .../Open Groups/OpenGroupManager.swift | 128 ++-- .../Open Groups/Types/OpenGroupAPIError.swift | 2 + .../Open Groups/Types/PreparedSendData.swift | 125 ++++ SessionMessagingKit/SMKDependencies.swift | 7 +- .../MessageReceiver+Calls.swift | 55 +- ...eReceiver+DataExtractionNotification.swift | 15 +- .../MessageReceiver+ExpirationTimers.swift | 12 +- .../MessageReceiver+UnsendRequests.swift | 1 + .../MessageReceiver+VisibleMessages.swift | 23 +- .../MessageSender+Convenience.swift | 5 +- .../Sending & Receiving/MessageSender.swift | 19 +- .../Pollers/ClosedGroupPoller.swift | 8 +- .../Pollers/CurrentUserPoller.swift | 8 +- .../Pollers/OpenGroupPoller.swift | 42 +- .../Sending & Receiving/Pollers/Poller.swift | 54 +- .../SessionUtil+ConvoInfoVolatile.swift | 6 +- .../SessionUtil/SessionUtil.swift | 1 - .../Shared Models/MessageViewModel.swift | 102 ++- .../SessionThreadViewModel.swift | 19 +- .../Utilities/ProfileManager.swift | 21 +- SessionMessagingKit/Utilities/Threading.swift | 5 +- .../Open Groups/OpenGroupAPISpec.swift | 379 +++++----- .../Open Groups/OpenGroupManagerSpec.swift | 17 +- .../NotificationServiceExtension.swift | 24 +- SessionShareExtension/ThreadPickerVC.swift | 27 +- .../ThreadPickerViewModel.swift | 1 + SessionSnodeKit/Jobs/GetSnodePoolJob.swift | 2 +- SessionSnodeKit/Models/GetStatsResponse.swift | 16 + .../Networking/OnionRequestAPI.swift | 140 ++-- SessionSnodeKit/Networking/SnodeAPI.swift | 23 +- SessionSnodeKit/SSKDependencies.swift | 6 +- ...eadDisappearingMessagesViewModelSpec.swift | 10 +- .../ThreadSettingsViewModelSpec.swift | 8 +- .../NotificationContentViewModelSpec.swift | 6 +- .../HighlightMentionBackgroundView.swift | 2 +- .../Components/TopBannerController.swift | 18 +- .../Migrations/_001_ThemePreferences.swift | 10 +- SessionUIKit/Style Guide/ThemeManager.swift | 20 +- .../Combine/Publisher+Utilities.swift | 32 +- SessionUtilitiesKit/Database/Storage.swift | 19 +- .../Types/PagedDatabaseObserver.swift | 92 +-- .../General/Dependencies.swift | 20 +- SessionUtilitiesKit/JobRunner/JobRunner.swift | 33 +- SessionUtilitiesKit/Networking/Request.swift | 4 +- SessionUtilitiesKit/Utilities/Version.swift | 55 ++ .../Utilities/VersionSpec.swift | 98 +++ .../MediaMessageView.swift | 1 + _SharedTestUtilities/CombineExtensions.swift | 6 +- 119 files changed, 2290 insertions(+), 1376 deletions(-) create mode 100644 SessionMessagingKit/Open Groups/Types/PreparedSendData.swift create mode 100644 SessionSnodeKit/Models/GetStatsResponse.swift create mode 100644 SessionUtilitiesKit/Utilities/Version.swift create mode 100644 SessionUtilitiesKitTests/Utilities/VersionSpec.swift diff --git a/LibSession-Util b/LibSession-Util index 49c78682a..e0b994201 160000 --- a/LibSession-Util +++ b/LibSession-Util @@ -1 +1 @@ -Subproject commit 49c78682a6f4546c8773113f3e201244f0b1e65a +Subproject commit e0b994201a016cc5bf9065526a0ceb4291f60d5a diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 6ef13920c..0a5a4e81a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -578,6 +578,10 @@ FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; }; FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A31225574F5200338F3E /* MessageReceiveJob.swift */; }; FD245C6D285066A400B966DD /* NotifyPushServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */; }; + FD29598B2A43BB8100888A17 /* GetStatsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD29598A2A43BB8100888A17 /* GetStatsResponse.swift */; }; + FD29598D2A43BC0B00888A17 /* Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD29598C2A43BC0B00888A17 /* Version.swift */; }; + FD2959902A43BE5F00888A17 /* VersionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD29598F2A43BE5F00888A17 /* VersionSpec.swift */; }; + FD2959922A4417A900888A17 /* PreparedSendData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2959912A4417A900888A17 /* PreparedSendData.swift */; }; FD2AAAED28ED3E1000A49611 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; }; FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; }; FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; @@ -1281,7 +1285,6 @@ 7BC707F127290ACB002817AD /* SessionCallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCallManager.swift; sourceTree = ""; }; 7BCD116B27016062006330F1 /* WebRTCSession+DataChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebRTCSession+DataChannel.swift"; sourceTree = ""; }; 7BD477A727EC39F5004E2822 /* Atomic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; - 7BD477A927F15F24004E2822 /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; 7BDCFC0424206E7300641C39 /* SessionNotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionNotificationServiceExtension.entitlements; sourceTree = ""; }; 7BDCFC07242186E700641C39 /* NotificationServiceExtensionContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtensionContext.swift; sourceTree = ""; }; 7BFA8AE22831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextMenuVC+EmojiReactsView.swift"; sourceTree = ""; }; @@ -1725,6 +1728,10 @@ FD23EA6028ED0B260058676E /* CombineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineExtensions.swift; sourceTree = ""; }; FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; + FD29598A2A43BB8100888A17 /* GetStatsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStatsResponse.swift; sourceTree = ""; }; + FD29598C2A43BC0B00888A17 /* Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Version.swift; sourceTree = ""; }; + FD29598F2A43BE5F00888A17 /* VersionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionSpec.swift; sourceTree = ""; }; + FD2959912A4417A900888A17 /* PreparedSendData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreparedSendData.swift; sourceTree = ""; }; FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = ""; }; FD2B4AFA29429D1000AB4848 /* ConfigContactsSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigContactsSpec.swift; sourceTree = ""; }; FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+Contacts.swift"; sourceTree = ""; }; @@ -3217,7 +3224,6 @@ FDC4381827B34EAD00C60D73 /* Models */, FDC4380727B31D3A00C60D73 /* Types */, FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */, - 7BD477A927F15F24004E2822 /* OpenGroupServerIdLookup.swift */, B88FA7B726045D100049422F /* OpenGroupAPI.swift */, C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */, ); @@ -3609,6 +3615,7 @@ FD8ECF93293856AF00C0D1BB /* Randomness.swift */, C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */, C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */, + FD29598C2A43BC0B00888A17 /* Version.swift */, ); path = Utilities; sourceTree = ""; @@ -3784,6 +3791,14 @@ path = Utilities; sourceTree = ""; }; + FD29598E2A43BE5400888A17 /* Utilities */ = { + isa = PBXGroup; + children = ( + FD29598F2A43BE5F00888A17 /* VersionSpec.swift */, + ); + path = Utilities; + sourceTree = ""; + }; FD2B4B022949886900AB4848 /* Database */ = { isa = PBXGroup; children = ( @@ -4033,6 +4048,7 @@ FD37EA1228AB3F60003AE748 /* Database */, FD83B9B927CF20A5005E1583 /* General */, FD9B30F1293EA0AF008DEE3E /* Networking */, + FD29598E2A43BE5400888A17 /* Utilities */, ); path = SessionUtilitiesKitTests; sourceTree = ""; @@ -4167,6 +4183,7 @@ FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */, FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */, FDC4381627B32EC700C60D73 /* Personalization.swift */, + FD2959912A4417A900888A17 /* PreparedSendData.swift */, FDC4381427B329CE00C60D73 /* NonceGenerator.swift */, FDC438C227BB512200C60D73 /* SodiumProtocols.swift */, ); @@ -4395,6 +4412,7 @@ FDF848A629405C5A007DCAE5 /* ONSResolveRequest.swift */, FDF8489E29405C5A007DCAE5 /* ONSResolveResponse.swift */, FDF8489C29405C5A007DCAE5 /* GetServiceNodesRequest.swift */, + FD29598A2A43BB8100888A17 /* GetStatsResponse.swift */, ); path = Models; sourceTree = ""; @@ -5580,6 +5598,7 @@ FDF848CC29405C5B007DCAE5 /* SnodeReceivedMessage.swift in Sources */, FDF848C129405C5A007DCAE5 /* UpdateExpiryRequest.swift in Sources */, FDF848C729405C5B007DCAE5 /* SendMessageResponse.swift in Sources */, + FD29598B2A43BB8100888A17 /* GetStatsResponse.swift in Sources */, FDF848CA29405C5B007DCAE5 /* DeleteAllBeforeRequest.swift in Sources */, FDF848D229405C5B007DCAE5 /* LegacyGetMessagesRequest.swift in Sources */, FDF848CB29405C5B007DCAE5 /* SnodePoolResponse.swift in Sources */, @@ -5696,6 +5715,7 @@ C3D9E4F4256778AF0040E4F3 /* NSData+Image.m in Sources */, FDF222092818D2B0000A4995 /* NSAttributedString+Utilities.swift in Sources */, C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */, + FD29598D2A43BC0B00888A17 /* Version.swift in Sources */, FDF8487C29405906007DCAE5 /* HTTPMethod.swift in Sources */, FDF8488429405A2B007DCAE5 /* RequestInfo.swift in Sources */, C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */, @@ -5892,6 +5912,7 @@ FD245C632850664600B966DD /* Configuration.swift in Sources */, C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, + FD2959922A4417A900888A17 /* PreparedSendData.swift in Sources */, FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, FD432437299DEA38008A0213 /* TypeConversion+Utilities.swift in Sources */, FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */, @@ -6156,6 +6177,7 @@ FD0B77B229B82B7A009169BA /* ArrayUtilitiesSpec.swift in Sources */, FD1A94FE2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift in Sources */, FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */, + FD2959902A43BE5F00888A17 /* VersionSpec.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6395,7 +6417,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 408; + CURRENT_PROJECT_VERSION = 409; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6467,7 +6489,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 408; + CURRENT_PROJECT_VERSION = 409; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6532,7 +6554,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 408; + CURRENT_PROJECT_VERSION = 409; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6606,7 +6628,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 408; + CURRENT_PROJECT_VERSION = 409; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6671,7 +6693,7 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = SUQ8J2PCT7; DYLIB_COMPATIBILITY_VERSION = 1; @@ -6808,7 +6830,7 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = SUQ8J2PCT7; DYLIB_COMPATIBILITY_VERSION = 1; @@ -6959,7 +6981,7 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = SUQ8J2PCT7; DYLIB_COMPATIBILITY_VERSION = 1; @@ -7096,7 +7118,7 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = SUQ8J2PCT7; DYLIB_COMPATIBILITY_VERSION = 1; @@ -7235,7 +7257,7 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = SUQ8J2PCT7; DYLIB_COMPATIBILITY_VERSION = 1; @@ -7514,7 +7536,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 408; + CURRENT_PROJECT_VERSION = 409; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7585,7 +7607,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 408; + CURRENT_PROJECT_VERSION = 409; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 1c9265e90..bcef043e3 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -214,7 +214,10 @@ extension ContextMenuVC { let shouldShowEmojiActions: Bool = { if cellViewModel.threadVariant == .community { - return OpenGroupManager.isOpenGroupSupport(.reactions, on: cellViewModel.threadOpenGroupServer) + return OpenGroupManager.doesOpenGroupSupport( + capability: .reactions, + on: cellViewModel.threadOpenGroupServer + ) } return !currentThreadIsMessageRequest }() diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 315480a08..6c10b9da8 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -150,10 +150,17 @@ extension ConversationVC: } func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { - sendAttachments(attachments, with: messageText ?? "") - self.snInputView.text = "" + sendMessage(text: (messageText ?? ""), attachments: attachments) resetMentions() - dismiss(animated: true) { } + + dismiss(animated: true) { [weak self] in + if self?.isFirstResponder == false { + self?.becomeFirstResponder() + } + else { + self?.reloadInputViews() + } + } } func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? { @@ -167,13 +174,17 @@ extension ConversationVC: // MARK: - AttachmentApprovalViewControllerDelegate func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { - sendAttachments(attachments, with: messageText ?? "") { [weak self] in - self?.dismiss(animated: true, completion: nil) - } - - scrollToBottom(isAnimated: false) - self.snInputView.text = "" + sendMessage(text: (messageText ?? ""), attachments: attachments) resetMentions() + + dismiss(animated: true) { [weak self] in + if self?.isFirstResponder == false { + self?.becomeFirstResponder() + } + else { + self?.reloadInputViews() + } + } } func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) { @@ -181,7 +192,7 @@ extension ConversationVC: } func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) { - snInputView.text = newMessageText ?? "" + snInputView.text = (newMessageText ?? "") } func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { @@ -348,6 +359,7 @@ extension ConversationVC: attachments: attachments, approvalDelegate: self ) + navController.modalPresentationStyle = .fullScreen present(navController, animated: true, completion: nil) } @@ -369,7 +381,7 @@ extension ConversationVC: modalActivityIndicator.dismiss { guard !attachment.hasError else { - self?.showErrorAlert(for: attachment, onDismiss: nil) + self?.showErrorAlert(for: attachment) return } @@ -385,149 +397,33 @@ extension ConversationVC: // MARK: --Message Sending func handleSendButtonTapped() { - sendMessage() - } - - func sendMessage(hasPermissionToSendSeed: Bool = false) { - guard !showBlockedModalIfNeeded() else { return } - - let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines)) - - guard !text.isEmpty else { return } - - if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed { - // Warn the user if they're about to send their seed to someone - let modal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "modal_send_seed_title".localized(), - body: .text("modal_send_seed_explanation".localized()), - confirmTitle: "modal_send_seed_send_button_title".localized(), - confirmStyle: .danger, - cancelStyle: .alert_text, - onConfirm: { [weak self] _ in self?.sendMessage(hasPermissionToSendSeed: true) } - ) - ) - - return present(modal, animated: true, completion: nil) - } - - // Clearing this out immediately to make this appear more snappy - DispatchQueue.main.async { [weak self] in - self?.snInputView.text = "" - self?.snInputView.quoteDraftInfo = nil - - self?.resetMentions() - } - - // Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can - // use it to determine if the user is creating a new thread and update the 'isApproved' - // flags appropriately - let threadId: String = self.viewModel.threadData.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant - let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true) - let sentTimestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs() - let linkPreviewDraft: LinkPreviewDraft? = snInputView.linkPreviewInfo?.draft - let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model - - // If this was a message request then approve it - approveMessageRequestIfNeeded( - for: threadId, - threadVariant: self.viewModel.threadData.threadVariant, - isNewThread: !oldThreadShouldBeVisible, - timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting + sendMessage( + text: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), + linkPreviewDraft: snInputView.linkPreviewInfo?.draft, + quoteModel: snInputView.quoteDraftInfo?.model ) - - // Send the message - Storage.shared - .writePublisher { [weak self] db in - // Let the viewModel know we are about to send a message - self?.viewModel.sentMessageBeforeUpdate = true - - // Update the thread to be visible (if it isn't already) - if self?.viewModel.threadData.threadShouldBeVisible == false { - _ = try SessionThread - .filter(id: threadId) - .updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true)) - } - - let authorId: String = { - if let blindedId = self?.viewModel.threadData.currentUserBlindedPublicKey { - return blindedId - } - return self?.viewModel.threadData.currentUserPublicKey ?? getUserHexEncodedPublicKey(db) - }() - - // Create the interaction - let interaction: Interaction = try Interaction( - threadId: threadId, - authorId: authorId, - variant: .standardOutgoing, - body: text, - timestampMs: sentTimestampMs, - hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text), - expiresInSeconds: try? DisappearingMessagesConfiguration - .select(.durationSeconds) - .filter(id: threadId) - .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) - .asRequest(of: TimeInterval.self) - .fetchOne(db), - linkPreviewUrl: linkPreviewDraft?.urlString - ).inserted(db) - - // If there is a LinkPreview and it doesn't match an existing one then add it now - if - let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft, - (try? interaction.linkPreview.isEmpty(db)) == true - { - try LinkPreview( - url: linkPreviewDraft.urlString, - title: linkPreviewDraft.title, - attachmentId: LinkPreview.saveAttachmentIfPossible( - db, - imageData: linkPreviewDraft.jpegImageData, - mimeType: OWSMimeTypeImageJpeg - ) - ).insert(db) - } - - // If there is a Quote the insert it now - if let interactionId: Int64 = interaction.id, let quoteModel: QuotedReplyModel = quoteModel { - try Quote( - interactionId: interactionId, - authorId: quoteModel.authorId, - timestampMs: quoteModel.timestampMs, - body: quoteModel.body, - attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db) - ).insert(db) - } - - try MessageSender.send( - db, - interaction: interaction, - threadId: threadId, - threadVariant: threadVariant - ) - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .sinkUntilComplete( - receiveCompletion: { [weak self] _ in - self?.handleMessageSent() - } - ) } - func sendAttachments(_ attachments: [SignalAttachment], with text: String, hasPermissionToSendSeed: Bool = false, onComplete: (() -> ())? = nil) { + func sendMessage( + text: String, + attachments: [SignalAttachment] = [], + linkPreviewDraft: LinkPreviewDraft? = nil, + quoteModel: QuotedReplyModel? = nil, + hasPermissionToSendSeed: Bool = false + ) { guard !showBlockedModalIfNeeded() else { return } - for attachment in attachments { - if attachment.hasError { - return showErrorAlert(for: attachment, onDismiss: onComplete) - } + // Handle attachment errors if applicable + if let failedAttachment: SignalAttachment = attachments.first(where: { $0.hasError }) { + return showErrorAlert(for: failedAttachment) } - let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines)) + let processedText: String = replaceMentions(in: text.trimmingCharacters(in: .whitespacesAndNewlines)) - if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed { + // If we have no content then do nothing + guard !processedText.isEmpty || !attachments.isEmpty else { return } + + if processedText.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed { // Warn the user if they're about to send their seed to someone let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -537,7 +433,13 @@ extension ConversationVC: confirmStyle: .danger, cancelStyle: .alert_text, onConfirm: { [weak self] _ in - self?.sendAttachments(attachments, with: text, hasPermissionToSendSeed: true, onComplete: onComplete) + self?.sendMessage( + text: text, + attachments: attachments, + linkPreviewDraft: linkPreviewDraft, + quoteModel: quoteModel, + hasPermissionToSendSeed: true + ) } ) ) @@ -564,17 +466,24 @@ extension ConversationVC: // If this was a message request then approve it approveMessageRequestIfNeeded( for: threadId, - threadVariant: self.viewModel.threadData.threadVariant, + threadVariant: threadVariant, isNewThread: !oldThreadShouldBeVisible, timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) - // Send the message + // Optimistically insert the outgoing message (this will trigger a UI update) + self.viewModel.sentMessageBeforeUpdate = true + let optimisticData: ConversationViewModel.OptimisticMessageData = self.viewModel.optimisticallyAppendOutgoingMessage( + text: processedText, + sentTimestampMs: sentTimestampMs, + attachments: attachments, + linkPreviewDraft: linkPreviewDraft, + quoteModel: quoteModel + ) + + // Actually send the message Storage.shared .writePublisher { [weak self] db in - // Let the viewModel know we are about to send a message - self?.viewModel.sentMessageBeforeUpdate = true - // Update the thread to be visible (if it isn't already) if self?.viewModel.threadData.threadShouldBeVisible == false { _ = try SessionThread @@ -582,35 +491,44 @@ extension ConversationVC: .updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true)) } - // Create the interaction - let interaction: Interaction = try Interaction( - threadId: threadId, - authorId: getUserHexEncodedPublicKey(db), - variant: .standardOutgoing, - body: text, - timestampMs: sentTimestampMs, - hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text), - expiresInSeconds: try? DisappearingMessagesConfiguration - .select(.durationSeconds) - .filter(id: threadId) - .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) - .asRequest(of: TimeInterval.self) - .fetchOne(db) - ).inserted(db) + // 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) - guard let interactionId: Int64 = interaction.id else { return } + // If there is a LinkPreview and it doesn't match an existing one then add it now + if + let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft, + (try? insertedInteraction.linkPreview.isEmpty(db)) == true + { + try LinkPreview( + url: linkPreviewDraft.urlString, + title: linkPreviewDraft.title, + attachmentId: try optimisticData.linkPreviewAttachment?.inserted(db).id + ).insert(db) + } - // Prepare any attachments - try Attachment.prepare( + // If there is a Quote the insert it now + if let interactionId: Int64 = insertedInteraction.id, let quoteModel: QuotedReplyModel = quoteModel { + try Quote( + interactionId: interactionId, + authorId: quoteModel.authorId, + timestampMs: quoteModel.timestampMs, + body: quoteModel.body, + attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db) + ).insert(db) + } + + // Process any attachments + try Attachment.process( db, - attachments: attachments, - for: interactionId + data: optimisticData.attachmentData, + for: insertedInteraction.id ) - // Send the message try MessageSender.send( db, - interaction: interaction, + interaction: insertedInteraction, threadId: threadId, threadVariant: threadVariant ) @@ -619,11 +537,6 @@ extension ConversationVC: .sinkUntilComplete( receiveCompletion: { [weak self] _ in self?.handleMessageSent() - - // Attachment successfully sent - dismiss the screen - DispatchQueue.main.async { - onComplete?() - } } ) } @@ -1212,7 +1125,7 @@ extension ConversationVC: guard cellViewModel.threadVariant == .community else { return } Storage.shared - .readPublisherFlatMap { db -> AnyPublisher<(OpenGroupAPI.ReactionRemoveAllResponse, OpenGroupAPI.PendingChange), Error> in + .readPublisher { db -> (OpenGroupAPI.PreparedSendData, OpenGroupAPI.PendingChange) in guard let openGroup: OpenGroup = try? OpenGroup .fetchOne(db, id: cellViewModel.threadId), @@ -1223,6 +1136,14 @@ extension ConversationVC: .fetchOne(db) else { throw StorageError.objectNotFound } + let sendData: OpenGroupAPI.PreparedSendData = try OpenGroupAPI + .preparedReactionDeleteAll( + db, + emoji: emoji, + id: openGroupServerMessageId, + in: openGroup.roomToken, + on: openGroup.server + ) let pendingChange: OpenGroupAPI.PendingChange = OpenGroupManager .addPendingReaction( emoji: emoji, @@ -1232,27 +1153,22 @@ extension ConversationVC: type: .removeAll ) - return OpenGroupAPI - .reactionDeleteAll( - db, - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server - ) - .map { _, response in (response, pendingChange) } - .eraseToAnyPublisher() + return (sendData, pendingChange) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .handleEvents( - receiveOutput: { response, pendingChange in - OpenGroupManager - .updatePendingChange( - pendingChange, - seqNo: response.seqNo - ) - } - ) + .flatMap { sendData, pendingChange in + OpenGroupAPI.send(data: sendData) + .handleEvents( + receiveOutput: { _, response in + OpenGroupManager + .updatePendingChange( + pendingChange, + seqNo: response.seqNo + ) + } + ) + .eraseToAnyPublisher() + } .sinkUntilComplete( receiveCompletion: { _ in Storage.shared.writeAsync { db in @@ -1266,14 +1182,16 @@ extension ConversationVC: } func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) { - guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else { - return - } - - let threadIsMessageRequest: Bool = (self.viewModel.threadData.threadIsMessageRequest == true) - guard !threadIsMessageRequest else { return } + guard + self.viewModel.threadData.threadIsMessageRequest != true && ( + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardOutgoing + ) + else { return } // Perform local rate limiting (don't allow more than 20 reactions within 60 seconds) + let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant + let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken let sentTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs() let recentReactionTimestamps: [Int64] = General.cache.wrappedValue.recentReactionTimestamps @@ -1299,9 +1217,38 @@ extension ConversationVC: .appending(sentTimestamp) } - // Perform the sending logic - Storage.shared - .writePublisherFlatMap { [weak self] db -> AnyPublisher in + typealias OpenGroupInfo = ( + pendingReaction: Reaction?, + pendingChange: OpenGroupAPI.PendingChange, + sendData: OpenGroupAPI.PreparedSendData + ) + + /// 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 { + Future { resolver in + 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( + OpenGroupManager.addPendingReaction( + emoji: emoji, + id: serverMessageId, + in: openGroupServer, + on: openGroupPublicKey, + type: (remove ? .remove : .add) + ) + )) + } + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .flatMap { pendingChange -> AnyPublisher<(MessageSender.PreparedSendData?, OpenGroupInfo?), Error> in + Storage.shared.writePublisher { [weak self] db -> (MessageSender.PreparedSendData?, OpenGroupInfo?) in // Update the thread to be visible (if it isn't already) if self?.viewModel.threadData.threadShouldBeVisible == false { _ = try SessionThread @@ -1310,29 +1257,29 @@ extension ConversationVC: } let pendingReaction: Reaction? = { - if remove { + guard !remove else { return try? Reaction .filter(Reaction.Columns.interactionId == cellViewModel.id) .filter(Reaction.Columns.authorId == cellViewModel.currentUserPublicKey) .filter(Reaction.Columns.emoji == emoji) .fetchOne(db) - } else { - let sortId = Reaction.getSortId( - db, - interactionId: cellViewModel.id, - emoji: emoji - ) - - return Reaction( - interactionId: cellViewModel.id, - serverHash: nil, - timestampMs: sentTimestamp, - authorId: cellViewModel.currentUserPublicKey, - 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: sentTimestamp, + authorId: cellViewModel.currentUserPublicKey, + emoji: emoji, + count: 1, + sortId: sortId + ) }() // Update the database @@ -1350,125 +1297,108 @@ extension ConversationVC: Emoji.addRecent(db, emoji: emoji) } - // If it's not an OpenGroup then send the message directly to the thread - guard - let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: cellViewModel.threadId), - OpenGroupManager.isOpenGroupSupport(.reactions, on: openGroup.server) - else { - let sendData: MessageSender.PreparedSendData = try MessageSender.preparedSendData( - db, - message: VisibleMessage( - sentTimestamp: UInt64(sentTimestamp), - text: nil, - reaction: VisibleMessage.VMReaction( - timestamp: UInt64(cellViewModel.timestampMs), - publicKey: { - guard cellViewModel.variant == .standardIncoming else { - return cellViewModel.currentUserPublicKey - } - - return cellViewModel.authorId - }(), - emoji: emoji, - kind: (remove ? .remove : .react) - ) - ), - to: try Message.Destination - .from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant), - namespace: try Message.Destination - .from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant) - .defaultNamespace, - interactionId: cellViewModel.id - ) - - return Just(sendData) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - // Otherwise we need to make an API call to the OpenGroup - // Send reaction to open groups - guard - let openGroupServerMessageId: Int64 = try? Interaction - .select(.openGroupServerMessageId) - .filter(id: cellViewModel.id) - .asRequest(of: Int64.self) - .fetchOne(db) - else { throw MessageSenderError.invalidMessage } - - let pendingChange: OpenGroupAPI.PendingChange = OpenGroupManager - .addPendingReaction( - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server, - type: (remove ? .remove : .add) - ) - - let request: AnyPublisher = { - switch remove { - case true: - return OpenGroupAPI - .reactionDelete( - db, - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server - ) - .map { _, response in response.seqNo } - .eraseToAnyPublisher() - - case false: - return OpenGroupAPI - .reactionAdd( - db, - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server - ) - .map { _, response in response.seqNo } - .eraseToAnyPublisher() - } - }() - - return request - .handleEvents( - receiveOutput: { seqNo in - OpenGroupManager - .updatePendingChange( - pendingChange, - seqNo: seqNo - ) - }, - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure: - OpenGroupManager.removePendingChange(pendingChange) - - self?.handleReactionSentFailure( - pendingReaction, - remove: remove + switch threadVariant { + case .community: + guard + let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, + let openGroupServer: String = cellViewModel.threadOpenGroupServer, + let openGroupRoom: String = openGroupRoom, + let pendingChange: OpenGroupAPI.PendingChange = pendingChange, + OpenGroupManager.doesOpenGroupSupport(db, capability: .reactions, on: openGroupServer) + else { throw MessageSenderError.invalidMessage } + + let sendData: OpenGroupAPI.PreparedSendData = try { + guard !remove else { + return try OpenGroupAPI + .preparedReactionDelete( + db, + emoji: emoji, + id: serverMessageId, + in: openGroupRoom, + on: openGroupServer ) + .map { _, response in response.seqNo } } - } - ) - .map { _ in nil } - .eraseToAnyPublisher() - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { maybeSendData -> AnyPublisher in - guard let sendData: MessageSender.PreparedSendData = maybeSendData else { - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + + return try OpenGroupAPI + .preparedReactionAdd( + db, + emoji: emoji, + id: serverMessageId, + in: openGroupRoom, + on: openGroupServer + ) + .map { _, response in response.seqNo } + }() + + return (nil, (pendingReaction, pendingChange, sendData)) + + default: + let sendData: MessageSender.PreparedSendData = try MessageSender.preparedSendData( + db, + message: VisibleMessage( + sentTimestamp: UInt64(sentTimestamp), + text: nil, + reaction: VisibleMessage.VMReaction( + timestamp: UInt64(cellViewModel.timestampMs), + publicKey: { + guard cellViewModel.variant == .standardIncoming else { + return cellViewModel.currentUserPublicKey + } + + return cellViewModel.authorId + }(), + emoji: emoji, + kind: (remove ? .remove : .react) + ) + ), + to: try Message.Destination + .from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant), + namespace: try Message.Destination + .from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant) + .defaultNamespace, + interactionId: cellViewModel.id + ) + + return (sendData, nil) } - - return MessageSender.sendImmediate(preparedSendData: sendData) } - .sinkUntilComplete() + } + .tryFlatMap { messageSendData, openGroupInfo -> AnyPublisher in + switch (messageSendData, openGroupInfo) { + case (.some(let sendData), _): + return MessageSender.sendImmediate(preparedSendData: sendData) + + case (_, .some(let info)): + return OpenGroupAPI.send(data: info.sendData) + .handleEvents( + receiveOutput: { _, seqNo in + OpenGroupManager + .updatePendingChange( + info.pendingChange, + seqNo: seqNo + ) + }, + receiveCompletion: { [weak self] result in + switch result { + case .finished: break + case .failure: + OpenGroupManager.removePendingChange(info.pendingChange) + + self?.handleReactionSentFailure( + info.pendingReaction, + remove: remove + ) + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() + + default: throw MessageSenderError.invalidMessage + } + } + .sinkUntilComplete() } func handleReactionSentFailure(_ pendingReaction: Reaction?, remove: Bool) { @@ -1891,16 +1821,18 @@ extension ConversationVC: // Delete the message from the open group deleteRemotely( from: self, - request: Storage.shared.readPublisherFlatMap { db in - OpenGroupAPI.messageDelete( - db, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server - ) + request: Storage.shared + .readPublisher { db in + try OpenGroupAPI.preparedMessageDelete( + db, + id: openGroupServerMessageId, + in: openGroup.roomToken, + on: openGroup.server + ) + } + .flatMap { OpenGroupAPI.send(data: $0) } .map { _ in () } .eraseToAnyPublisher() - } ) { [weak self] in self?.showInputAccessoryView() } @@ -2100,21 +2032,20 @@ extension ConversationVC: cancelStyle: .alert_text, onConfirm: { [weak self] _ in Storage.shared - .readPublisherFlatMap { db -> AnyPublisher in + .readPublisher { db -> OpenGroupAPI.PreparedSendData in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { throw StorageError.objectNotFound } - return OpenGroupAPI - .userBan( + return try OpenGroupAPI + .preparedUserBan( db, sessionId: cellViewModel.authorId, from: [openGroup.roomToken], on: openGroup.server ) - .map { _ in () } - .eraseToAnyPublisher() } + .flatMap { OpenGroupAPI.send(data: $0) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sinkUntilComplete( @@ -2316,11 +2247,11 @@ extension ConversationVC: let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4Audio as String) guard !attachment.hasError else { - return showErrorAlert(for: attachment, onDismiss: nil) + return showErrorAlert(for: attachment) } // Send attachment - sendAttachments([ attachment ], with: "") + sendMessage(text: "", attachments: [attachment]) } func cancelVoiceMessageRecording() { @@ -2360,15 +2291,14 @@ extension ConversationVC: // MARK: - Convenience - func showErrorAlert(for attachment: SignalAttachment, onDismiss: (() -> ())?) { + func showErrorAlert(for attachment: SignalAttachment) { let modal: ConfirmationModal = ConfirmationModal( targetView: self.view, info: ConfirmationModal.Info( title: "ATTACHMENT_ERROR_ALERT_TITLE".localized(), body: .text(attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage), cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text, - afterClosed: onDismiss + cancelStyle: .alert_text ) ) self.present(modal, animated: true) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index c52f8d1cb..c655c3e89 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -890,6 +890,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers guard !didSendMessageBeforeUpdate && !wasOnlyUpdates else { self.viewModel.updateInteractionData(updatedData) self.tableView.reloadData() + self.tableView.layoutIfNeeded() // If we just sent a message then we want to jump to the bottom of the conversation instantly if didSendMessageBeforeUpdate { diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 63ae30e30..0c898f941 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -176,9 +176,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { /// 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 lazy var observableThreadData: ValueObservation>> = setupObservableThreadData(for: self.threadId) + public typealias ThreadObservation = ValueObservation>>> + public lazy var observableThreadData: ThreadObservation = setupObservableThreadData(for: self.threadId) - private func setupObservableThreadData(for threadId: String) -> ValueObservation>> { + private func setupObservableThreadData(for threadId: String) -> ThreadObservation { return ValueObservation .trackingConstantRegion { [weak self] db -> SessionThreadViewModel? in let userPublicKey: String = getUserHexEncodedPublicKey(db) @@ -197,6 +198,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } } .removeDuplicates() + .handleEvents(didFail: { SNLog("[ConversationViewModel] Observation failed with error: \($0)") }) } public func updateThreadData(_ updatedData: SessionThreadViewModel) { @@ -314,8 +316,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { ) ], onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + self?.resolveOptimisticUpdates(with: updatedData) + PagedData.processAndTriggerUpdates( - updatedData: self?.process(data: updatedData, for: updatedPageInfo), + updatedData: self?.process( + data: updatedData, + for: updatedPageInfo, + optimisticMessages: (self?.optimisticallyInsertedMessages.wrappedValue.values) + .map { Array($0) }, + initialUnreadInteractionId: self?.initialUnreadInteractionId + ), currentDataRetriever: { self?.interactionData }, onDataChange: self?.onInteractionChange, onUnobservedDataChange: { updatedData, changeset in @@ -329,11 +339,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { ) } - private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { - let initialUnreadInteractionId: Int64? = self.initialUnreadInteractionId + private func process( + data: [MessageViewModel], + for pageInfo: PagedData.PageInfo, + optimisticMessages: [MessageViewModel]?, + initialUnreadInteractionId: Int64? + ) -> [SectionModel] { let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true }) let sortedData: [MessageViewModel] = data - .filter { $0.isTypingIndicator != true } + .appending(contentsOf: (optimisticMessages ?? [])) + .filter { !$0.cellType.isPostProcessed } .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } // We load messages from newest to oldest so having a pageOffset larger than zero means @@ -408,12 +423,151 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { self.interactionData = updatedData } - public func expandReactions(for interactionId: Int64) { - reactionExpandedInteractionIds.insert(interactionId) + // MARK: - Optimistic Message Handling + + public typealias OptimisticMessageData = ( + id: UUID, + interaction: Interaction, + attachmentData: Attachment.PreparedData?, + linkPreviewAttachment: Attachment? + ) + + private var optimisticallyInsertedMessages: Atomic<[UUID: MessageViewModel]> = Atomic([:]) + private var optimisticMessageAssociatedInteractionIds: Atomic<[Int64: UUID]> = Atomic([:]) + + public func optimisticallyAppendOutgoingMessage( + text: String?, + sentTimestampMs: Int64, + attachments: [SignalAttachment]?, + linkPreviewDraft: LinkPreviewDraft?, + quoteModel: QuotedReplyModel? + ) -> OptimisticMessageData { + // Generate the optimistic data + let optimisticMessageId: UUID = UUID() + let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser() + let interaction: Interaction = Interaction( + threadId: threadData.threadId, + authorId: (threadData.currentUserBlindedPublicKey ?? threadData.currentUserPublicKey), + variant: .standardOutgoing, + body: text, + timestampMs: sentTimestampMs, + hasMention: Interaction.isUserMentioned( + publicKeysToCheck: [ + threadData.currentUserPublicKey, + threadData.currentUserBlindedPublicKey + ].compactMap { $0 }, + body: text + ), + expiresInSeconds: threadData.disappearingMessagesConfiguration + .map { disappearingConfig in + guard disappearingConfig.isEnabled else { return nil } + + return disappearingConfig.durationSeconds + }, + linkPreviewUrl: linkPreviewDraft?.urlString + ) + let optimisticAttachments: Attachment.PreparedData? = attachments + .map { Attachment.prepare(attachments: $0) } + let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in + try? LinkPreview.generateAttachmentIfPossible( + imageData: draft.jpegImageData, + mimeType: OWSMimeTypeImageJpeg + ) + } + let optimisticData: OptimisticMessageData = ( + optimisticMessageId, + interaction, + optimisticAttachments, + linkPreviewAttachment + ) + + // Generate the actual 'MessageViewModel' + let messageViewModel: MessageViewModel = MessageViewModel( + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + threadHasDisappearingMessagesEnabled: (threadData.disappearingMessagesConfiguration?.isEnabled ?? false), + 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, + isSenderOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin( + threadData.currentUserPublicKey, + for: threadData.openGroupRoomToken, + on: threadData.openGroupServer + ), + currentUserProfile: currentUserProfile, + quote: quoteModel.map { model in + // Don't care about this optimistic quote (the proper one will be generated in the database) + Quote( + interactionId: -1, // Can't save to db optimistically + authorId: model.authorId, + timestampMs: model.timestampMs, + body: model.body, + attachmentId: model.attachment?.id + ) + }, + quoteAttachment: quoteModel?.attachment, + linkPreview: linkPreviewDraft.map { draft in + LinkPreview( + url: draft.urlString, + title: draft.title, + attachmentId: nil // Can't save to db optimistically + ) + }, + linkPreviewAttachment: linkPreviewAttachment, + attachments: optimisticAttachments?.attachments + ) + + optimisticallyInsertedMessages.mutate { $0[optimisticMessageId] = messageViewModel } + + // 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.wrappedValue else { + return optimisticData + } + + /// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above + let currentData: [SectionModel] = (unobservedInteractionDataChanges?.0 ?? interactionData) + + PagedData.processAndTriggerUpdates( + updatedData: process( + data: (currentData.first(where: { $0.model == .messages })?.elements ?? []), + for: currentPageInfo, + optimisticMessages: Array(optimisticallyInsertedMessages.wrappedValue.values), + initialUnreadInteractionId: initialUnreadInteractionId + ), + currentDataRetriever: { [weak self] in self?.interactionData }, + onDataChange: self.onInteractionChange, + onUnobservedDataChange: { [weak self] updatedData, changeset in + self?.unobservedInteractionDataChanges = (changeset.isEmpty ? + nil : + (updatedData, changeset) + ) + } + ) + + return optimisticData } - public func collapseReactions(for interactionId: Int64) { - reactionExpandedInteractionIds.remove(interactionId) + /// Record an association between an `optimisticMessageId` and a specific `interactionId` + public func associate(optimisticMessageId: UUID, to interactionId: Int64?) { + guard let interactionId: Int64 = interactionId else { return } + + optimisticMessageAssociatedInteractionIds.mutate { $0[interactionId] = optimisticMessageId } + } + + /// 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 + .mutate { associatedIds in interactionIds.compactMap { associatedIds.removeValue(forKey: $0) } } + + optimisticallyInsertedMessages.mutate { messages in idsToRemove.forEach { messages.removeValue(forKey: $0) } } } // MARK: - Mentions @@ -575,6 +729,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } } + public func expandReactions(for interactionId: Int64) { + reactionExpandedInteractionIds.insert(interactionId) + } + + public func collapseReactions(for interactionId: Int64) { + reactionExpandedInteractionIds.remove(interactionId) + } + // MARK: - Audio Playback public struct PlaybackInfo { diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 892e3e525..5ea9ceddd 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -332,6 +332,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // Build the link preview LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sink( receiveCompletion: { [weak self] result in diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 91eda8b7e..08e300b08 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -119,17 +119,6 @@ final class QuoteView: UIView { contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self) contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true - // Line view - let lineColor: ThemeValue = { - switch mode { - case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary) - case .draft: return .primary - } - }() - let lineView = UIView() - lineView.themeBackgroundColor = lineColor - lineView.set(.width, to: Values.accentLineThickness) - if let attachment: Attachment = attachment { let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType) let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black") @@ -181,13 +170,26 @@ final class QuoteView: UIView { } } else { + // Line view + let lineColor: ThemeValue = { + switch mode { + case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary) + case .draft: return .primary + } + }() + let lineView = UIView() + lineView.themeBackgroundColor = lineColor mainStackView.addArrangedSubview(lineView) + + lineView.pin(.top, to: .top, of: mainStackView) + lineView.pin(.bottom, to: .bottom, of: mainStackView) + lineView.set(.width, to: Values.accentLineThickness) } // Body label let bodyLabel = TappableLabel() - bodyLabel.numberOfLines = 0 bodyLabel.lineBreakMode = .byTruncatingTail + bodyLabel.numberOfLines = 2 let targetThemeColor: ThemeValue = { switch mode { @@ -229,7 +231,6 @@ final class QuoteView: UIView { // Label stack view let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace) - var authorLabelHeight: CGFloat? let isCurrentUser: Bool = [ currentUserPublicKey, @@ -259,16 +260,12 @@ final class QuoteView: UIView { authorLabel.themeTextColor = targetThemeColor authorLabel.lineBreakMode = .byTruncatingTail authorLabel.isHidden = (authorLabel.text == nil) - - let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace) - authorLabel.set(.height, to: authorLabelSize.height) - authorLabelHeight = authorLabelSize.height + authorLabel.numberOfLines = 1 let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ]) labelStackView.axis = .vertical labelStackView.spacing = labelStackViewSpacing labelStackView.distribution = .equalCentering - labelStackView.set(.width, to: max(bodyLabelSize.width, authorLabelSize.width)) labelStackView.isLayoutMarginsRelativeArrangement = true labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0) mainStackView.addArrangedSubview(labelStackView) @@ -277,29 +274,6 @@ final class QuoteView: UIView { contentView.addSubview(mainStackView) mainStackView.pin(to: contentView) - if threadVariant == .contact { - bodyLabel.set(.width, to: bodyLabelSize.width) - } - - let bodyLabelHeight = bodyLabelSize.height.clamp(0, (mode == .regular ? 60 : 40)) - let contentViewHeight: CGFloat - - if attachment != nil { - contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail - bodyLabel.set(.height, to: 18) // Experimentally determined - } - else { - if let authorLabelHeight = authorLabelHeight { // Group thread - contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin - } - else { - contentViewHeight = bodyLabelHeight + 2 * smallSpacing - } - } - - contentView.set(.height, to: contentViewHeight) - lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line - if mode == .draft { // Cancel button let cancelButton = UIButton(type: .custom) diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift index 685247ce4..981c05624 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift @@ -149,6 +149,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel State in try HomeViewModel.retrieveState(db) } .removeDuplicates() + .handleEvents(didFail: { SNLog("[HomeViewModel] Observation failed with error: \($0)") }) private static func retrieveState(_ db: Database) throws -> State { let hasViewedSeed: Bool = db[.hasViewedSeed] diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 2f6267dd9..a7ed46b98 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -17,7 +17,7 @@ public class MessageRequestsViewModel { // MARK: - Variables - public static let pageSize: Int = 15 + public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15) // MARK: - Initialization diff --git a/Session/Home/New Conversation/NewDMVC.swift b/Session/Home/New Conversation/NewDMVC.swift index 874d4987f..2ce3b92a1 100644 --- a/Session/Home/New Conversation/NewDMVC.swift +++ b/Session/Home/New Conversation/NewDMVC.swift @@ -210,6 +210,7 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle .present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in SnodeAPI .getSessionID(for: onsNameOrPublicKey) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveCompletion: { result in diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift index ca5c14ecf..c5e92d27d 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift @@ -360,6 +360,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect cell .requestRenditionForSending() + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sink( receiveCompletion: { [weak self] result in @@ -490,6 +491,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect assert(searchBar.text == nil || searchBar.text?.count == 0) GiphyAPI.trending() + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sink( receiveCompletion: { result in @@ -527,6 +529,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect GiphyAPI .search(query: query) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sink( receiveCompletion: { [weak self] result in diff --git a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift index f8a0fa206..ec2ccddbb 100644 --- a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift +++ b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift @@ -291,7 +291,6 @@ enum GiphyAPI { return urlSession .dataTaskPublisher(for: url) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .mapError { urlError in Logger.error("search request failed: \(urlError)") @@ -340,7 +339,6 @@ enum GiphyAPI { return urlSession .dataTaskPublisher(for: request) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .mapError { urlError in Logger.error("search request failed: \(urlError)") diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 8aeb4400a..22a1db975 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -360,7 +360,7 @@ public class MediaGalleryViewModel { /// 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 AlbumObservation = ValueObservation>> + public typealias AlbumObservation = ValueObservation>>> public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil) private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation { @@ -383,6 +383,7 @@ public class MediaGalleryViewModel { .fetchAll(db) } .removeDuplicates() + .handleEvents(didFail: { SNLog("[MediaGalleryViewModel] Observation failed with error: \($0)") }) } @discardableResult public func loadAndCacheAlbumData(for interactionId: Int64, in threadId: String) -> [Item] { diff --git a/Session/Media Viewing & Editing/PhotoCapture.swift b/Session/Media Viewing & Editing/PhotoCapture.swift index 51205b62f..37fa99901 100644 --- a/Session/Media Viewing & Editing/PhotoCapture.swift +++ b/Session/Media Viewing & Editing/PhotoCapture.swift @@ -84,7 +84,7 @@ class PhotoCapture: NSObject { func startCapture() -> AnyPublisher { return Just(()) - .subscribe(on: sessionQueue) + .subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes .setFailureType(to: Error.self) .tryMap { [weak self] _ -> Void in self?.session.beginConfiguration() @@ -136,7 +136,7 @@ class PhotoCapture: NSObject { func stopCapture() -> AnyPublisher { return Just(()) - .subscribe(on: sessionQueue) + .subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes .handleEvents( receiveOutput: { [weak self] in self?.session.stopRunning() } ) @@ -160,7 +160,7 @@ class PhotoCapture: NSObject { return Just(()) .setFailureType(to: Error.self) - .subscribe(on: sessionQueue) + .subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes .tryMap { [weak self, newPosition = self.desiredPosition] _ -> Void in self?.session.beginConfiguration() defer { self?.session.commitConfiguration() } @@ -196,7 +196,7 @@ class PhotoCapture: NSObject { func switchFlashMode() -> AnyPublisher { return Just(()) - .subscribe(on: sessionQueue) + .subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes .handleEvents( receiveOutput: { [weak self] _ in switch self?.captureOutput.flashMode { @@ -351,7 +351,7 @@ extension PhotoCapture: CaptureButtonDelegate { Logger.verbose("") Just(()) - .subscribe(on: sessionQueue) + .subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes .sinkUntilComplete( receiveCompletion: { [weak self] _ in guard let strongSelf = self else { return } diff --git a/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift index d5e192e4f..76e880278 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionUIKit class MediaDismissAnimationController: NSObject { private let mediaItem: Media @@ -46,6 +47,18 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning switch fromVC { case let contextProvider as MediaPresentationContextProvider: fromContextProvider = contextProvider + + case let topBannerController as TopBannerController: + guard + let firstChild: UIViewController = topBannerController.children.first, + let navController: UINavigationController = firstChild as? UINavigationController, + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { + transitionContext.completeTransition(false) + return + } + + fromContextProvider = contextProvider case let navController as UINavigationController: guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { @@ -64,6 +77,19 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning case let contextProvider as MediaPresentationContextProvider: toVC.view.layoutIfNeeded() toContextProvider = contextProvider + + case let topBannerController as TopBannerController: + guard + let firstChild: UIViewController = topBannerController.children.first, + let navController: UINavigationController = firstChild as? UINavigationController, + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { + transitionContext.completeTransition(false) + return + } + + toVC.view.layoutIfNeeded() + toContextProvider = contextProvider case let navController as UINavigationController: guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { diff --git a/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift index 83efc9a24..7dd7a4f0b 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionUIKit class MediaZoomAnimationController: NSObject { private let mediaItem: Media @@ -34,6 +35,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { switch fromVC { case let contextProvider as MediaPresentationContextProvider: fromContextProvider = contextProvider + + case let topBannerController as TopBannerController: + guard + let firstChild: UIViewController = topBannerController.children.first, + let navController: UINavigationController = firstChild as? UINavigationController, + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { + transitionContext.completeTransition(false) + return + } + + fromContextProvider = contextProvider case let navController as UINavigationController: guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { @@ -51,6 +64,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { switch toVC { case let contextProvider as MediaPresentationContextProvider: toContextProvider = contextProvider + + case let topBannerController as TopBannerController: + guard + let firstChild: UIViewController = topBannerController.children.first, + let navController: UINavigationController = firstChild as? UINavigationController, + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { + transitionContext.completeTransition(false) + return + } + + toContextProvider = contextProvider case let navController as UINavigationController: guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index dcab9b1d8..ca106f141 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -332,12 +332,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } } - private func showFailedMigrationAlert(calledFrom lifecycleMethod: LifecycleMethod, error: Error?) { + private func showFailedMigrationAlert( + calledFrom lifecycleMethod: LifecycleMethod, + error: Error?, + isRestoreError: Bool = false + ) { let alert = UIAlertController( title: "Session", message: { - switch (error ?? StorageError.generic) { - case StorageError.startupFailed: return "DATABASE_STARTUP_FAILED".localized() + switch (isRestoreError, (error ?? StorageError.generic)) { + case (true, _): return "DATABASE_RESTORE_FAILED".localized() + case (_, StorageError.startupFailed): return "DATABASE_STARTUP_FAILED".localized() default: return "DATABASE_MIGRATION_FAILED".localized() } }(), @@ -348,32 +353,45 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error) } }) - alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in - // Remove the legacy database and any message hashes that have been migrated to the new DB - try? SUKLegacy.deleteLegacyDatabaseFilesAndKey() - - Storage.shared.write { db in - try SnodeReceivedMessageInfo.deleteAll(db) - } - - // The re-run the migration (should succeed since there is no data) - AppSetup.runPostSetupMigrations( - migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in - self?.loadingViewController?.updateProgress( - progress: progress, - minEstimatedTotalTime: minEstimatedTotalTime - ) - }, - migrationsCompletion: { [weak self] result, needsConfigSync in - if case .failure(let error) = result { - self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error) - return - } + + // Only offer the 'Restore' option if the user hasn't already tried to restore + if !isRestoreError { + alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in + if SUKLegacy.hasLegacyDatabaseFile { + // Remove the legacy database and any message hashes that have been migrated to the new DB + try? SUKLegacy.deleteLegacyDatabaseFilesAndKey() - self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync) + Storage.shared.write { db in + try SnodeReceivedMessageInfo.deleteAll(db) + } } - ) - }) + else { + // If we don't have a legacy database then reset the current database for a clean migration + Storage.resetForCleanMigration() + } + + // Hide the top banner if there was one + TopBannerController.hide() + + // The re-run the migration (should succeed since there is no data) + AppSetup.runPostSetupMigrations( + migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in + self?.loadingViewController?.updateProgress( + progress: progress, + minEstimatedTotalTime: minEstimatedTotalTime + ) + }, + migrationsCompletion: { [weak self] result, needsConfigSync in + if case .failure(let error) = result { + self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error, isRestoreError: true) + return + } + + self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync) + } + ) + }) + } alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in DDLog.flushLog() @@ -612,12 +630,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) { guard Identity.userExists() else { return } - poller.start() - - guard shouldStartGroupPollers else { return } - - ClosedGroupPoller.shared.start() - OpenGroupManager.shared.startPolling() + /// There is a fun issue where if you launch without any valid paths then the pollers are guaranteed to fail their first poll due to + /// trying and failing to build paths without having the `SnodeAPI.snodePool` populated, by waiting for the + /// `JobRunner.blockingQueue` to complete we can have more confidence that paths won't fail to build incorrectly + JobRunner.afterBlockingQueue { [weak self] in + self?.poller.start() + + guard shouldStartGroupPollers else { return } + + ClosedGroupPoller.shared.start() + OpenGroupManager.shared.startPolling() + } } public func stopPollers(shouldStopUserPoller: Bool = true) { diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index cf306a367..178d7c6ea 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 0853ba3fb..2e5c23059 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 2cd107fb4..4d1257d37 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index 721d742fe..37be0ffb3 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "لطفا بعدا دوباره تلاش کنید"; "LOADING_CONVERSATIONS" = "درحال بارگزاری پیام ها..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "هنگام بهینه‌سازی پایگاه داده خطایی روی داد\n\nشما می‌توانید گزارش‌های برنامه خود را صادر کنید تا بتوانید برای عیب‌یابی به اشتراک بگذارید یا می‌توانید دستگاه خود را بازیابی کنید\n\nهشدار: بازیابی دستگاه شما منجر به از دست رفتن داده‌های قدیمی‌تر از دو هفته می‌شود."; "RECOVERY_PHASE_ERROR_GENERIC" = "مشکلی پیش آمد. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید."; "RECOVERY_PHASE_ERROR_LENGTH" = "به نظر می رسد کلمات کافی وارد نکرده اید. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید."; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index e06981e6c..61f77cd4a 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 0f42d5c3b..b5bf78948 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Veuillez réessayer plus tard"; "LOADING_CONVERSATIONS" = "Chargement des conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "Une erreur est survenue pendant l'optimisation de la base de données\n\nVous pouvez exporter votre journal d'application pour le partager et aider à régler le problème ou vous pouvez restaurer votre appareil\n\nAttention : restaurer votre appareil résultera en une perte des données des deux dernières semaines"; "RECOVERY_PHASE_ERROR_GENERIC" = "Quelque chose s'est mal passé. Vérifiez votre phrase de récupération et réessayez s'il vous plaît."; "RECOVERY_PHASE_ERROR_LENGTH" = "Il semble que vous n'avez pas saisi tous les mots. Vérifiez votre phrase de récupération et réessayez s'il vous plaît."; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index bf51ebd6c..2b5a37525 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index 5bac5abfb..c7714aca6 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index 4e54eb039..62d0c3471 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index b41f34667..acb886818 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index f2b83382f..e5ffab88b 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 0aaa027f7..2597356f0 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index dbefe0039..190af0988 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index 888d81746..3f9740a6e 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index 77850b124..648eb63da 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index f28501061..2e06c99a0 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index 5a995249d..b140447f4 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index 688680b2a..dfb732242 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 98ca3e5bb..c720946db 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index aa5527991..4efaa9b08 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index 8296fa917..f8735880c 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index 68076c678..b2a05e36b 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -415,6 +415,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index dfd62083b..4c818024b 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -575,9 +575,7 @@ class NotificationActionHandler { threadVariant: thread.variant ) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } - .receive(on: DispatchQueue.main) .handleEvents( receiveCompletion: { result in switch result { diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index 51df226c3..50fe02510 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -52,8 +52,9 @@ public enum PushRegistrationError: Error { Logger.info("") return registerUserNotificationSettings() - .setFailureType(to: Error.self) + .subscribe(on: DispatchQueue.global(qos: .default)) .receive(on: DispatchQueue.main) // MUST be on main thread + .setFailureType(to: Error.self) .tryFlatMap { _ -> AnyPublisher<(pushToken: String, voipToken: String), Error> in #if targetEnvironment(simulator) throw PushRegistrationError.pushNotSupported(description: "Push not supported on simulators") diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index 3af0cccd3..4e8711b3e 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -251,6 +251,8 @@ public class UserNotificationActionHandler: NSObject { func handleNotificationResponse( _ response: UNNotificationResponse, completionHandler: @escaping () -> Void) { AssertIsOnMainThread() handleNotificationResponse(response) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveCompletion: { result in switch result { diff --git a/Session/Onboarding/DisplayNameVC.swift b/Session/Onboarding/DisplayNameVC.swift index cc1ee24fe..051b45aa1 100644 --- a/Session/Onboarding/DisplayNameVC.swift +++ b/Session/Onboarding/DisplayNameVC.swift @@ -189,7 +189,7 @@ final class DisplayNameVC: BaseVC { // Try to save the user name but ignore the result ProfileManager.updateLocal( - queue: DispatchQueue.global(qos: .default), + queue: .global(qos: .default), profileName: displayName ) diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 914976728..5f23d32d4 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -36,14 +36,12 @@ enum Onboarding { let userPublicKey: String = getUserHexEncodedPublicKey() return SnodeAPI.getSwarm(for: userPublicKey) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .tryFlatMapWithRandomSnode { snode -> AnyPublisher in CurrentUserPoller .poll( namespaces: [.configUserProfile], from: snode, for: userPublicKey, - on: DispatchQueue.global(qos: .userInitiated), // Note: These values mean the received messages will be // processed immediately rather than async as part of a Job calledFromBackgroundPoller: true, @@ -67,7 +65,6 @@ enum Onboarding { namespaces: [.default], from: snode, for: userPublicKey, - on: DispatchQueue.global(qos: .userInitiated), // Note: These values mean the received messages will be // processed immediately rather than async as part of a Job calledFromBackgroundPoller: true, @@ -215,7 +212,9 @@ enum Onboarding { guard self != .register else { return } // Fetch the - Onboarding.profileNamePublisher.sinkUntilComplete() + Onboarding.profileNamePublisher + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete() } func completeRegistration() { diff --git a/Session/Onboarding/PNModeVC.swift b/Session/Onboarding/PNModeVC.swift index 8c60ab51e..bf4884a29 100644 --- a/Session/Onboarding/PNModeVC.swift +++ b/Session/Onboarding/PNModeVC.swift @@ -176,6 +176,7 @@ final class PNModeVC: BaseVC, OptionViewDelegate { // If we don't have one then show a loading indicator and try to retrieve the existing name ModalActivityIndicatorViewController.present(fromViewController: self) { [weak self, flow = self.flow] viewController in Onboarding.profileNamePublisher + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .timeout(.seconds(15), scheduler: DispatchQueue.main, customError: { HTTPError.timeout }) .catch { _ -> AnyPublisher in SNLog("Onboarding failed to retrieve existing profile information") diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index 128cb6259..9fb666989 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -143,7 +143,8 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true OpenGroupManager.getDefaultRoomsIfNeeded() - .receive(on: DispatchQueue.main) + .subscribe(on: DispatchQueue.global(qos: .default)) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sinkUntilComplete( receiveCompletion: { [weak self] _ in self?.update() }, receiveValue: { [weak self] rooms in self?.rooms = rooms } @@ -336,7 +337,7 @@ extension OpenGroupSuggestionGrid { .eraseToAnyPublisher() ) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sinkUntilComplete( receiveValue: { [weak self] imageData, hasData in guard hasData else { diff --git a/Session/Settings/ConversationSettingsViewModel.swift b/Session/Settings/ConversationSettingsViewModel.swift index 2786de2c0..1c6803913 100644 --- a/Session/Settings/ConversationSettingsViewModel.swift +++ b/Session/Settings/ConversationSettingsViewModel.swift @@ -104,6 +104,7 @@ class ConversationSettingsViewModel: SessionTableViewModel] = [] public static var isValid: Bool = false - public static func poll(completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + public static func poll( + completionHandler: @escaping (UIBackgroundFetchResult) -> Void, + dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies( + subscribeQueue: .global(qos: .background), + receiveQueue: .main + ) + ) { Publishers .MergeMany( - [pollForMessages()] - .appending(contentsOf: pollForClosedGroupMessages()) + [pollForMessages(using: dependencies)] + .appending(contentsOf: pollForClosedGroupMessages(using: dependencies)) .appending( contentsOf: Storage.shared .read { db in - // 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 + /// 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 + /// the background poll as they are likely to fail again try OpenGroup .select(.server) - .filter(OpenGroup.Columns.roomToken != "") - .filter(OpenGroup.Columns.isActive) + .filter( + OpenGroup.Columns.roomToken != "" && + OpenGroup.Columns.isActive && + OpenGroup.Columns.pollFailureCount < OpenGroupAPI.Poller.maxRoomFailureCountForBackgroundPoll + ) .distinct() .asRequest(of: String.self) .fetchSet(db) @@ -38,13 +49,14 @@ public final class BackgroundPoller { return poller.poll( calledFromBackgroundPoller: true, isBackgroundPollerValid: { BackgroundPoller.isValid }, - isPostCapabilitiesRetry: false + isPostCapabilitiesRetry: false, + using: dependencies ) } ) ) - .subscribeOnMain(immediately: true) - .receiveOnMain(immediately: true) + .subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true) + .receive(on: dependencies.receiveQueue, immediatelyIfMain: true) .collect() .sinkUntilComplete( receiveCompletion: { result in @@ -61,27 +73,29 @@ public final class BackgroundPoller { ) } - private static func pollForMessages() -> AnyPublisher { + private static func pollForMessages( + using dependencies: OpenGroupManager.OGMDependencies + ) -> AnyPublisher { let userPublicKey: String = getUserHexEncodedPublicKey() return SnodeAPI.getSwarm(for: userPublicKey) - .subscribeOnMain(immediately: true) - .receiveOnMain(immediately: true) .tryFlatMapWithRandomSnode { snode -> AnyPublisher<[Message], Error> in CurrentUserPoller.poll( namespaces: CurrentUserPoller.namespaces, from: snode, for: userPublicKey, - on: DispatchQueue.main, calledFromBackgroundPoller: true, - isBackgroundPollValid: { BackgroundPoller.isValid } + isBackgroundPollValid: { BackgroundPoller.isValid }, + using: dependencies ) } .map { _ in () } .eraseToAnyPublisher() } - private static func pollForClosedGroupMessages() -> [AnyPublisher] { + private static func pollForClosedGroupMessages( + using dependencies: OpenGroupManager.OGMDependencies + ) -> [AnyPublisher] { // Fetch all closed groups (excluding any don't contain the current user as a // GroupMemeber as the user is no longer a member of those) return Storage.shared @@ -98,8 +112,6 @@ public final class BackgroundPoller { .defaulting(to: []) .map { groupPublicKey in SnodeAPI.getSwarm(for: groupPublicKey) - .subscribeOnMain(immediately: true) - .receiveOnMain(immediately: true) .tryFlatMap { swarm -> AnyPublisher<[Message], Error> in guard let snode: Snode = swarm.randomElement() else { throw OnionRequestAPIError.insufficientSnodes @@ -109,9 +121,9 @@ public final class BackgroundPoller { namespaces: ClosedGroupPoller.namespaces, from: snode, for: groupPublicKey, - on: DispatchQueue.main, calledFromBackgroundPoller: true, - isBackgroundPollValid: { BackgroundPoller.isValid } + isBackgroundPollValid: { BackgroundPoller.isValid }, + using: dependencies ) } .map { _ in () } diff --git a/SessionMessagingKit/Calls/WebRTCSession.swift b/SessionMessagingKit/Calls/WebRTCSession.swift index d6816ae8f..7d7added5 100644 --- a/SessionMessagingKit/Calls/WebRTCSession.swift +++ b/SessionMessagingKit/Calls/WebRTCSession.swift @@ -198,6 +198,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { ) } .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete( receiveCompletion: { result in switch result { @@ -263,6 +264,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { ) } .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete( receiveCompletion: { result in switch result { diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index aed812c51..bb575a6fa 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -994,11 +994,14 @@ extension Attachment { } } - public static func prepare(_ db: Database, attachments: [SignalAttachment], for interactionId: Int64) throws { - // Prepare any attachments - try attachments.enumerated() - .forEach { index, signalAttachment in - let maybeAttachment: Attachment? = Attachment( + public struct PreparedData { + public let attachments: [Attachment] + } + + public static func prepare(attachments: [SignalAttachment]) -> PreparedData { + return PreparedData( + attachments: attachments.compactMap { signalAttachment in + Attachment( variant: (signalAttachment.isVoiceMessage ? .voiceMessage : .standard @@ -1008,9 +1011,23 @@ extension Attachment { sourceFilename: signalAttachment.sourceFilename, caption: signalAttachment.captionText ) + } + ) + } + + public static func process( + _ db: Database, + data: PreparedData?, + for interactionId: Int64? + ) throws { + guard + let data: PreparedData = data, + let interactionId: Int64 = interactionId + else { return } - guard let attachment: Attachment = maybeAttachment else { return } - + try data.attachments + .enumerated() + .forEach { index, attachment in let interactionAttachment: InteractionAttachment = InteractionAttachment( albumIndex: index, interactionId: interactionId, @@ -1042,7 +1059,7 @@ extension Attachment { let attachmentId: String = self.id return Storage.shared - .writePublisherFlatMap { db -> AnyPublisher<(String?, Data?, Data?), Error> in + .writePublisher { db -> (OpenGroupAPI.PreparedSendData?, String?, Data?, Data?) in // If the attachment is a downloaded attachment, check if it came from // the server and if so just succeed immediately (no use re-uploading // an attachment that is already present on the server) - or if we want @@ -1062,9 +1079,7 @@ extension Attachment { .filter(id: attachmentId) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded)) - return Just((Attachment.fileId(for: self.downloadUrl), nil, nil)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return (nil, Attachment.fileId(for: self.downloadUrl), nil, nil) } var encryptionKey: NSData = NSData() @@ -1089,42 +1104,41 @@ extension Attachment { .filter(id: attachmentId) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) - switch destination { - case .openGroup(let openGroup): - return OpenGroupAPI - .uploadFile( - db, - bytes: data.bytes, - to: openGroup.roomToken, - on: openGroup.server - ) - .map { _, response -> (String, Data?, Data?) in - ( - response.id, - (destination.shouldEncrypt ? encryptionKey as Data : nil), - (destination.shouldEncrypt ? digest as Data : nil) + // We need database access for OpenGroup uploads so generate prepared data + let preparedSendData: OpenGroupAPI.PreparedSendData? = try { + switch destination { + case .openGroup(let openGroup): + return try OpenGroupAPI + .preparedUploadFile( + db, + bytes: data.bytes, + to: openGroup.roomToken, + on: openGroup.server ) - } - .eraseToAnyPublisher() - case .fileServer: - /// **Note:** FileServer uploads don't need database access so - return Just(( - nil, - (destination.shouldEncrypt ? encryptionKey as Data : nil), - (destination.shouldEncrypt ? digest as Data : nil) - )) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } + default: return nil + } + }() + + return ( + preparedSendData, + nil, + (destination.shouldEncrypt ? encryptionKey as Data : nil), + (destination.shouldEncrypt ? digest as Data : nil) + ) } - .flatMap { maybeFileId, encryptionKey, digest -> AnyPublisher<(String?, Data?, Data?), Error> in + .flatMap { preparedSendData, existingFileId, encryptionKey, digest -> AnyPublisher<(String?, Data?, Data?), Error> in + // No need to upload if the file was already uploaded + if let fileId: String = existingFileId { + return Just((fileId, encryptionKey, digest)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + switch destination { case .openGroup: - /// **Note:** OpenGroup uploads need database access so this should - /// have already been uploaded - return Just((maybeFileId, encryptionKey, digest)) - .setFailureType(to: Error.self) + return OpenGroupAPI.send(data: preparedSendData) + .map { _, response -> (String, Data?, Data?) in (response.id, encryptionKey, digest) } .eraseToAnyPublisher() case .fileServer: diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 5c18b9417..a3bbb8339 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -5,7 +5,7 @@ import GRDB import SessionUtilitiesKit import SessionSnodeKit -public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct DisappearingMessagesConfiguration: 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) diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 21fdd5456..60fe57b9a 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -319,7 +319,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu openGroupServerMessageId: Int64? = nil, openGroupWhisperMods: Bool = false, openGroupWhisperTo: String? = nil - ) throws { + ) { self.serverHash = serverHash self.messageUuid = messageUuid self.threadId = threadId @@ -821,6 +821,18 @@ public extension Interaction { } } + return isUserMentioned( + publicKeysToCheck: publicKeysToCheck, + body: body, + quoteAuthorId: quoteAuthorId + ) + } + + static func isUserMentioned( + publicKeysToCheck: [String], + body: String?, + quoteAuthorId: String? = nil + ) -> Bool { // A user is mentioned if their public key is in the body of a message or one of their messages // was quoted return publicKeysToCheck.contains { publicKey in diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 35018ce59..b214bc78d 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -130,7 +130,7 @@ public extension LinkPreview { return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) } - static func saveAttachmentIfPossible(_ db: Database, imageData: Data?, mimeType: String) throws -> String? { + static func generateAttachmentIfPossible(imageData: Data?, mimeType: String) throws -> Attachment? { guard let imageData: Data = imageData, !imageData.isEmpty else { return nil } guard let fileExtension: String = MIMETypeUtil.fileExtension(forMIMEType: mimeType) else { return nil } @@ -141,9 +141,7 @@ public extension LinkPreview { return nil } - return try Attachment(contentType: mimeType, dataSource: dataSource)? - .inserted(db) - .id + return Attachment(contentType: mimeType, dataSource: dataSource) } static func isValidLinkUrl(_ urlString: String) -> Bool { @@ -355,7 +353,6 @@ public extension LinkPreview { return session .dataTaskPublisher(for: request) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .mapError { _ -> Error in HTTPError.generic } // URLError codes are negative values .tryMap { data, response -> (Data, URLResponse) in guard let urlResponse: HTTPURLResponse = response as? HTTPURLResponse else { diff --git a/SessionMessagingKit/File Server/FileServerAPI.swift b/SessionMessagingKit/File Server/FileServerAPI.swift index cc2bc3636..73c1ccead 100644 --- a/SessionMessagingKit/File Server/FileServerAPI.swift +++ b/SessionMessagingKit/File Server/FileServerAPI.swift @@ -89,7 +89,6 @@ public enum FileServerAPI { with: serverPublicKey, timeout: timeout ) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .tryMap { _, response -> Data in guard let response: Data = response else { throw HTTPError.parsingFailed } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 1afa7b6b7..d42f7078c 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -94,9 +94,20 @@ public enum AttachmentDownloadJob: JobExecutor { else { throw AttachmentDownloadError.invalidUrl } return Storage.shared - .readPublisher { db in try OpenGroup.fetchOne(db, id: threadId) } - .flatMap { maybeOpenGroup -> AnyPublisher in - guard let openGroup: OpenGroup = maybeOpenGroup else { + .readPublisher { db -> OpenGroupAPI.PreparedSendData? in + try OpenGroup.fetchOne(db, id: threadId) + .map { openGroup in + try OpenGroupAPI + .preparedDownloadFile( + db, + fileId: fileId, + from: openGroup.roomToken, + on: openGroup.server + ) + } + } + .flatMap { maybePreparedSendData -> AnyPublisher in + guard let preparedSendData: OpenGroupAPI.PreparedSendData = maybePreparedSendData else { return FileServerAPI .download( fileId, @@ -105,16 +116,8 @@ public enum AttachmentDownloadJob: JobExecutor { .eraseToAnyPublisher() } - return Storage.shared - .readPublisherFlatMap { db in - OpenGroupAPI - .downloadFile( - db, - fileId: fileId, - from: openGroup.roomToken, - on: openGroup.server - ) - } + return OpenGroupAPI + .send(data: preparedSendData) .map { _, data in data } .eraseToAnyPublisher() } diff --git a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift index e7eee9f34..63297bb02 100644 --- a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift @@ -221,25 +221,29 @@ public extension ConfigurationSyncJob { return } - // Upsert a config sync job (if there is already an pending one then no need - // to add another one) + // Upsert a config sync job if needed JobRunner.upsert( db, - job: ConfigurationSyncJob.createOrUpdateIfNeeded(db, publicKey: publicKey) + job: ConfigurationSyncJob.createIfNeeded(db, publicKey: publicKey) ) } - @discardableResult static func createOrUpdateIfNeeded(_ db: Database, publicKey: String) -> Job { - // Try to get an existing job (if there is one that's not running) - if - let existingJobs: [Job] = try? Job + @discardableResult static func createIfNeeded(_ db: Database, publicKey: String) -> Job? { + /// The ConfigurationSyncJob will automatically reschedule itself to run again after 3 seconds so if there is an existing + /// job then there is no need to create another instance + /// + /// **Note:** Jobs with different `threadId` values can run concurrently + guard + JobRunner + .infoForCurrentlyRunningJobs(of: .configurationSync) + .filter({ _, info in info.threadId == publicKey }) + .isEmpty, + (try? Job .filter(Job.Columns.variant == Job.Variant.configurationSync) .filter(Job.Columns.threadId == publicKey) - .fetchAll(db), - let existingJob: Job = existingJobs.first(where: { !JobRunner.isCurrentlyRunning($0) }) - { - return existingJob - } + .isEmpty(db)) + .defaulting(to: false) + else { return nil } // Otherwise create a new job return Job( @@ -278,7 +282,7 @@ public extension ConfigurationSyncJob { Future { resolver in ConfigurationSyncJob.run( Job(variant: .configurationSync), - queue: DispatchQueue.global(qos: .userInitiated), + queue: .global(qos: .userInitiated), success: { _, _ in resolver(Result.success(())) }, failure: { _, error, _ in resolver(Result.failure(error ?? HTTPError.generic)) }, deferred: { _ in } diff --git a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift index b078fe781..9b7a6e2bc 100644 --- a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift @@ -55,6 +55,7 @@ public enum GroupLeavingJob: JobExecutor { ) } .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .subscribe(on: queue) .receive(on: queue) .sinkUntilComplete( receiveCompletion: { result in diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 8e97addcd..ad8f54c77 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -189,6 +189,7 @@ public enum MessageSendJob: JobExecutor { } .map { sendData in sendData.with(fileIds: messageFileIds) } .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .subscribe(on: queue) .receive(on: queue) .sinkUntilComplete( receiveCompletion: { result in diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index 3ac03bdc3..0e746218e 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -49,6 +49,7 @@ public enum SendReadReceiptsJob: JobExecutor { ) } .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .subscribe(on: queue) .receive(on: queue) .sinkUntilComplete( receiveCompletion: { result in diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index aa1c56d35..59cce9d4c 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -31,9 +31,7 @@ public enum OpenGroupAPI { server: String, hasPerformedInitialPoll: Bool, timeSinceLastPoll: TimeInterval, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) + using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { let lastInboxMessageId: Int64 = (try? OpenGroup .select(.inboxLatestMessageId) @@ -153,9 +151,7 @@ public enum OpenGroupAPI { _ db: Database, server: String, requests: [BatchRequest.Info], - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) + using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { let responseTypes = requests.map { $0.responseType } @@ -187,9 +183,7 @@ public enum OpenGroupAPI { _ db: Database, server: String, requests: [BatchRequest.Info], - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) + using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { let responseTypes = requests.map { $0.responseType } @@ -217,25 +211,23 @@ public enum OpenGroupAPI { /// /// Eg. `GET /capabilities` could return `{"capabilities": ["sogs", "batch"]}` `GET /capabilities?required=magic,batch` /// could return: `{"capabilities": ["sogs", "batch"], "missing": ["magic"]}` - public static func capabilities( + public static func preparedCapabilities( _ db: Database, server: String, forceBlinded: Bool = false, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, Capabilities), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .capabilities ), + responseType: Capabilities.self, forceBlinded: forceBlinded, using: dependencies ) - .decoded(as: Capabilities.self, using: dependencies) } // MARK: - Room @@ -243,23 +235,21 @@ public enum OpenGroupAPI { /// Returns a list of available rooms on the server /// /// Rooms to which the user does not have access (e.g. because they are banned, or the room has restricted access permissions) are not included - public static func rooms( + public static func preparedRooms( _ db: Database, server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, [Room]), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData<[Room]> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .rooms ), + responseType: [Room].self, using: dependencies ) - .decoded(as: [Room].self, using: dependencies) } /// Returns the details of a single room @@ -268,24 +258,22 @@ public enum OpenGroupAPI { /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func room( + public static func preparedRoom( _ db: Database, for roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, Room), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .room(roomToken) ), + responseType: Room.self, using: dependencies ) - .decoded(as: Room.self, using: dependencies) } /// Polls a room for metadata updates @@ -297,25 +285,23 @@ public enum OpenGroupAPI { /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func roomPollInfo( + public static func preparedRoomPollInfo( _ db: Database, lastUpdated: Int64, for roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, RoomPollInfo), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .roomPollInfo(roomToken, lastUpdated) ), + responseType: RoomPollInfo.self, using: dependencies ) - .decoded(as: RoomPollInfo.self, using: dependencies) } public typealias CapabilitiesAndRoomResponse = ( @@ -332,9 +318,7 @@ public enum OpenGroupAPI { _ db: Database, for roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) + using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher { let requestResponseType: [BatchRequest.Info] = [ // Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) @@ -398,9 +382,7 @@ public enum OpenGroupAPI { public static func capabilitiesAndRooms( _ db: Database, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) + using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])), Error> { let requestResponseType: [BatchRequest.Info] = [ // Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) @@ -458,7 +440,7 @@ public enum OpenGroupAPI { // MARK: - Messages /// Posts a new message to a room - public static func send( + public static func preparedSend( _ db: Database, plaintext: Data, to roomToken: String, @@ -466,17 +448,14 @@ public enum OpenGroupAPI { whisperTo: String?, whisperMods: Bool, fileIds: [String]?, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, Message), Error> { + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { - return Fail(error: OpenGroupAPIError.signingFailed) - .eraseToAnyPublisher() + throw OpenGroupAPIError.signingFailed } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -490,54 +469,49 @@ public enum OpenGroupAPI { fileIds: fileIds ) ), + responseType: Message.self, using: dependencies ) - .decoded(as: Message.self, using: dependencies) } /// Returns a single message by ID - public static func message( + public static func preparedMessage( _ db: Database, id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, Message), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .roomMessageIndividual(roomToken, id: id) ), + responseType: Message.self, using: dependencies ) - .decoded(as: Message.self, using: dependencies) } /// Edits a message, replacing its existing content with new content and a new signature /// /// **Note:** This edit may only be initiated by the creator of the post, and the poster must currently have write permissions in the room - public static func messageUpdate( + public static func preparedMessageUpdate( _ db: Database, id: Int64, plaintext: Data, fileIds: [Int64]?, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { - return Fail(error: OpenGroupAPIError.signingFailed) - .eraseToAnyPublisher() + throw OpenGroupAPIError.signingFailed } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .put, @@ -549,27 +523,27 @@ public enum OpenGroupAPI { fileIds: fileIds ) ), + responseType: NoResponse.self, using: dependencies ) } - public static func messageDelete( + public static func preparedMessageDelete( _ db: Database, id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .delete, server: server, endpoint: .roomMessageIndividual(roomToken, id: id) ), + responseType: NoResponse.self, using: dependencies ) } @@ -578,66 +552,60 @@ public enum OpenGroupAPI { /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func recentMessages( + public static func preparedRecentMessages( _ db: Database, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, [Message]), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData<[Message]> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .roomMessagesRecent(roomToken) ), + responseType: [Message].self, using: dependencies ) - .decoded(as: [Message].self, using: dependencies) } /// **Note:** This is the direct request to retrieve recent messages before a given message and is currently unused, in order to call this directly /// remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func messagesBefore( + public static func preparedMessagesBefore( _ db: Database, messageId: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, [Message]), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData<[Message]> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .roomMessagesBefore(roomToken, id: messageId) ), + responseType: [Message].self, using: dependencies ) - .decoded(as: [Message].self, using: dependencies) } /// **Note:** This is the direct request to retrieve messages since a given message `seqNo` so should be retrieved automatically from the /// `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the /// `OpenGroupManager.handleMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func messagesSince( + public static func preparedMessagesSince( _ db: Database, seqNo: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, [Message]), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData<[Message]> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, @@ -647,9 +615,9 @@ public enum OpenGroupAPI { .reactors: "20" ] ), + responseType: [Message].self, using: dependencies ) - .decoded(as: [Message].self, using: dependencies) } /// Deletes all messages from a given sessionId within the provided rooms (or globally) on a server @@ -665,148 +633,134 @@ public enum OpenGroupAPI { /// - server: The server to delete messages from /// /// - dependencies: Injected dependencies (used for unit testing) - public static func messagesDeleteAll( + public static func preparedMessagesDeleteAll( _ db: Database, sessionId: String, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .delete, server: server, endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) ), + responseType: NoResponse.self, using: dependencies ) } // MARK: - Reactions - public static func reactors( + public static func preparedReactors( _ db: Database, emoji: String, id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher { + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - return Fail(error: OpenGroupAPIError.invalidEmoji) - .eraseToAnyPublisher() + throw OpenGroupAPIError.invalidEmoji } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .get, server: server, endpoint: .reactors(roomToken, id: id, emoji: encodedEmoji) ), + responseType: NoResponse.self, using: dependencies ) - .map { responseInfo, _ in responseInfo } - .eraseToAnyPublisher() } - public static func reactionAdd( + public static func preparedReactionAdd( _ db: Database, emoji: String, id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, ReactionAddResponse), Error> { + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - return Fail(error: OpenGroupAPIError.invalidEmoji) - .eraseToAnyPublisher() + throw OpenGroupAPIError.invalidEmoji } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .put, server: server, endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji) ), + responseType: ReactionAddResponse.self, using: dependencies ) - .decoded(as: ReactionAddResponse.self, using: dependencies) } - public static func reactionDelete( + public static func preparedReactionDelete( _ db: Database, emoji: String, id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, ReactionRemoveResponse), Error> { + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - return Fail(error: OpenGroupAPIError.invalidEmoji) - .eraseToAnyPublisher() + throw OpenGroupAPIError.invalidEmoji } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .delete, server: server, endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji) ), + responseType: ReactionRemoveResponse.self, using: dependencies ) - .decoded(as: ReactionRemoveResponse.self, using: dependencies) } - public static func reactionDeleteAll( + public static func preparedReactionDeleteAll( _ db: Database, emoji: String, id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, ReactionRemoveAllResponse), Error> { + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - return Fail(error: OpenGroupAPIError.invalidEmoji) - .eraseToAnyPublisher() + throw OpenGroupAPIError.invalidEmoji } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .delete, server: server, endpoint: .reactionDelete(roomToken, id: id, emoji: encodedEmoji) ), + responseType: ReactionRemoveAllResponse.self, using: dependencies ) - .decoded(as: ReactionRemoveAllResponse.self, using: dependencies) } // MARK: - Pinning @@ -821,94 +775,83 @@ public enum OpenGroupAPI { /// Pinned messages that are already pinned will be re-pinned (that is, their pin timestamp and pinning admin user will be updated) - because pinned /// messages are returned in pinning-order this allows admins to order multiple pinned messages in a room by re-pinning (via this endpoint) in the /// order in which pinned messages should be displayed - public static func pinMessage( + public static func preparedPinMessage( _ db: Database, id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, server: server, endpoint: .roomPinMessage(roomToken, id: id) ), + responseType: NoResponse.self, using: dependencies ) - .map { responseInfo, _ in responseInfo } - .eraseToAnyPublisher() } /// Remove a message from this room's pinned message list /// /// The user must have `admin` (not just `moderator`) permissions in the room - public static func unpinMessage( + public static func preparedUnpinMessage( _ db: Database, id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, server: server, endpoint: .roomUnpinMessage(roomToken, id: id) ), + responseType: NoResponse.self, using: dependencies ) - .map { responseInfo, _ in responseInfo } - .eraseToAnyPublisher() } - + /// Removes _all_ pinned messages from this room /// /// The user must have `admin` (not just `moderator`) permissions in the room - public static func unpinAll( + public static func preparedUnpinAll( _ db: Database, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, server: server, endpoint: .roomUnpinAll(roomToken) ), + responseType: NoResponse.self, using: dependencies ) - .map { responseInfo, _ in responseInfo } - .eraseToAnyPublisher() } // MARK: - Files - public static func uploadFile( + public static func preparedUploadFile( _ db: Database, bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, FileUploadResponse), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -922,37 +865,30 @@ public enum OpenGroupAPI { ], body: bytes ), + responseType: FileUploadResponse.self, timeout: FileServerAPI.fileUploadTimeout, using: dependencies ) - .decoded(as: FileUploadResponse.self, using: dependencies) } - public static func downloadFile( + public static func preparedDownloadFile( _ db: Database, fileId: String, from roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, Data), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .roomFileIndividual(roomToken, fileId) ), + responseType: Data.self, timeout: FileServerAPI.fileDownloadTimeout, using: dependencies ) - .tryMap { responseInfo, maybeData -> (ResponseInfoType, Data) in - guard let data: Data = maybeData else { throw HTTPError.parsingFailed } - - return (responseInfo, data) - } - .eraseToAnyPublisher() } // MARK: - Inbox/Outbox (Message Requests) @@ -963,23 +899,21 @@ public enum OpenGroupAPI { /// method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the /// `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func inbox( + public static func preparedInbox( _ db: Database, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, [DirectMessage]?), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData<[DirectMessage]?> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .inbox ), + responseType: [DirectMessage]?.self, using: dependencies ) - .decoded(as: [DirectMessage]?.self, using: dependencies) } /// Polls for any DMs received since the given id, this method will return a `304` with an empty response if there are no messages @@ -988,40 +922,36 @@ public enum OpenGroupAPI { /// automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response /// of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func inboxSince( + public static func preparedInboxSince( _ db: Database, id: Int64, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, [DirectMessage]?), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData<[DirectMessage]?> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .inboxSince(id: id) ), + responseType: [DirectMessage]?.self, using: dependencies ) - .decoded(as: [DirectMessage]?.self, using: dependencies) } /// Delivers a direct message to a user via their blinded Session ID /// /// The body of this request is a JSON object containing a message key with a value of the encrypted-then-base64-encoded message to deliver - public static func send( + public static func preparedSend( _ db: Database, ciphertext: Data, toInboxFor blindedSessionId: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, SendDirectMessageResponse), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -1031,9 +961,9 @@ public enum OpenGroupAPI { message: ciphertext ) ), + responseType: SendDirectMessageResponse.self, using: dependencies ) - .decoded(as: SendDirectMessageResponse.self, using: dependencies) } /// Retrieves all of the user's sent DMs (up to limit) @@ -1042,23 +972,21 @@ public enum OpenGroupAPI { /// from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of /// this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func outbox( + public static func preparedOutbox( _ db: Database, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, [DirectMessage]?), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData<[DirectMessage]?> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .outbox ), + responseType: [DirectMessage]?.self, using: dependencies ) - .decoded(as: [DirectMessage]?.self, using: dependencies) } /// Polls for any DMs sent since the given id, this method will return a `304` with an empty response if there are no messages @@ -1067,24 +995,22 @@ public enum OpenGroupAPI { /// should be retrieved automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure /// to route the response of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") - public static func outboxSince( + public static func preparedOutboxSince( _ db: Database, id: Int64, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, [DirectMessage]?), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData<[DirectMessage]?> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .outboxSince(id: id) ), + responseType: [DirectMessage]?.self, using: dependencies ) - .decoded(as: [DirectMessage]?.self, using: dependencies) } // MARK: - Users @@ -1120,18 +1046,16 @@ public enum OpenGroupAPI { /// - server: The server to delete messages from /// /// - dependencies: Injected dependencies (used for unit testing) - public static func userBan( + public static func preparedUserBan( _ db: Database, sessionId: String, for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -1143,6 +1067,7 @@ public enum OpenGroupAPI { timeout: timeout ) ), + responseType: NoResponse.self, using: dependencies ) } @@ -1171,17 +1096,15 @@ public enum OpenGroupAPI { /// - server: The server to delete messages from /// /// - dependencies: Injected dependencies (used for unit testing) - public static func userUnban( + public static func preparedUserUnban( _ db: Database, sessionId: String, from roomTokens: [String]?, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - return OpenGroupAPI - .send( + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -1192,6 +1115,7 @@ public enum OpenGroupAPI { global: (roomTokens == nil ? true : nil) ) ), + responseType: NoResponse.self, using: dependencies ) } @@ -1247,7 +1171,7 @@ public enum OpenGroupAPI { /// - server: The server to perform the permission changes on /// /// - dependencies: Injected dependencies (used for unit testing) - public static func userModeratorUpdate( + public static func preparedUserModeratorUpdate( _ db: Database, sessionId: String, moderator: Bool? = nil, @@ -1255,17 +1179,14 @@ public enum OpenGroupAPI { visible: Bool, for roomTokens: [String]?, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) - ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { - return Fail(error: HTTPError.generic) - .eraseToAnyPublisher() + throw HTTPError.generic } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -1279,6 +1200,7 @@ public enum OpenGroupAPI { visible: visible ) ), + responseType: NoResponse.self, using: dependencies ) } @@ -1290,9 +1212,7 @@ public enum OpenGroupAPI { sessionId: String, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) + using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: ResponseInfoType]), Error> { let banRequestBody: UserBanRequest = UserBanRequest( rooms: [roomToken], @@ -1344,9 +1264,7 @@ public enum OpenGroupAPI { for serverName: String, fallbackSigningType signingType: SessionId.Prefix, forceBlinded: Bool = false, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) + using dependencies: SMKDependencies = SMKDependencies() ) -> (publicKey: String, signature: Bytes)? { guard let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), @@ -1413,9 +1331,7 @@ public enum OpenGroupAPI { for serverName: String, with serverPublicKey: String, forceBlinded: Bool = false, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) + using dependencies: SMKDependencies = SMKDependencies() ) -> URLRequest? { guard let url: URL = request.url else { return nil } @@ -1472,14 +1388,44 @@ public enum OpenGroupAPI { // MARK: - Convenience + private static func prepareSendData( + _ db: Database, + request: Request, + responseType: R.Type, + forceBlinded: Bool = false, + timeout: TimeInterval = HTTP.defaultTimeout, + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + let urlRequest: URLRequest = try request.generateUrlRequest() + let maybePublicKey: String? = try? OpenGroup + .select(.publicKey) + .filter(OpenGroup.Columns.server == request.server.lowercased()) + .asRequest(of: String.self) + .fetchOne(db) + + guard let publicKey: String = maybePublicKey else { throw OpenGroupAPIError.noPublicKey } + + // Attempt to sign the request with the new auth + guard let signedRequest: URLRequest = sign(db, request: urlRequest, for: request.server, with: publicKey, forceBlinded: forceBlinded, using: dependencies) else { + throw OpenGroupAPIError.signingFailed + } + + return PreparedSendData( + request: signedRequest, + endpoint: request.endpoint, + server: request.server, + publicKey: publicKey, + responseType: responseType, + timeout: timeout + ) + } + private static func send( _ db: Database, request: Request, forceBlinded: Bool = false, timeout: TimeInterval = HTTP.defaultTimeout, - using dependencies: SMKDependencies = SMKDependencies( - queue: OpenGroupAPI.workQueue - ) + using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { let urlRequest: URLRequest @@ -1511,8 +1457,27 @@ public enum OpenGroupAPI { // We want to avoid blocking the db write thread so we dispatch the API call to a different thread return Just(()) .setFailureType(to: Error.self) - .subscribe(on: dependencies.queue) .flatMap { dependencies.onionApi.sendOnionRequest(signedRequest, to: request.server, with: publicKey, timeout: timeout) } .eraseToAnyPublisher() } + + public static func send( + data: PreparedSendData?, + using dependencies: SMKDependencies = SMKDependencies() + ) -> AnyPublisher<(ResponseInfoType, R), Error> { + guard let validData: PreparedSendData = data else { + return Fail(error: OpenGroupAPIError.invalidPreparedData) + .eraseToAnyPublisher() + } + + return dependencies.onionApi + .sendOnionRequest( + validData.request, + to: validData.server, + with: validData.publicKey, + timeout: validData.timeout + ) + .decoded(with: validData, using: dependencies) + .eraseToAnyPublisher() + } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 0cb6f4a6b..08b585bd5 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -296,8 +296,6 @@ public final class OpenGroupManager { ) } } - .subscribe(on: OpenGroupAPI.workQueue) - .receive(on: OpenGroupAPI.workQueue) .flatMap { response -> Future in Future { resolver in dependencies.storage.write { db in @@ -347,7 +345,7 @@ public final class OpenGroupManager { _ db: Database, openGroupId: String, calledFromConfigHandling: Bool, - dependencies: OGMDependencies = OGMDependencies() + using dependencies: OGMDependencies = OGMDependencies() ) { let server: String? = try? OpenGroup .select(.server) @@ -907,26 +905,28 @@ public final class OpenGroupManager { } /// This method specifies if the given capability is supported on a specified Open Group - public static func isOpenGroupSupport( - _ capability: Capability.Variant, + public static func doesOpenGroupSupport( + _ db: Database? = nil, + capability: Capability.Variant, on server: String?, using dependencies: OGMDependencies = OGMDependencies() ) -> Bool { guard let server: String = server else { return false } + guard let db: Database = db else { + return dependencies.storage + .read { db in doesOpenGroupSupport(db, capability: capability, on: server, using: dependencies) } + .defaulting(to: false) + } - return dependencies.storage - .read { db in - 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: []) + 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) - } - .defaulting(to: false) + return capabilities.contains(capability) } /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group @@ -1012,7 +1012,10 @@ public final class OpenGroupManager { } @discardableResult public static func getDefaultRoomsIfNeeded( - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: OGMDependencies = OGMDependencies( + subscribeQueue: OpenGroupAPI.workQueue, + receiveQueue: OpenGroupAPI.workQueue + ) ) -> AnyPublisher<[OpenGroupAPI.Room], Error> { // Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again if let existingPublisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.cache.defaultRoomsPublisher { @@ -1028,8 +1031,8 @@ public final class OpenGroupManager { using: dependencies ) } - .subscribe(on: OpenGroupAPI.workQueue) - .receive(on: OpenGroupAPI.workQueue) + .subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true) + .receive(on: dependencies.receiveQueue, immediatelyIfMain: true) .retry(8) .map { response in dependencies.storage.writeAsync { db in @@ -1077,7 +1080,6 @@ public final class OpenGroupManager { on: OpenGroupAPI.defaultServer, using: dependencies ) - .sinkUntilComplete() } } @@ -1107,13 +1109,13 @@ public final class OpenGroupManager { return publisher } - public static func roomImage( + @discardableResult public static func roomImage( _ db: Database, fileId: String, for roomToken: String, on server: String, using dependencies: OGMDependencies = OGMDependencies( - queue: DispatchQueue.global(qos: .background) + subscribeQueue: .global(qos: .background) ) ) -> AnyPublisher { // Normally the image for a given group is stored with the group thread, so it's only @@ -1149,37 +1151,63 @@ public final class OpenGroupManager { return publisher } - // Trigger the download on a background queue - let publisher: AnyPublisher = OpenGroupAPI - .downloadFile( - db, - fileId: fileId, - from: roomToken, - on: server, - using: dependencies - ) - .map { _, imageData in - if server.lowercased() == OpenGroupAPI.defaultServer { - dependencies.storage.write { db in - _ = try OpenGroup - .filter(id: threadId) - .updateAll(db, OpenGroup.Columns.imageData.set(to: imageData)) - } - dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] = now + let sendData: OpenGroupAPI.PreparedSendData + + do { + sendData = try OpenGroupAPI + .preparedDownloadFile( + db, + fileId: fileId, + from: roomToken, + on: server, + using: dependencies + ) + } + catch { + return Fail(error: error) + .eraseToAnyPublisher() + } + + // Defer the actual download and run it on a separate thread to avoid blocking the calling thread + let publisher: AnyPublisher = Deferred { + Future { resolver in + dependencies.subscribeQueue.async { + // Hold on to the publisher until it has completed at least once + OpenGroupAPI + .send( + data: sendData, + using: dependencies + ) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): resolver(Result.failure(error)) + } + }, + receiveValue: { _, imageData in + if server.lowercased() == OpenGroupAPI.defaultServer { + dependencies.storage.write { db in + _ = try OpenGroup + .filter(id: threadId) + .updateAll(db, OpenGroup.Columns.imageData.set(to: imageData)) + } + dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] = now + } + + resolver(Result.success(imageData)) + } + ) } - - return imageData } - .shareReplay(1) - .eraseToAnyPublisher() + } + .shareReplay(1) + .eraseToAnyPublisher() dependencies.mutableCache.mutate { cache in cache.groupImagePublishers[threadId] = publisher } - // Hold on to the publisher until it has completed at least once - publisher.sinkUntilComplete() - return publisher } } @@ -1198,7 +1226,8 @@ extension OpenGroupManager { public var cache: OGMCacheType { return mutableCache.wrappedValue } public init( - queue: DispatchQueue? = nil, + subscribeQueue: DispatchQueue? = nil, + receiveQueue: DispatchQueue? = nil, cache: Atomic? = nil, onionApi: OnionRequestAPIType.Type? = nil, generalCache: Atomic? = nil, @@ -1218,7 +1247,8 @@ extension OpenGroupManager { _mutableCache = Atomic(cache) super.init( - queue: queue, + subscribeQueue: subscribeQueue, + receiveQueue: receiveQueue, onionApi: onionApi, generalCache: generalCache, storage: storage, diff --git a/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift b/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift index fa427f86f..a86383993 100644 --- a/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift +++ b/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift @@ -7,6 +7,7 @@ public enum OpenGroupAPIError: LocalizedError { case signingFailed case noPublicKey case invalidEmoji + case invalidPreparedData public var errorDescription: String? { switch self { @@ -14,6 +15,7 @@ public enum OpenGroupAPIError: LocalizedError { case .signingFailed: return "Couldn't sign message." case .noPublicKey: return "Couldn't find server public key." case .invalidEmoji: return "The emoji is invalid." + case .invalidPreparedData: return "Invalid PreparedSendData provided." } } } diff --git a/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift b/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift new file mode 100644 index 000000000..f2798326f --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift @@ -0,0 +1,125 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import SessionUtilitiesKit + +public extension OpenGroupAPI { + struct PreparedSendData { + internal let request: URLRequest + internal let endpoint: Endpoint + internal let server: String + internal let publicKey: String + internal let originalType: Decodable.Type + internal let responseType: R.Type + internal let timeout: TimeInterval + internal let responseConverter: ((ResponseInfoType, Any) throws -> R) + + internal init( + request: URLRequest, + endpoint: Endpoint, + server: String, + publicKey: String, + responseType: R.Type, + timeout: TimeInterval + ) where R: Decodable { + self.request = request + self.endpoint = endpoint + self.server = server + self.publicKey = publicKey + self.originalType = responseType + self.responseType = responseType + self.timeout = timeout + self.responseConverter = { _, response in + guard let validResponse: R = response as? R else { throw HTTPError.invalidResponse } + + return validResponse + } + } + + private init( + request: URLRequest, + endpoint: Endpoint, + server: String, + publicKey: String, + originalType: U.Type, + responseType: R.Type, + timeout: TimeInterval, + responseConverter: @escaping (ResponseInfoType, Any) throws -> R + ) { + self.request = request + self.endpoint = endpoint + self.server = server + self.publicKey = publicKey + self.originalType = originalType + self.responseType = responseType + self.timeout = timeout + self.responseConverter = responseConverter + } + } +} + +public extension OpenGroupAPI.PreparedSendData { + func map(transform: @escaping (ResponseInfoType, R) throws -> O) -> OpenGroupAPI.PreparedSendData { + return OpenGroupAPI.PreparedSendData( + request: request, + endpoint: endpoint, + server: server, + publicKey: publicKey, + originalType: originalType, + responseType: O.self, + timeout: timeout, + responseConverter: { info, response in + let validResponse: R = try responseConverter(info, response) + + return try transform(info, validResponse) + } + ) + } +} + +// MARK: - Convenience + +public extension Publisher where Output == (ResponseInfoType, Data?), Failure == Error { + func decoded( + with preparedData: OpenGroupAPI.PreparedSendData, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher<(ResponseInfoType, R), Error> { + self + .tryMap { responseInfo, maybeData -> (ResponseInfoType, R) in + // Depending on the 'originalType' we need to process the response differently + let targetData: Any = try { + switch preparedData.originalType { + case is NoResponse.Type: return NoResponse() + case is Optional.Type: return maybeData as Any + case is Data.Type: return try maybeData ?? { throw HTTPError.parsingFailed }() + + case is _OptionalProtocol.Type: + guard let data: Data = maybeData else { return maybeData as Any } + + return try preparedData.originalType.decoded(from: data, using: dependencies) + + default: + guard let data: Data = maybeData else { throw HTTPError.parsingFailed } + + return try preparedData.originalType.decoded(from: data, using: dependencies) + } + }() + + // Generate and return the converted data + let convertedData: R = try preparedData.responseConverter(responseInfo, targetData) + + return (responseInfo, convertedData) + } + .eraseToAnyPublisher() + } +} + +// MARK: - _OptionalProtocol + +/// This protocol should only be used within this file and is used to distinguish between `Any.Type` and `Optional.Type` as +/// it seems that `is Optional.Type` doesn't work nicely but this protocol works nicely as long as the case is under any explicit +/// `Optional` handling that we need +private protocol _OptionalProtocol {} + +extension Optional: _OptionalProtocol {} diff --git a/SessionMessagingKit/SMKDependencies.swift b/SessionMessagingKit/SMKDependencies.swift index fa66b4b6a..8b763356e 100644 --- a/SessionMessagingKit/SMKDependencies.swift +++ b/SessionMessagingKit/SMKDependencies.swift @@ -1,4 +1,5 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import Foundation import GRDB import Sodium @@ -57,7 +58,8 @@ public class SMKDependencies: SSKDependencies { // MARK: - Initialization public init( - queue: DispatchQueue? = nil, + subscribeQueue: DispatchQueue? = nil, + receiveQueue: DispatchQueue? = nil, onionApi: OnionRequestAPIType.Type? = nil, generalCache: Atomic? = nil, storage: Storage? = nil, @@ -83,7 +85,8 @@ public class SMKDependencies: SSKDependencies { _nonceGenerator24 = Atomic(nonceGenerator24) super.init( - queue: queue, + subscribeQueue: subscribeQueue, + receiveQueue: receiveQueue, onionApi: onionApi, generalCache: generalCache, storage: storage, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index 8737f2643..6497f7e3e 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -64,12 +64,14 @@ extension MessageReceiver { let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: sender, variant: .contact, shouldBeVisible: nil) - Environment.shared?.notificationsManager.wrappedValue? - .notifyUser( - db, - forIncomingCall: interaction, - in: thread - ) + if !interaction.wasRead { + Environment.shared?.notificationsManager.wrappedValue? + .notifyUser( + db, + forIncomingCall: interaction, + in: thread + ) + } } return } @@ -79,12 +81,14 @@ extension MessageReceiver { let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: sender, variant: .contact, shouldBeVisible: nil) - Environment.shared?.notificationsManager.wrappedValue? - .notifyUser( - db, - forIncomingCall: interaction, - in: thread - ) + if !interaction.wasRead { + Environment.shared?.notificationsManager.wrappedValue? + .notifyUser( + db, + forIncomingCall: interaction, + in: thread + ) + } // Trigger the missed call UI if needed NotificationCenter.default.post( @@ -196,6 +200,10 @@ extension MessageReceiver { SNLog("[Calls] Sending end call message because there is an ongoing call.") + let messageSentTimestamp: Int64 = ( + message.sentTimestamp.map { Int64($0) } ?? + SnodeAPI.currentOffsetTimestampMs() + ) _ = try Interaction( serverHash: message.serverHash, messageUuid: message.uuid, @@ -203,9 +211,13 @@ extension MessageReceiver { authorId: caller, variant: .infoCall, body: String(data: messageInfoData, encoding: .utf8), - timestampMs: ( - message.sentTimestamp.map { Int64($0) } ?? - SnodeAPI.currentOffsetTimestampMs() + timestampMs: messageSentTimestamp, + wasRead: SessionUtil.timestampAlreadyRead( + threadId: thread.id, + threadVariant: thread.variant, + timestampMs: (messageSentTimestamp * 1000), + userPublicKey: getUserHexEncodedPublicKey(db), + openGroup: nil ) ) .inserted(db) @@ -227,6 +239,7 @@ extension MessageReceiver { interactionId: nil // Explicitly nil as it's a separate message from above ) ) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete() } @@ -246,9 +259,10 @@ extension MessageReceiver { !thread.isMessageRequest(db) else { return nil } + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo( state: state.defaulting( - to: (sender == getUserHexEncodedPublicKey(db) ? + to: (sender == currentUserPublicKey ? .outgoing : .incoming ) @@ -268,7 +282,14 @@ extension MessageReceiver { authorId: sender, variant: .infoCall, body: String(data: messageInfoData, encoding: .utf8), - timestampMs: timestampMs + timestampMs: timestampMs, + wasRead: SessionUtil.timestampAlreadyRead( + threadId: thread.id, + threadVariant: thread.variant, + timestampMs: (timestampMs * 1000), + userPublicKey: currentUserPublicKey, + openGroup: nil + ) ).inserted(db) } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift index 48b91fba4..db91fb8ea 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift @@ -3,6 +3,7 @@ import Foundation import GRDB import SessionSnodeKit +import SessionUtilitiesKit extension MessageReceiver { internal static func handleDataExtractionNotification( @@ -17,6 +18,10 @@ extension MessageReceiver { let messageKind: DataExtractionNotification.Kind = message.kind else { throw MessageReceiverError.invalidMessage } + let timestampMs: Int64 = ( + message.sentTimestamp.map { Int64($0) } ?? + SnodeAPI.currentOffsetTimestampMs() + ) _ = try Interaction( serverHash: message.serverHash, threadId: threadId, @@ -27,9 +32,13 @@ extension MessageReceiver { case .mediaSaved: return .infoMediaSavedNotification } }(), - timestampMs: ( - message.sentTimestamp.map { Int64($0) } ?? - SnodeAPI.currentOffsetTimestampMs() + timestampMs: timestampMs, + wasRead: SessionUtil.timestampAlreadyRead( + threadId: threadId, + threadVariant: threadVariant, + timestampMs: (timestampMs * 1000), + userPublicKey: getUserHexEncodedPublicKey(db), + openGroup: nil ) ).inserted(db) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift index fbec8536b..60a95f4ca 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift @@ -71,18 +71,26 @@ extension MessageReceiver { } // Add an info message for the user + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) _ = try Interaction( serverHash: nil, // Intentionally null so sync messages are seen as duplicates threadId: threadId, authorId: sender, variant: .infoDisappearingMessagesUpdate, body: config.messageInfoString( - with: (sender != getUserHexEncodedPublicKey(db) ? + with: (sender != currentUserPublicKey ? Profile.displayName(db, id: sender) : nil ) ), - timestampMs: timestampMs + timestampMs: timestampMs, + wasRead: SessionUtil.timestampAlreadyRead( + threadId: threadId, + threadVariant: threadVariant, + timestampMs: (timestampMs * 1000), + userPublicKey: currentUserPublicKey, + openGroup: nil + ) ).inserted(db) // Only save the updated config if we can perform the change diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift index 433c1734f..ea80c22ff 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift @@ -48,6 +48,7 @@ extension MessageReceiver { publicKey: author, serverHashes: [serverHash] ) + .subscribe(on: DispatchQueue.global(qos: .background)) .sinkUntilComplete() } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 1fc33de52..2958b3548 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -117,7 +117,8 @@ extension MessageReceiver { message: message, associatedWithProto: proto, sender: sender, - messageSentTimestamp: messageSentTimestamp + messageSentTimestamp: messageSentTimestamp, + openGroup: maybeOpenGroup ) { return interactionId } @@ -323,7 +324,7 @@ extension MessageReceiver { } // Notify the user if needed - guard variant == .standardIncoming else { return interactionId } + guard variant == .standardIncoming && !interaction.wasRead else { return interactionId } // Use the same identifier for notifications when in backgroud polling to prevent spam Environment.shared?.notificationsManager.wrappedValue? @@ -342,7 +343,8 @@ extension MessageReceiver { message: VisibleMessage, associatedWithProto proto: SNProtoContent, sender: String, - messageSentTimestamp: TimeInterval + messageSentTimestamp: TimeInterval, + openGroup: OpenGroup? ) throws -> Int64? { guard let reaction: VisibleMessage.VMReaction = message.reaction, @@ -370,17 +372,28 @@ extension MessageReceiver { switch reaction.kind { case .react: + let timestampMs: Int64 = Int64(messageSentTimestamp * 1000) + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) let reaction: Reaction = try Reaction( interactionId: interactionId, serverHash: message.serverHash, - timestampMs: Int64(messageSentTimestamp * 1000), + timestampMs: timestampMs, authorId: sender, emoji: reaction.emoji, count: 1, sortId: sortId ).inserted(db) + let timestampAlreadyRead: Bool = SessionUtil.timestampAlreadyRead( + threadId: thread.id, + threadVariant: thread.variant, + timestampMs: timestampMs, + userPublicKey: currentUserPublicKey, + openGroup: openGroup + ) - if sender != getUserHexEncodedPublicKey(db) { + // Don't notify if the reaction was added before the lastest read timestamp for + // the conversation + if sender != currentUserPublicKey && !timestampAlreadyRead { Environment.shared?.notificationsManager.wrappedValue? .notifyUser( db, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 4c6421b34..f0e139d35 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -108,10 +108,7 @@ extension MessageSender { ) } - public static func performUploadsIfNeeded( - queue: DispatchQueue, - preparedSendData: PreparedSendData - ) -> AnyPublisher { + public static func performUploadsIfNeeded(preparedSendData: PreparedSendData) -> AnyPublisher { // We need an interactionId in order for a message to have uploads guard let interactionId: Int64 = preparedSendData.interactionId else { return Just(preparedSendData) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 1da13d3ec..9005bf1b8 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -643,7 +643,6 @@ public final class MessageSender { snodeMessage, in: namespace ) - .subscribe(on: DispatchQueue.global(qos: .default)) .flatMap { response -> AnyPublisher in let updatedMessage: Message = message updatedMessage.serverHash = response.1.hash @@ -703,7 +702,7 @@ public final class MessageSender { Future { resolver in NotifyPushServerJob.run( job, - queue: DispatchQueue.global(qos: .default), + queue: .global(qos: .default), success: { _, _ in resolver(Result.success(())) }, failure: { _, _, _ in // Always fulfill because the notify PN server job isn't critical. @@ -760,9 +759,9 @@ public final class MessageSender { // Send the result return dependencies.storage - .readPublisherFlatMap { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: plaintext, to: roomToken, @@ -773,7 +772,7 @@ public final class MessageSender { using: dependencies ) } - .subscribe(on: DispatchQueue.global(qos: .default)) + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .flatMap { (responseInfo, responseData) -> AnyPublisher in let serverTimestampMs: UInt64? = responseData.posted.map { UInt64(floor($0 * 1000)) } let updatedMessage: Message = message @@ -828,9 +827,9 @@ public final class MessageSender { // Send the result return dependencies.storage - .readPublisherFlatMap { db in - return OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, ciphertext: ciphertext, toInboxFor: recipientBlindedPublicKey, @@ -838,7 +837,7 @@ public final class MessageSender { using: dependencies ) } - .subscribe(on: DispatchQueue.global(qos: .default)) + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .flatMap { (responseInfo, responseData) -> AnyPublisher in let updatedMessage: Message = message updatedMessage.openGroupServerMessageId = UInt64(responseData.id) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index c84258b4a..b752b6464 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -92,12 +92,16 @@ public final class ClosedGroupPoller: Poller { .eraseToAnyPublisher() } - override func handlePollError(_ error: Error, for publicKey: String) { + override func handlePollError( + _ error: Error, + for publicKey: String, + using dependencies: SMKDependencies = SMKDependencies() + ) { SNLog("Polling failed for closed group with public key: \(publicKey) due to error: \(error).") // Try to restart the poller from scratch Threading.pollerQueue.async { [weak self] in - self?.setUpPolling(for: publicKey) + self?.setUpPolling(for: publicKey, using: dependencies) } } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift index e4b07e678..b144f9479 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift @@ -100,7 +100,11 @@ public final class CurrentUserPoller: Poller { .eraseToAnyPublisher() } - override func handlePollError(_ error: Error, for publicKey: String) { + override func handlePollError( + _ error: Error, + for publicKey: String, + using dependencies: SMKDependencies = SMKDependencies() + ) { if UserDefaults.sharedLokiProject?[.isMainAppActive] != true { // Do nothing when an error gets throws right after returning from the background (happens frequently) } @@ -115,7 +119,7 @@ public final class CurrentUserPoller: Poller { // Try to restart the poller from scratch Threading.pollerQueue.async { [weak self] in - self?.setUpPolling(for: publicKey) + self?.setUpPolling(for: publicKey, using: dependencies) } } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 7458ca7a8..975a31770 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -18,8 +18,16 @@ extension OpenGroupAPI { // MARK: - Settings private static let minPollInterval: TimeInterval = 3 - private static let maxPollInterval: Double = (60 * 60) - internal static let maxInactivityPeriod: Double = (14 * 24 * 60 * 60) + private static let maxPollInterval: TimeInterval = (60 * 60) + internal static let maxInactivityPeriod: TimeInterval = (14 * 24 * 60 * 60) + + /// If there are hidden rooms that we poll and they fail too many times we want to prune them (as it likely means they no longer + /// exist, and since they are already hidden it's unlikely that the user will notice that we stopped polling for them) + internal static let maxHiddenRoomFailureCount: Int64 = 10 + + /// When doing a background poll we want to only fetch from rooms which are unlikely to timeout, in order to do this we exclude + /// any rooms which have failed more than this threashold + public static let maxRoomFailureCountForBackgroundPoll: Int64 = 15 // MARK: - Lifecycle @@ -41,7 +49,12 @@ extension OpenGroupAPI { // MARK: - Polling - private func pollRecursively(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) { + private func pollRecursively( + using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies( + subscribeQueue: Threading.pollerQueue, + receiveQueue: OpenGroupAPI.workQueue + ) + ) { guard hasStarted else { return } let minPollFailureCount: TimeInterval = Storage.shared @@ -59,6 +72,8 @@ extension OpenGroupAPI { // Wait until the last poll completes before polling again ensuring we don't poll any faster than // the 'nextPollInterval' value poll(using: dependencies) + .subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true) + .receive(on: dependencies.receiveQueue, immediatelyIfMain: true) .sinkUntilComplete( receiveCompletion: { [weak self] _ in let currentTime: TimeInterval = Date().timeIntervalSince1970 @@ -129,8 +144,6 @@ extension OpenGroupAPI { .map { response in (failureCount, response) } .eraseToAnyPublisher() } - .subscribe(on: Threading.pollerQueue) - .receive(on: Threading.pollerQueue) .handleEvents( receiveOutput: { [weak self] failureCount, response in guard !calledFromBackgroundPoller || isBackgroundPollerValid() else { @@ -179,7 +192,8 @@ extension OpenGroupAPI { calledFromBackgroundPoller: calledFromBackgroundPoller, isBackgroundPollerValid: isBackgroundPollerValid, isPostCapabilitiesRetry: isPostCapabilitiesRetry, - error: error + error: error, + using: dependencies ) .handleEvents( receiveOutput: { [weak self] didHandleError in @@ -232,7 +246,7 @@ extension OpenGroupAPI { /// If the polling has failed 10+ times then try to prune any invalid rooms that /// aren't visible (they would have been added via config messages and will /// likely always fail but the user has no way to delete them) - guard pollFailureCount > 10 else { return } + guard pollFailureCount > Poller.maxHiddenRoomFailureCount else { return } prunedIds = roomsAreVisible .filter { !$0.shouldBeVisible } @@ -247,19 +261,20 @@ extension OpenGroupAPI { /// not be in an invalid state on other devices - one of the other devices /// will eventually trigger a new config update which will re-add this room /// and hopefully at that time it'll work again - calledFromConfigHandling: true + calledFromConfigHandling: true, + using: dependencies ) } } - SNLog("Open group polling failed due to error: \(error). Setting failure count to \(pollFailureCount).") + SNLog("Open group polling to \(server) failed due to error: \(error). Setting failure count to \(pollFailureCount).") // Add a note to the logs that this happened if !prunedIds.isEmpty { let rooms: String = prunedIds .compactMap { $0.components(separatedBy: server).last } .joined(separator: ", ") - SNLog("Hidden open group failure count surpassed 10, removed hidden rooms \(rooms).") + SNLog("Hidden open group failure count surpassed \(Poller.maxHiddenRoomFailureCount), removed hidden rooms \(rooms).") } } @@ -299,16 +314,15 @@ extension OpenGroupAPI { } return dependencies.storage - .readPublisherFlatMap { db in - OpenGroupAPI.capabilities( + .readPublisher { db in + try OpenGroupAPI.preparedCapabilities( db, server: server, forceBlinded: true, using: dependencies ) } - .subscribe(on: OpenGroupAPI.workQueue) - .receive(on: OpenGroupAPI.workQueue) + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .flatMap { [weak self] _, responseBody -> AnyPublisher in guard let strongSelf = self, isBackgroundPollerValid() else { return Just(()) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 8dbcbdd91..e69afe618 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -59,7 +59,7 @@ public class Poller { preconditionFailure("abstract class - override in subclass") } - internal func handlePollError(_ error: Error, for publicKey: String) { + internal func handlePollError(_ error: Error, for publicKey: String, using dependencies: SMKDependencies) { preconditionFailure("abstract class - override in subclass") } @@ -81,37 +81,46 @@ public class Poller { /// We want to initially trigger a poll against the target service node and then run the recursive polling, /// if an error is thrown during the poll then this should automatically restart the polling - internal func setUpPolling(for publicKey: String) { + internal func setUpPolling( + for publicKey: String, + using dependencies: SMKDependencies = SMKDependencies( + subscribeQueue: Threading.pollerQueue, + receiveQueue: Threading.pollerQueue + ) + ) { guard isPolling.wrappedValue[publicKey] == true else { return } let namespaces: [SnodeAPI.Namespace] = self.namespaces getSnodeForPolling(for: publicKey) - .subscribe(on: Threading.pollerQueue) + .subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true) .flatMap { snode -> AnyPublisher<[Message], Error> in Poller.poll( namespaces: namespaces, from: snode, for: publicKey, - on: Threading.pollerQueue, - poller: self + poller: self, + using: dependencies ) } - .receive(on: Threading.pollerQueue) + .receive(on: dependencies.receiveQueue, immediatelyIfMain: true) .sinkUntilComplete( receiveCompletion: { [weak self] result in switch result { - case .finished: self?.pollRecursively(for: publicKey) + case .finished: self?.pollRecursively(for: publicKey, using: dependencies) case .failure(let error): guard self?.isPolling.wrappedValue[publicKey] == true else { return } - self?.handlePollError(error, for: publicKey) + self?.handlePollError(error, for: publicKey, using: dependencies) } } ) } - private func pollRecursively(for publicKey: String) { + private func pollRecursively( + for publicKey: String, + using dependencies: SMKDependencies = SMKDependencies() + ) { guard isPolling.wrappedValue[publicKey] == true else { return } let namespaces: [SnodeAPI.Namespace] = self.namespaces @@ -125,21 +134,21 @@ public class Poller { timer.invalidate() self?.getSnodeForPolling(for: publicKey) - .subscribe(on: Threading.pollerQueue) + .subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true) .flatMap { snode -> AnyPublisher<[Message], Error> in Poller.poll( namespaces: namespaces, from: snode, for: publicKey, - on: Threading.pollerQueue, - poller: self + poller: self, + using: dependencies ) } - .receive(on: Threading.pollerQueue) + .receive(on: dependencies.receiveQueue, immediatelyIfMain: true) .sinkUntilComplete( receiveCompletion: { result in switch result { - case .failure(let error): self?.handlePollError(error, for: publicKey) + case .failure(let error): self?.handlePollError(error, for: publicKey, using: dependencies) case .finished: let maxNodePollCount: UInt = (self?.maxNodePollCount ?? 0) @@ -161,7 +170,7 @@ public class Poller { timer.invalidate() self?.pollCount.mutate { $0[publicKey] = 0 } - self?.setUpPolling(for: publicKey) + self?.setUpPolling(for: publicKey, using: dependencies) } } return @@ -169,7 +178,7 @@ public class Poller { } // Otherwise just loop - self?.pollRecursively(for: publicKey) + self?.pollRecursively(for: publicKey, using: dependencies) } } ) @@ -186,10 +195,12 @@ public class Poller { namespaces: [SnodeAPI.Namespace], from snode: Snode, for publicKey: String, - on queue: DispatchQueue, calledFromBackgroundPoller: Bool = false, isBackgroundPollValid: @escaping (() -> Bool) = { true }, - poller: Poller? = nil + poller: Poller? = nil, + using dependencies: SMKDependencies = SMKDependencies( + receiveQueue: Threading.pollerQueue + ) ) -> AnyPublisher<[Message], Error> { // If the polling has been cancelled then don't continue guard @@ -213,7 +224,8 @@ public class Poller { namespaces: namespaces, refreshingConfigHashes: configHashes, from: snode, - associatedWith: publicKey + associatedWith: publicKey, + using: dependencies ) .flatMap { namespacedResults -> AnyPublisher<[Message], Error> in guard @@ -391,7 +403,7 @@ public class Poller { // Note: In the background we just want jobs to fail silently ConfigMessageReceiveJob.run( job, - queue: queue, + queue: dependencies.receiveQueue, success: { _, _ in resolver(Result.success(())) }, failure: { _, _, _ in resolver(Result.success(())) }, deferred: { _ in resolver(Result.success(())) } @@ -411,7 +423,7 @@ public class Poller { // Note: In the background we just want jobs to fail silently MessageReceiveJob.run( job, - queue: queue, + queue: dependencies.receiveQueue, success: { _, _ in resolver(Result.success(())) }, failure: { _, _, _ in resolver(Result.success(())) }, deferred: { _ in resolver(Result.success(())) } diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift index 47964c504..677f5bd81 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift @@ -328,7 +328,7 @@ public extension SessionUtil { return false } - return (oneToOne.last_read > timestampMs) + return (oneToOne.last_read >= timestampMs) case .legacyGroup: var cThreadId: [CChar] = threadId.cArray.nullTerminated() @@ -338,7 +338,7 @@ public extension SessionUtil { return false } - return (legacyGroup.last_read > timestampMs) + return (legacyGroup.last_read >= timestampMs) case .community: guard let openGroup: OpenGroup = openGroup else { return false } @@ -351,7 +351,7 @@ public extension SessionUtil { return false } - return (convoCommunity.last_read > timestampMs) + return (convoCommunity.last_read >= timestampMs) case .group: return false } diff --git a/SessionMessagingKit/SessionUtil/SessionUtil.swift b/SessionMessagingKit/SessionUtil/SessionUtil.swift index adca3e50e..44eb2f20b 100644 --- a/SessionMessagingKit/SessionUtil/SessionUtil.swift +++ b/SessionMessagingKit/SessionUtil/SessionUtil.swift @@ -355,7 +355,6 @@ public enum SessionUtil { ) let seqNo: Int64 = cPushData.pointee.seqno cPushData.deallocate() - SNLog("[libSession - DEBUG] Push data for \(variant) config data, size: \(configCountInfo), bytes: \(pushData.count)") return OutgoingConfResult( message: SharedConfigMessage( diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index bc143b0e5..67d719d74 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -56,6 +56,15 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, 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 differenceIdentifier: Int64 { id } @@ -74,6 +83,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let rowId: Int64 public let id: Int64 + public let openGroupServerMessageId: Int64? public let variant: Interaction.Variant public let timestampMs: Int64 public let receivedAtTimestampMs: Int64 @@ -171,6 +181,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, threadContactNameInternal: self.threadContactNameInternal, rowId: self.rowId, id: self.id, + openGroupServerMessageId: self.openGroupServerMessageId, variant: self.variant, timestampMs: self.timestampMs, receivedAtTimestampMs: self.receivedAtTimestampMs, @@ -335,6 +346,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, threadContactNameInternal: self.threadContactNameInternal, rowId: self.rowId, id: self.id, + openGroupServerMessageId: self.openGroupServerMessageId, variant: self.variant, timestampMs: self.timestampMs, receivedAtTimestampMs: self.receivedAtTimestampMs, @@ -516,7 +528,7 @@ public extension MessageViewModel { static let genericId: Int64 = -1 static let typingIndicatorId: Int64 = -2 - // Note: This init method is only used system-created cells or empty states + /// This init method is only used for system-created cells or empty states init( variant: Interaction.Variant = .standardOutgoing, timestampMs: Int64 = Int64.max, @@ -546,6 +558,7 @@ public extension MessageViewModel { }() self.rowId = targetId self.id = targetId + self.openGroupServerMessageId = nil self.variant = variant self.timestampMs = timestampMs self.receivedAtTimestampMs = receivedAtTimestampMs @@ -567,11 +580,11 @@ public extension MessageViewModel { self.linkPreview = nil self.linkPreviewAttachment = nil self.currentUserPublicKey = "" + self.attachments = nil + self.reactionInfo = nil // Post-Query Processing Data - self.attachments = nil - self.reactionInfo = nil self.cellType = cellType self.authorName = "" self.senderName = nil @@ -587,6 +600,84 @@ public extension MessageViewModel { self.isLastOutgoing = isLastOutgoing self.currentUserBlindedPublicKey = nil } + + /// This init method is only used for optimistic outgoing messages + init( + threadId: String, + threadVariant: SessionThread.Variant, + threadHasDisappearingMessagesEnabled: Bool, + threadOpenGroupServer: String?, + threadOpenGroupPublicKey: String?, + threadContactNameInternal: String, + timestampMs: Int64, + receivedAtTimestampMs: Int64, + authorId: String, + authorNameInternal: String, + body: String?, + expiresStartedAtMs: Double?, + expiresInSeconds: TimeInterval?, + isSenderOpenGroupModerator: Bool, + currentUserProfile: Profile, + quote: Quote?, + quoteAttachment: Attachment?, + linkPreview: LinkPreview?, + linkPreviewAttachment: Attachment?, + attachments: [Attachment]? + ) { + self.threadId = threadId + self.threadVariant = threadVariant + self.threadIsTrusted = false + self.threadHasDisappearingMessagesEnabled = threadHasDisappearingMessagesEnabled + self.threadOpenGroupServer = threadOpenGroupServer + self.threadOpenGroupPublicKey = threadOpenGroupPublicKey + self.threadContactNameInternal = threadContactNameInternal + + // Interaction Info + + self.rowId = -1 + self.id = -1 + 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.state = .sending + self.hasAtLeastOneReadReceipt = false + self.mostRecentFailureText = nil + self.isSenderOpenGroupModerator = isSenderOpenGroupModerator + self.isTypingIndicator = false + self.profile = currentUserProfile + self.quote = quote + self.quoteAttachment = quoteAttachment + self.linkPreview = linkPreview + self.linkPreviewAttachment = linkPreviewAttachment + self.currentUserPublicKey = currentUserProfile.id + self.attachments = attachments + self.reactionInfo = nil + + // Post-Query Processing Data + + self.cellType = .textOnlyMessage + self.authorName = "" + 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.currentUserBlindedPublicKey = nil + } } // MARK: - Convenience @@ -688,7 +779,7 @@ public extension MessageViewModel { let interactionAttachmentAttachmentIdColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) let interactionAttachmentAlbumIndexColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) - let numColumnsBeforeLinkedRecords: Int = 21 + let numColumnsBeforeLinkedRecords: Int = 22 let finalGroupSQL: SQL = (groupSQL ?? "") let request: SQLRequest = """ SELECT @@ -704,6 +795,7 @@ public extension MessageViewModel { \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(interaction[.id]), + \(interaction[.openGroupServerMessageId]), \(interaction[.variant]), \(interaction[.timestampMs]), \(interaction[.receivedAtTimestampMs]), @@ -977,7 +1069,7 @@ public extension MessageViewModel.ReactionInfo { items: pagedRowIdsWithNoReactions .compactMap { rowId -> ViewModel? in updatedPagedDataCache.data[rowId] } .filter { viewModel -> Bool in (viewModel.reactionInfo?.isEmpty == false) } - .map { viewModel -> ViewModel in viewModel.with(reactionInfo: nil) } + .map { viewModel -> ViewModel in viewModel.with(reactionInfo: []) } ) return updatedPagedDataCache diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 20508b0a6..c11af914e 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -33,6 +33,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public static let threadWasMarkedUnreadKey: SQL = SQL(stringLiteral: CodingKeys.threadWasMarkedUnread.stringValue) public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue) public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue) + public static let disappearingMessagesConfigurationKey: SQL = SQL(stringLiteral: CodingKeys.disappearingMessagesConfiguration.stringValue) public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue) public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue) public static let closedGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupUserCount.stringValue) @@ -66,6 +67,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue public static let openGroupUserCountString: String = CodingKeys.openGroupUserCount.stringValue + public static let disappearingMessagesConfigurationString: String = CodingKeys.disappearingMessagesConfiguration.stringValue public static let contactProfileString: String = CodingKeys.contactProfile.stringValue public static let closedGroupProfileFrontString: String = CodingKeys.closedGroupProfileFront.stringValue public static let closedGroupProfileBackString: String = CodingKeys.closedGroupProfileBack.stringValue @@ -116,6 +118,8 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat // Thread display info + public let disappearingMessagesConfiguration: DisappearingMessagesConfiguration? + private let contactProfile: Profile? private let closedGroupProfileFront: Profile? private let closedGroupProfileBack: Profile? @@ -343,7 +347,8 @@ public extension SessionThreadViewModel { contactProfile: Profile? = nil, currentUserIsClosedGroupMember: Bool? = nil, openGroupPermissions: OpenGroup.Permissions? = nil, - unreadCount: UInt = 0 + unreadCount: UInt = 0, + disappearingMessagesConfiguration: DisappearingMessagesConfiguration? = nil ) { self.rowId = -1 self.threadId = threadId @@ -368,6 +373,8 @@ public extension SessionThreadViewModel { // Thread display info + self.disappearingMessagesConfiguration = disappearingMessagesConfiguration + self.contactProfile = contactProfile self.closedGroupProfileFront = nil self.closedGroupProfileBack = nil @@ -430,6 +437,7 @@ public extension SessionThreadViewModel { threadWasMarkedUnread: self.threadWasMarkedUnread, threadUnreadCount: self.threadUnreadCount, threadUnreadMentionCount: self.threadUnreadMentionCount, + disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, contactProfile: self.contactProfile, closedGroupProfileFront: self.closedGroupProfileFront, closedGroupProfileBack: self.closedGroupProfileBack, @@ -486,6 +494,7 @@ public extension SessionThreadViewModel { threadWasMarkedUnread: self.threadWasMarkedUnread, threadUnreadCount: self.threadUnreadCount, threadUnreadMentionCount: self.threadUnreadMentionCount, + disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, contactProfile: self.contactProfile, closedGroupProfileFront: self.closedGroupProfileFront, closedGroupProfileBack: self.closedGroupProfileBack, @@ -839,6 +848,7 @@ public extension SessionThreadViewModel { /// but including this warning just in case there is a discrepancy) static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() + let disappearingMessagesConfiguration: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let closedGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() @@ -883,6 +893,8 @@ public extension SessionThreadViewModel { \(thread[.markedAsUnread]) AS \(ViewModel.threadWasMarkedUnreadKey), \(aggregateInteractionLiteral).\(ViewModel.threadUnreadCountKey), + + \(ViewModel.disappearingMessagesConfigurationKey).*, \(ViewModel.contactProfileKey).*, \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), @@ -911,6 +923,7 @@ public extension SessionThreadViewModel { \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) FROM \(SessionThread.self) + LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfiguration[.threadId]) = \(thread[.id]) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN ( SELECT @@ -945,11 +958,13 @@ public extension SessionThreadViewModel { return request.adapted { db in let adapters = try splittingRowAdapters(columnCounts: [ numColumnsBeforeProfiles, + DisappearingMessagesConfiguration.numberOfSelectedColumns(db), Profile.numberOfSelectedColumns(db) ]) return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1] + ViewModel.disappearingMessagesConfigurationString: adapters[1], + ViewModel.contactProfileString: adapters[2] ]) } } diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index ffbff0aeb..ea0753690 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -51,7 +51,10 @@ public struct ProfileManager { } if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty { - downloadAvatar(for: profile) + // FIXME: Refactor avatar downloading to be a proper Job so we can avoid this + JobRunner.afterBlockingQueue { + ProfileManager.downloadAvatar(for: profile) + } } return nil @@ -78,7 +81,10 @@ public struct ProfileManager { completion: { _, _ in // Try to re-download the avatar if it has a URL if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty { - downloadAvatar(for: profile) + // FIXME: Refactor avatar downloading to be a proper Job so we can avoid this + JobRunner.afterBlockingQueue { + ProfileManager.downloadAvatar(for: profile) + } } } ) @@ -214,7 +220,8 @@ public struct ProfileManager { FileServerAPI .download(fileId, useOldServer: useOldServer) - .receive(on: DispatchQueue.global(qos: .default)) + .subscribe(on: DispatchQueue.global(qos: .background)) + .receive(on: DispatchQueue.global(qos: .background)) .sinkUntilComplete( receiveCompletion: { _ in currentAvatarDownloads.mutate { $0.remove(profile.id) } @@ -451,6 +458,7 @@ public struct ProfileManager { // Upload the avatar to the FileServer FileServerAPI .upload(encryptedAvatarData) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: queue) .sinkUntilComplete( receiveCompletion: { result in @@ -590,7 +598,12 @@ public struct ProfileManager { db.afterNextTransactionNestedOnce(dedupeId: dedupeIdentifier) { db in // Need to refetch to ensure the db changes have occurred - ProfileManager.downloadAvatar(for: Profile.fetchOrCreate(db, id: publicKey)) + let targetProfile: Profile = Profile.fetchOrCreate(db, id: publicKey) + + // FIXME: Refactor avatar downloading to be a proper Job so we can avoid this + JobRunner.afterBlockingQueue { + ProfileManager.downloadAvatar(for: targetProfile) + } } } } diff --git a/SessionMessagingKit/Utilities/Threading.swift b/SessionMessagingKit/Utilities/Threading.swift index b7e1cab79..ef06b3c6b 100644 --- a/SessionMessagingKit/Utilities/Threading.swift +++ b/SessionMessagingKit/Utilities/Threading.swift @@ -1,6 +1,5 @@ import Foundation -internal enum Threading { - - internal static let pollerQueue = DispatchQueue(label: "SessionMessagingKit.pollerQueue") +public enum Threading { + public static let pollerQueue = DispatchQueue(label: "SessionMessagingKit.pollerQueue") } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 379c634eb..0697d65a1 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -825,13 +825,14 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Capabilities)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.capabilities( + .readPublisher { db in + try OpenGroupAPI.preparedCapabilities( db, server: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -895,13 +896,14 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: [OpenGroupAPI.Room])? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.rooms( + .readPublisher { db in + try OpenGroupAPI.preparedRooms( db, server: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1261,9 +1263,9 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1274,6 +1276,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1306,9 +1309,9 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1319,6 +1322,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1346,9 +1350,9 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1359,6 +1363,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1381,9 +1386,9 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1394,6 +1399,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1414,9 +1420,9 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1427,6 +1433,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1454,9 +1461,9 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1467,6 +1474,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1494,9 +1502,9 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1507,6 +1515,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1529,9 +1538,9 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1542,6 +1551,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1570,9 +1580,9 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1583,6 +1593,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1623,9 +1634,9 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .message( + .readPublisher { db in + try OpenGroupAPI + .preparedMessage( db, id: 123, in: "testRoom", @@ -1633,6 +1644,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1672,12 +1684,12 @@ class OpenGroupAPISpec: QuickSpec { } it("correctly sends the update") { - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1687,6 +1699,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1713,12 +1726,12 @@ class OpenGroupAPISpec: QuickSpec { } it("signs the message correctly") { - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1728,6 +1741,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1752,12 +1766,12 @@ class OpenGroupAPISpec: QuickSpec { _ = try OpenGroup.deleteAll(db) } - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1767,6 +1781,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1786,12 +1801,12 @@ class OpenGroupAPISpec: QuickSpec { _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) } - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1801,6 +1816,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1818,12 +1834,12 @@ class OpenGroupAPISpec: QuickSpec { mockEd25519.reset() // The 'keyPair' value doesn't equate so have to explicitly reset mockEd25519.when { try $0.sign(data: anyArray(), keyPair: any()) }.thenReturn(nil) - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1833,6 +1849,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1857,12 +1874,12 @@ class OpenGroupAPISpec: QuickSpec { } it("signs the message correctly") { - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1872,6 +1889,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1896,12 +1914,12 @@ class OpenGroupAPISpec: QuickSpec { _ = try OpenGroup.deleteAll(db) } - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1911,6 +1929,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1930,12 +1949,12 @@ class OpenGroupAPISpec: QuickSpec { _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) } - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1945,6 +1964,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1970,12 +1990,12 @@ class OpenGroupAPISpec: QuickSpec { } .thenReturn(nil) - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1985,6 +2005,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2007,12 +2028,12 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .messageDelete( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageDelete( db, id: 123, in: "testRoom", @@ -2020,6 +2041,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2039,7 +2061,7 @@ class OpenGroupAPISpec: QuickSpec { } context("when deleting all messages for a user") { - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? beforeEach { class TestApi: TestOnionRequestAPI { @@ -2054,9 +2076,9 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .messagesDeleteAll( + .readPublisher { db in + try OpenGroupAPI + .preparedMessagesDeleteAll( db, sessionId: "testUserId", in: "testRoom", @@ -2064,6 +2086,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2091,12 +2114,12 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: ResponseInfoType? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .pinMessage( + .readPublisher { db in + try OpenGroupAPI + .preparedPinMessage( db, id: 123, in: "testRoom", @@ -2104,6 +2127,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2116,7 +2140,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response as? TestOnionRequestAPI.ResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.urlString).to(equal("testserver/room/testRoom/pin/123")) } @@ -2129,12 +2153,12 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: ResponseInfoType? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .unpinMessage( + .readPublisher { db in + try OpenGroupAPI + .preparedUnpinMessage( db, id: 123, in: "testRoom", @@ -2142,6 +2166,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2154,7 +2179,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response as? TestOnionRequestAPI.ResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.urlString).to(equal("testserver/room/testRoom/unpin/123")) } @@ -2167,18 +2192,19 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: ResponseInfoType? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .unpinAll( + .readPublisher { db in + try OpenGroupAPI + .preparedUnpinAll( db, in: "testRoom", on: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2191,7 +2217,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response as? TestOnionRequestAPI.ResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) expect(requestData?.urlString).to(equal("testserver/room/testRoom/unpin/all")) } @@ -2209,9 +2235,9 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .uploadFile( + .readPublisher { db in + try OpenGroupAPI + .preparedUploadFile( db, bytes: [], to: "testRoom", @@ -2219,6 +2245,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2245,9 +2272,9 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .uploadFile( + .readPublisher { db in + try OpenGroupAPI + .preparedUploadFile( db, bytes: [], to: "testRoom", @@ -2255,6 +2282,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2281,9 +2309,9 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .uploadFile( + .readPublisher { db in + try OpenGroupAPI + .preparedUploadFile( db, bytes: [], fileName: "TestFileName", @@ -2292,6 +2320,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2319,9 +2348,9 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .downloadFile( + .readPublisher { db in + try OpenGroupAPI + .preparedDownloadFile( db, fileId: "1", from: "testRoom", @@ -2329,6 +2358,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2376,9 +2406,9 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.SendDirectMessageResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, ciphertext: "test".data(using: .utf8)!, toInboxFor: "testUserId", @@ -2386,6 +2416,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2410,7 +2441,7 @@ class OpenGroupAPISpec: QuickSpec { // MARK: - Users context("when banning a user") { - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? beforeEach { class TestApi: TestOnionRequestAPI { @@ -2425,9 +2456,9 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .userBan( + .readPublisher { db in + try OpenGroupAPI + .preparedUserBan( db, sessionId: "testUserId", for: nil, @@ -2436,6 +2467,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2455,9 +2487,9 @@ class OpenGroupAPISpec: QuickSpec { it("does a global ban if no room tokens are provided") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .userBan( + .readPublisher { db in + try OpenGroupAPI + .preparedUserBan( db, sessionId: "testUserId", for: nil, @@ -2466,6 +2498,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2487,9 +2520,9 @@ class OpenGroupAPISpec: QuickSpec { it("does room specific bans if room tokens are provided") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .userBan( + .readPublisher { db in + try OpenGroupAPI + .preparedUserBan( db, sessionId: "testUserId", for: nil, @@ -2498,6 +2531,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2519,7 +2553,7 @@ class OpenGroupAPISpec: QuickSpec { } context("when unbanning a user") { - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? beforeEach { class TestApi: TestOnionRequestAPI { @@ -2534,9 +2568,9 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .userUnban( + .readPublisher { db in + try OpenGroupAPI + .preparedUserUnban( db, sessionId: "testUserId", from: nil, @@ -2544,6 +2578,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2563,9 +2598,9 @@ class OpenGroupAPISpec: QuickSpec { it("does a global ban if no room tokens are provided") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .userUnban( + .readPublisher { db in + try OpenGroupAPI + .preparedUserUnban( db, sessionId: "testUserId", from: nil, @@ -2573,6 +2608,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2594,9 +2630,9 @@ class OpenGroupAPISpec: QuickSpec { it("does room specific bans if room tokens are provided") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .userUnban( + .readPublisher { db in + try OpenGroupAPI + .preparedUserUnban( db, sessionId: "testUserId", from: ["testRoom"], @@ -2604,6 +2640,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2625,7 +2662,7 @@ class OpenGroupAPISpec: QuickSpec { } context("when updating a users permissions") { - var response: (info: ResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? beforeEach { class TestApi: TestOnionRequestAPI { @@ -2640,9 +2677,9 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .userModeratorUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedUserModeratorUpdate( db, sessionId: "testUserId", moderator: true, @@ -2653,6 +2690,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2672,9 +2710,9 @@ class OpenGroupAPISpec: QuickSpec { it("does a global update if no room tokens are provided") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .userModeratorUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedUserModeratorUpdate( db, sessionId: "testUserId", moderator: true, @@ -2685,6 +2723,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2706,9 +2745,9 @@ class OpenGroupAPISpec: QuickSpec { it("does room specific updates if room tokens are provided") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .userModeratorUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedUserModeratorUpdate( db, sessionId: "testUserId", moderator: true, @@ -2719,6 +2758,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2740,9 +2780,9 @@ class OpenGroupAPISpec: QuickSpec { it("fails if neither moderator or admin are set") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .userModeratorUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedUserModeratorUpdate( db, sessionId: "testUserId", moderator: nil, @@ -2753,6 +2793,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2890,14 +2931,15 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2917,14 +2959,15 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2944,14 +2987,15 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2975,14 +3019,15 @@ class OpenGroupAPISpec: QuickSpec { it("signs correctly") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -3011,14 +3056,15 @@ class OpenGroupAPISpec: QuickSpec { mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn(nil) mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -3044,14 +3090,15 @@ class OpenGroupAPISpec: QuickSpec { it("signs correctly") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -3081,14 +3128,15 @@ class OpenGroupAPISpec: QuickSpec { .thenReturn(nil) mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -3108,14 +3156,15 @@ class OpenGroupAPISpec: QuickSpec { .thenReturn(nil) mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index dfcad2e76..8beeb921b 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -116,7 +116,8 @@ class OpenGroupManagerSpec: QuickSpec { mockNonce24Generator = MockNonce24Generator() mockUserDefaults = MockUserDefaults() dependencies = OpenGroupManager.OGMDependencies( - queue: DispatchQueue.main, + subscribeQueue: DispatchQueue.main, + receiveQueue: DispatchQueue.main, cache: Atomic(mockOGMCache), onionApi: TestCapabilitiesAndRoomApi.self, generalCache: Atomic(mockGeneralCache), @@ -991,7 +992,7 @@ class OpenGroupManagerSpec: QuickSpec { db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } @@ -1006,7 +1007,7 @@ class OpenGroupManagerSpec: QuickSpec { db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } @@ -1024,7 +1025,7 @@ class OpenGroupManagerSpec: QuickSpec { db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } @@ -1038,7 +1039,7 @@ class OpenGroupManagerSpec: QuickSpec { db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } @@ -1077,7 +1078,7 @@ class OpenGroupManagerSpec: QuickSpec { db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } @@ -1130,7 +1131,7 @@ class OpenGroupManagerSpec: QuickSpec { db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer), calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } @@ -1145,7 +1146,7 @@ class OpenGroupManagerSpec: QuickSpec { db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer), calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index e423e2784..3a943047e 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -136,7 +136,14 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension guard case .preOffer = callMessage.kind else { return self.completeSilenty() } if !db[.areCallsEnabled] { - if let sender: String = callMessage.sender, let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: callMessage, state: .permissionDenied) { + if + let sender: String = callMessage.sender, + let interaction: Interaction = try MessageReceiver.insertCallInfoMessage( + db, + for: callMessage, + state: .permissionDenied + ) + { let thread: SessionThread = try SessionThread .fetchOrCreate( db, @@ -145,12 +152,15 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension shouldBeVisible: nil ) - Environment.shared?.notificationsManager.wrappedValue? - .notifyUser( - db, - forIncomingCall: interaction, - in: thread - ) + // Notify the user if the call message wasn't already read + if !interaction.wasRead { + Environment.shared?.notificationsManager.wrappedValue? + .notifyUser( + db, + forIncomingCall: interaction, + in: thread + ) + } } break } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index a00a4c020..4013936b7 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -153,7 +153,8 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView tableView.deselectRow(at: indexPath, animated: true) ShareNavController.attachmentPrepPublisher? - .receiveOnMain(immediately: true) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sinkUntilComplete( receiveValue: { [weak self] attachments in guard let strongSelf = self else { return } @@ -232,18 +233,20 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView try LinkPreview( url: linkPreviewDraft.urlString, title: linkPreviewDraft.title, - attachmentId: LinkPreview.saveAttachmentIfPossible( - db, - imageData: linkPreviewDraft.jpegImageData, - mimeType: OWSMimeTypeImageJpeg - ) + attachmentId: LinkPreview + .generateAttachmentIfPossible( + imageData: linkPreviewDraft.jpegImageData, + mimeType: OWSMimeTypeImageJpeg + )? + .inserted(db) + .id ).insert(db) } // Prepare any attachments - try Attachment.prepare( + try Attachment.process( db, - attachments: finalAttachments, + data: Attachment.prepare(attachments: finalAttachments), for: interactionId ) @@ -257,13 +260,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { - MessageSender.performUploadsIfNeeded( - queue: DispatchQueue.global(qos: .userInitiated), - preparedSendData: $0 - ) - } + .flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0) } .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveCompletion: { [weak self] result in diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 93035647f..2d07a43cd 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -29,6 +29,7 @@ public class ThreadPickerViewModel { .fetchAll(db) } .removeDuplicates() + .handleEvents(didFail: { SNLog("[ThreadPickerViewModel] Observation failed with error: \($0)") }) // MARK: - Functions diff --git a/SessionSnodeKit/Jobs/GetSnodePoolJob.swift b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift index 2d0c5e827..79041941a 100644 --- a/SessionSnodeKit/Jobs/GetSnodePoolJob.swift +++ b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift @@ -56,7 +56,7 @@ public enum GetSnodePoolJob: JobExecutor { public static func run() { GetSnodePoolJob.run( Job(variant: .getSnodePool), - queue: DispatchQueue.global(qos: .background), + queue: .global(qos: .background), success: { _, _ in }, failure: { _, _, _ in }, deferred: { _ in } diff --git a/SessionSnodeKit/Models/GetStatsResponse.swift b/SessionSnodeKit/Models/GetStatsResponse.swift new file mode 100644 index 000000000..5e33cd03b --- /dev/null +++ b/SessionSnodeKit/Models/GetStatsResponse.swift @@ -0,0 +1,16 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +extension SnodeAPI { + public struct GetStatsResponse: Codable { + private enum CodingKeys: String, CodingKey { + case versionString = "version" + } + + let versionString: String? + + var version: Version? { versionString.map { Version.from($0) } } + } +} diff --git a/SessionSnodeKit/Networking/OnionRequestAPI.swift b/SessionSnodeKit/Networking/OnionRequestAPI.swift index 538733eea..926c49008 100644 --- a/SessionSnodeKit/Networking/OnionRequestAPI.swift +++ b/SessionSnodeKit/Networking/OnionRequestAPI.swift @@ -71,18 +71,12 @@ public enum OnionRequestAPI: OnionRequestAPIType { let timeout: TimeInterval = 3 // Use a shorter timeout for testing return HTTP.execute(.get, url, timeout: timeout) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .tryMap { responseData -> Void in - // TODO: Remove JSON usage - guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { - throw HTTPError.invalidJSON - } - guard let version = responseJson["version"] as? String else { - throw OnionRequestAPIError.missingSnodeVersion - } - guard version >= "2.0.7" else { - SNLog("Unsupported snode version: \(version).") - throw OnionRequestAPIError.unsupportedSnodeVersion(version) + .decoded(as: SnodeAPI.GetStatsResponse.self) + .tryMap { response -> Void in + guard let version: Version = response.version else { throw OnionRequestAPIError.missingSnodeVersion } + guard version >= Version(major: 2, minor: 0, patch: 7) else { + SNLog("Unsupported snode version: \(version.stringValue).") + throw OnionRequestAPIError.unsupportedSnodeVersion(version.stringValue) } return () @@ -154,63 +148,82 @@ public enum OnionRequestAPI: OnionRequestAPIType { return existingBuildPathsPublisher } - SNLog("Building onion request paths.") - DispatchQueue.main.async { - NotificationCenter.default.post(name: .buildingPaths, object: nil) - } - let reusableGuardSnodes = reusablePaths.map { $0[0] } - let publisher: AnyPublisher<[[Snode]], Error> = getGuardSnodes(reusing: reusableGuardSnodes) - .flatMap { guardSnodes -> AnyPublisher<[[Snode]], Error> in - var unusedSnodes = SnodeAPI.snodePool.wrappedValue - .subtracting(guardSnodes) - .subtracting(reusablePaths.flatMap { $0 }) - let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) - let pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount) - - guard unusedSnodes.count >= pathSnodeCount else { - return Fail<[[Snode]], Error>(error: OnionRequestAPIError.insufficientSnodes) - .eraseToAnyPublisher() - } - - // Don't test path snodes as this would reveal the user's IP to them - return Just( - guardSnodes + return buildPathsPublisher.mutate { result in + /// It was possible for multiple threads to call this at the same time resulting in duplicate promises getting created, while + /// this should no longer be possible (as the `wrappedValue` should now properly be blocked) this is a sanity check + /// to make sure we don't create an additional promise when one already exists + if let previouslyBlockedPublisher: AnyPublisher<[[Snode]], Error> = result { + return previouslyBlockedPublisher + } + + SNLog("Building onion request paths.") + DispatchQueue.main.async { + NotificationCenter.default.post(name: .buildingPaths, object: nil) + } + + /// Need to include the post-request code and a `shareReplay` within the publisher otherwise it can still be executed + /// multiple times as a result of multiple subscribers + let reusableGuardSnodes = reusablePaths.map { $0[0] } + let publisher: AnyPublisher<[[Snode]], Error> = getGuardSnodes(reusing: reusableGuardSnodes) + .flatMap { (guardSnodes: Set) -> AnyPublisher<[[Snode]], Error> in + var unusedSnodes: Set = SnodeAPI.snodePool.wrappedValue + .subtracting(guardSnodes) + .subtracting(reusablePaths.flatMap { $0 }) + let reusableGuardSnodeCount: UInt = UInt(reusableGuardSnodes.count) + let pathSnodeCount: UInt = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount) + + guard unusedSnodes.count >= pathSnodeCount else { + return Fail<[[Snode]], Error>(error: OnionRequestAPIError.insufficientSnodes) + .eraseToAnyPublisher() + } + + // Don't test path snodes as this would reveal the user's IP to them + let paths: [[Snode]] = guardSnodes .subtracting(reusableGuardSnodes) - .map { guardSnode in - let result = [ guardSnode ] + (0..<(pathSize - 1)).map { _ in - // randomElement() uses the system's default random generator, which is cryptographically secure - let pathSnode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above - unusedSnodes.remove(pathSnode) // All used snodes should be unique - return pathSnode - } + .map { (guardSnode: Snode) in + let result: [Snode] = [guardSnode] + .appending( + contentsOf: (0..<(pathSize - 1)) + .map { _ in + // randomElement() uses the system's default random generator, + // which is cryptographically secure + let pathSnode: Snode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above + unusedSnodes.remove(pathSnode) // All used snodes should be unique + return pathSnode + } + ) SNLog("Built new onion request path: \(result.prettifiedDescription).") return result } + + return Just(paths) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + .handleEvents( + receiveOutput: { output in + OnionRequestAPI.paths = (output + reusablePaths) + + Storage.shared.write { db in + SNLog("Persisting onion request paths to database.") + try? output.save(db) + } + + DispatchQueue.main.async { + NotificationCenter.default.post(name: .pathsBuilt, object: nil) + } + }, + receiveCompletion: { _ in buildPathsPublisher.mutate { $0 = nil } } ) - .setFailureType(to: Error.self) + .shareReplay(1) .eraseToAnyPublisher() - } - .handleEvents( - receiveOutput: { output in - OnionRequestAPI.paths = (output + reusablePaths) - - Storage.shared.write { db in - SNLog("Persisting onion request paths to database.") - try? output.save(db) - } - - DispatchQueue.main.async { - NotificationCenter.default.post(name: .pathsBuilt, object: nil) - } - }, - receiveCompletion: { _ in buildPathsPublisher.mutate { $0 = nil } } - ) - .eraseToAnyPublisher() - - buildPathsPublisher.mutate { $0 = publisher } - - return publisher + + /// Actually assign the atomic value + result = publisher + + return publisher + } } /// Returns a `Path` to be used for building an onion request. Builds new paths as needed. @@ -245,6 +258,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { if let snode = snode { if let path = paths.first(where: { !$0.contains(snode) }) { buildPaths(reusing: paths) // Re-build paths in the background + .subscribe(on: DispatchQueue.global(qos: .background)) .sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in }) .store(in: &cancellable) @@ -269,6 +283,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { } else { buildPaths(reusing: paths) // Re-build paths in the background + .subscribe(on: DispatchQueue.global(qos: .background)) .sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in }) .store(in: &cancellable) @@ -480,7 +495,6 @@ public enum OnionRequestAPI: OnionRequestAPIType { var guardSnode: Snode? return buildOnion(around: payload, targetedAt: destination) - .subscribe(on: Threading.workQueue) .flatMap { intermediate -> AnyPublisher<(ResponseInfoType, Data?), Error> in guardSnode = intermediate.guardSnode let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2" diff --git a/SessionSnodeKit/Networking/SnodeAPI.swift b/SessionSnodeKit/Networking/SnodeAPI.swift index 6e2abafbb..de81c675c 100644 --- a/SessionSnodeKit/Networking/SnodeAPI.swift +++ b/SessionSnodeKit/Networking/SnodeAPI.swift @@ -162,18 +162,17 @@ public final class SnodeAPI { return previouslyBlockedPublisher } - let publisher: AnyPublisher, Error> = { + let targetPublisher: AnyPublisher, Error> = { guard snodePool.count >= minSnodePoolCount else { return getSnodePoolFromSeedNode() } return getSnodePoolFromSnode() .catch { _ in getSnodePoolFromSeedNode() } .eraseToAnyPublisher() }() - - /// Actually assign the atomic value - result = publisher - return publisher + /// Need to include the post-request code and a `shareReplay` within the publisher otherwise it can still be executed + /// multiple times as a result of multiple subscribers + let publisher: AnyPublisher, Error> = targetPublisher .tryFlatMap { snodePool -> AnyPublisher, Error> in guard !snodePool.isEmpty else { throw SnodeAPIError.snodePoolUpdatingFailed } @@ -189,7 +188,14 @@ public final class SnodeAPI { .handleEvents( receiveCompletion: { _ in getSnodePoolPublisher.mutate { $0 = nil } } ) + .shareReplay(1) .eraseToAnyPublisher() + + /// Actually assign the atomic value + result = publisher + + return publisher + } } @@ -245,7 +251,6 @@ public final class SnodeAPI { } } ) - .subscribe(on: Threading.workQueue) .collect() .tryMap { results -> String in guard results.count == validationCount, Set(results).count == 1 else { @@ -758,7 +763,6 @@ public final class SnodeAPI { } return getSwarm(for: publicKey) - .subscribe(on: Threading.workQueue) .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher<[String: [(hash: String, expiry: UInt64)]], Error> in SnodeAPI .send( @@ -800,7 +804,6 @@ public final class SnodeAPI { } return getSwarm(for: publicKey) - .subscribe(on: Threading.workQueue) .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher in SnodeAPI .send( @@ -846,7 +849,6 @@ public final class SnodeAPI { let userX25519PublicKey: String = getUserHexEncodedPublicKey() return getSwarm(for: publicKey) - .subscribe(on: Threading.workQueue) .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher<[String: Bool], Error> in SnodeAPI .send( @@ -902,7 +904,6 @@ public final class SnodeAPI { let userX25519PublicKey: String = getUserHexEncodedPublicKey() return getSwarm(for: userX25519PublicKey) - .subscribe(on: Threading.workQueue) .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher<[String: Bool], Error> in getNetworkTime(from: snode) .flatMap { timestampMs -> AnyPublisher<[String: Bool], Error> in @@ -950,7 +951,6 @@ public final class SnodeAPI { let userX25519PublicKey: String = getUserHexEncodedPublicKey() return getSwarm(for: userX25519PublicKey) - .subscribe(on: Threading.workQueue) .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher<[String: Bool], Error> in getNetworkTime(from: snode) .flatMap { timestampMs -> AnyPublisher<[String: Bool], Error> in @@ -1048,7 +1048,6 @@ public final class SnodeAPI { useSeedNodeURLSession: true ) .decoded(as: SnodePoolResponse.self, using: dependencies) - .subscribe(on: Threading.workQueue) .mapError { error in switch error { case HTTPError.parsingFailed: return SnodeAPIError.snodePoolUpdatingFailed diff --git a/SessionSnodeKit/SSKDependencies.swift b/SessionSnodeKit/SSKDependencies.swift index e61cb7311..01faa2289 100644 --- a/SessionSnodeKit/SSKDependencies.swift +++ b/SessionSnodeKit/SSKDependencies.swift @@ -14,7 +14,8 @@ open class SSKDependencies: Dependencies { // MARK: - Initialization public init( - queue: DispatchQueue? = nil, + subscribeQueue: DispatchQueue? = nil, + receiveQueue: DispatchQueue? = nil, onionApi: OnionRequestAPIType.Type? = nil, generalCache: Atomic? = nil, storage: Storage? = nil, @@ -25,7 +26,8 @@ open class SSKDependencies: Dependencies { _onionApi = Atomic(onionApi) super.init( - queue: queue, + subscribeQueue: subscribeQueue, + receiveQueue: receiveQueue, generalCache: generalCache, storage: storage, scheduler: scheduler, diff --git a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift index c5638934f..862a650e8 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -49,7 +49,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { ) cancellables.append( viewModel.observableTableData - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -132,7 +132,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { ) cancellables.append( viewModel.observableTableData - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -178,7 +178,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { cancellables.append( viewModel.rightNavItems - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { navItems in items = navItems } @@ -194,7 +194,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { beforeEach { cancellables.append( viewModel.rightNavItems - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { navItems in items = navItems } @@ -221,7 +221,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { cancellables.append( viewModel.dismissScreen - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { _ in didDismissScreen = true } diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 5d2b08362..e05de6c9a 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -75,7 +75,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { ) disposables.append( viewModel.observableTableData - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -173,7 +173,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { ) disposables.append( viewModel.observableTableData - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -447,7 +447,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { ) disposables.append( viewModel.observableTableData - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -489,7 +489,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { ) disposables.append( viewModel.observableTableData - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } diff --git a/SessionTests/Settings/NotificationContentViewModelSpec.swift b/SessionTests/Settings/NotificationContentViewModelSpec.swift index 04f11d74b..6538c95b0 100644 --- a/SessionTests/Settings/NotificationContentViewModelSpec.swift +++ b/SessionTests/Settings/NotificationContentViewModelSpec.swift @@ -31,7 +31,7 @@ class NotificationContentViewModelSpec: QuickSpec { ) viewModel = NotificationContentViewModel(storage: mockStorage, scheduling: .immediate) dataChangeCancellable = viewModel.observableTableData - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -99,7 +99,7 @@ class NotificationContentViewModelSpec: QuickSpec { } viewModel = NotificationContentViewModel(storage: mockStorage, scheduling: .immediate) dataChangeCancellable = viewModel.observableTableData - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -148,7 +148,7 @@ class NotificationContentViewModelSpec: QuickSpec { var didDismissScreen: Bool = false dismissCancellable = viewModel.dismissScreen - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { _ in didDismissScreen = true } diff --git a/SessionUIKit/Components/HighlightMentionBackgroundView.swift b/SessionUIKit/Components/HighlightMentionBackgroundView.swift index 450636c53..289787c21 100644 --- a/SessionUIKit/Components/HighlightMentionBackgroundView.swift +++ b/SessionUIKit/Components/HighlightMentionBackgroundView.swift @@ -92,7 +92,7 @@ public class HighlightMentionBackgroundView: UIView { var ascent: CGFloat = 0 var descent: CGFloat = 0 var leading: CGFloat = 0 - let lineWidth = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading)) + _ = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading)) for run in runs { let attributes: NSDictionary = CTRunGetAttributes(run) diff --git a/SessionUIKit/Components/TopBannerController.swift b/SessionUIKit/Components/TopBannerController.swift index 6264471f4..f00e53115 100644 --- a/SessionUIKit/Components/TopBannerController.swift +++ b/SessionUIKit/Components/TopBannerController.swift @@ -163,7 +163,7 @@ public class TopBannerController: UIViewController { return } - // Not an ideal approach but should allow + // Not an ideal approach but should allow us to have a single banner guard let instance: TopBannerController = ((view?.window?.rootViewController as? TopBannerController) ?? TopBannerController.lastInstance) else { return } @@ -185,4 +185,20 @@ public class TopBannerController: UIViewController { instance?.contentStackView.layoutIfNeeded() } } + + public static func hide(inWindowFor view: UIView? = nil) { + guard Thread.isMainThread else { + DispatchQueue.main.async { + TopBannerController.hide(inWindowFor: view) + } + return + } + + // Not an ideal approach but should allow us to have a single banner + guard let instance: TopBannerController = ((view?.window?.rootViewController as? TopBannerController) ?? TopBannerController.lastInstance) else { + return + } + + UIView.performWithoutAnimation { instance.dismissBanner() } + } } diff --git a/SessionUIKit/Database/Migrations/_001_ThemePreferences.swift b/SessionUIKit/Database/Migrations/_001_ThemePreferences.swift index 39ca24f5e..2951dca42 100644 --- a/SessionUIKit/Database/Migrations/_001_ThemePreferences.swift +++ b/SessionUIKit/Database/Migrations/_001_ThemePreferences.swift @@ -35,10 +35,12 @@ enum _001_ThemePreferences: Migration { db[.themePrimaryColor] = targetPrimaryColor // Looks like the ThemeManager will load it's default values before this migration gets run - // as a result we need to update the ThemeManage to ensure the correct theme is applied - ThemeManager.currentTheme = targetTheme - ThemeManager.primaryColor = targetPrimaryColor - ThemeManager.matchSystemNightModeSetting = matchSystemNightModeSetting + // as a result we need to update the ThemeManager to ensure the correct theme is applied + ThemeManager.setInitialThemeState( + theme: targetTheme, + primaryColor: targetPrimaryColor, + matchSystemNightModeSetting: matchSystemNightModeSetting + ) Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } diff --git a/SessionUIKit/Style Guide/ThemeManager.swift b/SessionUIKit/Style Guide/ThemeManager.swift index 5926a82d1..a38356ebb 100644 --- a/SessionUIKit/Style Guide/ThemeManager.swift +++ b/SessionUIKit/Style Guide/ThemeManager.swift @@ -30,8 +30,12 @@ public enum ThemeManager { /// Unfortunately if we don't do this the `ThemeApplier` is immediately deallocated and we can't use it to update the theme private static var uiRegistry: NSMapTable = NSMapTable.weakToStrongObjects() + private static var _initialTheme: Theme? + private static var _initialPrimaryColor: Theme.PrimaryColor? + private static var _initialMatchSystemNightModeSetting: Bool? + public static var currentTheme: Theme = { - Storage.shared[.theme].defaulting(to: Theme.classicDark) + (_initialTheme ?? Storage.shared[.theme].defaulting(to: Theme.classicDark)) }() { didSet { // Only update if it was changed @@ -55,7 +59,7 @@ public enum ThemeManager { } public static var primaryColor: Theme.PrimaryColor = { - Storage.shared[.themePrimaryColor].defaulting(to: Theme.PrimaryColor.green) + (_initialPrimaryColor ?? Storage.shared[.themePrimaryColor].defaulting(to: Theme.PrimaryColor.green)) }() { didSet { // Only update if it was changed @@ -70,7 +74,7 @@ public enum ThemeManager { } public static var matchSystemNightModeSetting: Bool = { - Storage.shared[.themeMatchSystemDayNightCycle] + (_initialMatchSystemNightModeSetting ?? Storage.shared[.themeMatchSystemDayNightCycle]) }() { didSet { // Only update if it was changed @@ -99,6 +103,16 @@ public enum ThemeManager { // MARK: - Functions + public static func setInitialThemeState( + theme: Theme, + primaryColor: Theme.PrimaryColor, + matchSystemNightModeSetting: Bool + ) { + _initialTheme = theme + _initialPrimaryColor = primaryColor + _initialMatchSystemNightModeSetting = matchSystemNightModeSetting + } + public static func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { let currentUserInterfaceStyle: UIUserInterfaceStyle = UITraitCollection.current.userInterfaceStyle diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift index 4df5f143f..ddc059a85 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -28,21 +28,25 @@ public extension Publisher { /// The standard `.subscribe(on: DispatchQueue.main)` seems to ocassionally dispatch to the /// next run loop before actually subscribing, this method checks if it's running on the main thread already and /// if so just subscribes directly rather than routing via `.receive(on:)` - func subscribeOnMain(immediately receiveImmediately: Bool = false, options: DispatchQueue.SchedulerOptions? = nil) -> AnyPublisher { - guard receiveImmediately else { - return self.subscribe(on: DispatchQueue.main, options: options) + func subscribe( + on scheduler: S, + immediatelyIfMain: Bool, + options: S.SchedulerOptions? = nil + ) -> AnyPublisher where S: Scheduler { + guard immediatelyIfMain && ((scheduler as? DispatchQueue) == DispatchQueue.main) else { + return self.subscribe(on: scheduler, options: options) .eraseToAnyPublisher() } - + return self .flatMap { value -> AnyPublisher in guard Thread.isMainThread else { return Just(value) .setFailureType(to: Failure.self) - .subscribe(on: DispatchQueue.main, options: options) + .subscribe(on: scheduler, options: options) .eraseToAnyPublisher() } - + return Just(value) .setFailureType(to: Failure.self) .eraseToAnyPublisher() @@ -53,21 +57,25 @@ public extension Publisher { /// The standard `.receive(on: DispatchQueue.main)` seems to ocassionally dispatch to the /// next run loop before emitting data, this method checks if it's running on the main thread already and /// if so just emits directly rather than routing via `.receive(on:)` - func receiveOnMain(immediately receiveImmediately: Bool = false) -> AnyPublisher { - guard receiveImmediately else { - return self.receive(on: DispatchQueue.main) + func receive( + on scheduler: S, + immediatelyIfMain: Bool, + options: S.SchedulerOptions? = nil + ) -> AnyPublisher where S: Scheduler { + guard immediatelyIfMain && ((scheduler as? DispatchQueue) == DispatchQueue.main) else { + return self.receive(on: scheduler, options: options) .eraseToAnyPublisher() } - + return self .flatMap { value -> AnyPublisher in guard Thread.isMainThread else { return Just(value) .setFailureType(to: Failure.self) - .receive(on: DispatchQueue.main) + .receive(on: scheduler, options: options) .eraseToAnyPublisher() } - + return Just(value) .setFailureType(to: Failure.self) .eraseToAnyPublisher() diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 40b0380e9..ce25d5b86 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -46,6 +46,13 @@ open class Storage { public init( customWriter: DatabaseWriter? = nil, customMigrations: [TargetMigrations]? = nil + ) { + configureDatabase(customWriter: customWriter, customMigrations: customMigrations) + } + + private func configureDatabase( + customWriter: DatabaseWriter? = nil, + customMigrations: [TargetMigrations]? = nil ) { // Create the database directory if needed and ensure it's protection level is set before attempting to // create the database KeySpec or the database itself @@ -330,8 +337,16 @@ open class Storage { Storage.shared.migrationsCompleted.mutate { $0 = false } Storage.shared.dbWriter = nil - self.deleteDatabaseFiles() - try? self.deleteDbKeys() + deleteDatabaseFiles() + try? deleteDbKeys() + } + + public static func resetForCleanMigration() { + // Clear existing content + resetAllStorage() + + // Reconfigure + Storage.shared.configureDatabase() } private static func deleteDatabaseFiles() { diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 30d8fb877..26920940d 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -663,49 +663,53 @@ public class PagedDatabaseObserver: TransactionObserver where } // Fetch the desired data - let pageRowIds: [Int64] = PagedData.rowIds( - db, - tableName: pagedTableName, - requiredJoinSQL: joinSQL, - filterSQL: filterSQL, - groupSQL: groupSQL, - orderSQL: orderSQL, - limit: queryInfo.limit, - offset: queryInfo.offset - ) + let pageRowIds: [Int64] let newData: [T] + let updatedLimitInfo: PagedData.PageInfo - do { newData = try dataQuery(pageRowIds).fetchAll(db) } - catch { - SNLog("PagedDatabaseObserver threw exception: \(error)") - throw error - } - - let updatedLimitInfo: PagedData.PageInfo = PagedData.PageInfo( - pageSize: currentPageInfo.pageSize, - pageOffset: queryInfo.updatedCacheOffset, - currentCount: { - switch target { - case .reloadCurrent: return currentPageInfo.currentCount - default: return (currentPageInfo.currentCount + newData.count) - } - }(), - totalCount: totalCount - ) - - // Update the associatedRecords for the newly retrieved data - self?.associatedRecords.forEach { record in - record.updateCache( + do { + pageRowIds = try PagedData.rowIds( db, - rowIds: PagedData.associatedRowIds( - db, - tableName: record.databaseTableName, - pagedTableName: pagedTableName, - pagedTypeRowIds: newData.map { $0.rowId }, - joinToPagedType: record.joinToPagedType - ), - hasOtherChanges: false + tableName: pagedTableName, + requiredJoinSQL: joinSQL, + filterSQL: filterSQL, + groupSQL: groupSQL, + orderSQL: orderSQL, + limit: queryInfo.limit, + offset: queryInfo.offset ) + newData = try dataQuery(pageRowIds).fetchAll(db) + updatedLimitInfo = PagedData.PageInfo( + pageSize: currentPageInfo.pageSize, + pageOffset: queryInfo.updatedCacheOffset, + currentCount: { + switch target { + case .reloadCurrent: return currentPageInfo.currentCount + default: return (currentPageInfo.currentCount + newData.count) + } + }(), + totalCount: totalCount + ) + + // Update the associatedRecords for the newly retrieved data + let newDataRowIds: [Int64] = newData.map { $0.rowId } + try self?.associatedRecords.forEach { record in + record.updateCache( + db, + rowIds: try PagedData.associatedRowIds( + db, + tableName: record.databaseTableName, + pagedTableName: pagedTableName, + pagedTypeRowIds: newDataRowIds, + joinToPagedType: record.joinToPagedType + ), + hasOtherChanges: false + ) + } + } + catch { + SNLog("[PagedDatabaseObserver] Error loading data: \(error)") + throw error } return (newData, updatedLimitInfo, nil) @@ -1072,7 +1076,7 @@ public enum PagedData { orderSQL: SQL, limit: Int, offset: Int - ) -> [Int64] { + ) throws -> [Int64] { let tableNameLiteral: SQL = SQL(stringLiteral: tableName) let finalJoinSQL: SQL = (requiredJoinSQL ?? "") let finalGroupSQL: SQL = (groupSQL ?? "") @@ -1086,8 +1090,7 @@ public enum PagedData { LIMIT \(limit) OFFSET \(offset) """ - return (try? request.fetchAll(db)) - .defaulting(to: []) + return try request.fetchAll(db) } fileprivate static func index( @@ -1160,7 +1163,7 @@ public enum PagedData { pagedTableName: String, pagedTypeRowIds: [Int64], joinToPagedType: SQL - ) -> [Int64] { + ) throws -> [Int64] { guard !pagedTypeRowIds.isEmpty else { return [] } let tableNameLiteral: SQL = SQL(stringLiteral: tableName) @@ -1172,8 +1175,7 @@ public enum PagedData { WHERE \(pagedTableNameLiteral).rowId IN \(pagedTypeRowIds) """ - return (try? request.fetchAll(db)) - .defaulting(to: []) + return try request.fetchAll(db) } /// Returns the rowIds for the paged type based on the specified relatedRowIds diff --git a/SessionUtilitiesKit/General/Dependencies.swift b/SessionUtilitiesKit/General/Dependencies.swift index 30e443be1..64e2d5f3e 100644 --- a/SessionUtilitiesKit/General/Dependencies.swift +++ b/SessionUtilitiesKit/General/Dependencies.swift @@ -4,10 +4,16 @@ import Foundation import GRDB open class Dependencies { - public var _queue: Atomic - public var queue: DispatchQueue { - get { Dependencies.getValueSettingIfNull(&_queue) { DispatchQueue.global(qos: .default) } } - set { _queue.mutate { $0 = newValue } } + public var _subscribeQueue: Atomic + public var subscribeQueue: DispatchQueue { + get { Dependencies.getValueSettingIfNull(&_subscribeQueue) { DispatchQueue.global(qos: .default) } } + set { _subscribeQueue.mutate { $0 = newValue } } + } + + public var _receiveQueue: Atomic + public var receiveQueue: DispatchQueue { + get { Dependencies.getValueSettingIfNull(&_receiveQueue) { DispatchQueue.global(qos: .default) } } + set { _receiveQueue.mutate { $0 = newValue } } } public var _generalCache: Atomic?> @@ -43,14 +49,16 @@ open class Dependencies { // MARK: - Initialization public init( - queue: DispatchQueue? = nil, + subscribeQueue: DispatchQueue? = nil, + receiveQueue: DispatchQueue? = nil, generalCache: Atomic? = nil, storage: Storage? = nil, scheduler: ValueObservationScheduler? = nil, standardUserDefaults: UserDefaultsType? = nil, date: Date? = nil ) { - _queue = Atomic(queue) + _subscribeQueue = Atomic(subscribeQueue) + _receiveQueue = Atomic(receiveQueue) _generalCache = Atomic(generalCache) _storage = Atomic(storage) _scheduler = Atomic(scheduler) diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 48752c5bf..a562cc961 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -57,6 +57,10 @@ public final class JobRunner { // Once all blocking jobs have been completed we want to start running // the remaining job queues queues.wrappedValue.forEach { _, queue in queue.start() } + blockingQueueDrainCallback.mutate { + $0.forEach { $0() } + $0 = [] + } } ) ) @@ -120,6 +124,12 @@ public final class JobRunner { private static var hasCompletedInitialBecomeActive: Atomic = Atomic(false) private static var shutdownBackgroundTask: Atomic = Atomic(nil) fileprivate static var canStartQueues: Atomic = Atomic(false) + private static var blockingQueueDrainCallback: Atomic<[() -> ()]> = Atomic([]) + + fileprivate static var canStartNonBlockingQueue: Bool { + blockingQueue.wrappedValue?.hasStartedAtLeastOnce.wrappedValue == true && + blockingQueue.wrappedValue?.isRunning.wrappedValue != true + } // MARK: - Configuration @@ -127,6 +137,15 @@ public final class JobRunner { executorMap.mutate { $0[variant] = executor } } + public static func afterBlockingQueue(callback: @escaping () -> ()) { + guard + (blockingQueue.wrappedValue?.hasStartedAtLeastOnce.wrappedValue != true) || + (blockingQueue.wrappedValue?.isRunning.wrappedValue == true) + else { return callback() } + + blockingQueueDrainCallback.mutate { $0.append(callback) } + } + // MARK: - Execution /// Add a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start @@ -444,8 +463,8 @@ public final class JobRunner { // MARK: - JobQueue -private final class JobQueue { - fileprivate enum QueueType: Hashable { +public final class JobQueue { + public enum QueueType: Hashable { case blocking case general(number: Int) case messageSend @@ -530,6 +549,7 @@ private final class JobQueue { }() private var nextTrigger: Atomic = Atomic(nil) + fileprivate var hasStartedAtLeastOnce: Atomic = Atomic(false) fileprivate var isRunning: Atomic = Atomic(false) private var queue: Atomic<[Job]> = Atomic([]) private var jobCallbacks: Atomic<[Int64: [(JobRunner.JobResult) -> ()]]> = Atomic([:]) @@ -541,7 +561,7 @@ private final class JobQueue { // MARK: - Initialization - init( + fileprivate init( type: QueueType, executionType: ExecutionType = .serial, qos: DispatchQoS, @@ -754,7 +774,11 @@ private final class JobQueue { HasAppContext() && CurrentAppContext().isMainApp && !CurrentAppContext().isRunningTests && - JobRunner.canStartQueues.wrappedValue + JobRunner.canStartQueues.wrappedValue && + ( + type == .blocking || + JobRunner.canStartNonBlockingQueue + ) else { return } guard force || !isRunning.wrappedValue else { return } @@ -774,6 +798,7 @@ private final class JobQueue { wasAlreadyRunning = isRunning isRunning = true } + hasStartedAtLeastOnce.mutate { $0 = true } // Get any pending jobs let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue diff --git a/SessionUtilitiesKit/Networking/Request.swift b/SessionUtilitiesKit/Networking/Request.swift index df2a04e78..943f2a1fa 100644 --- a/SessionUtilitiesKit/Networking/Request.swift +++ b/SessionUtilitiesKit/Networking/Request.swift @@ -2,7 +2,9 @@ import Foundation // MARK: - Convenience Types -public struct Empty: Codable {} +public struct Empty: Codable { + public init() {} +} public typealias NoBody = Empty public typealias NoResponse = Empty diff --git a/SessionUtilitiesKit/Utilities/Version.swift b/SessionUtilitiesKit/Utilities/Version.swift new file mode 100644 index 000000000..38daf00bf --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Version.swift @@ -0,0 +1,55 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct Version: Comparable { + public let major: Int + public let minor: Int + public let patch: Int + + public var stringValue: String { "\(major).\(minor).\(patch)" } + + // MARK: - Initialization + + public init( + major: Int, + minor: Int, + patch: Int + ) { + self.major = major + self.minor = minor + self.patch = patch + } + + // MARK: - Functions + + public static func from(_ versionString: String) -> Version { + var tokens: [Int] = versionString + .split(separator: ".") + .map { (Int($0) ?? 0) } + + // Extend to '{major}.{minor}.{patch}' if any parts were omitted + while tokens.count < 3 { + tokens.append(0) + } + + return Version(major: tokens[0], minor: tokens[1], patch: tokens[2]) + } + + // MARK: - Comparable + + public static func == (lhs: Version, rhs: Version) -> Bool { + return ( + lhs.major == rhs.major && + lhs.minor == rhs.minor && + lhs.patch == rhs.patch + ) + } + + public static func < (lhs: Version, rhs: Version) -> Bool { + guard lhs.major >= rhs.major else { return true } + guard lhs.minor >= rhs.minor else { return true } + + return (lhs.patch < rhs.patch) + } +} diff --git a/SessionUtilitiesKitTests/Utilities/VersionSpec.swift b/SessionUtilitiesKitTests/Utilities/VersionSpec.swift new file mode 100644 index 000000000..2d46e2f3d --- /dev/null +++ b/SessionUtilitiesKitTests/Utilities/VersionSpec.swift @@ -0,0 +1,98 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionUtilitiesKit + +class VersionSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a Version") { + it("can be created from a string") { + let version: Version = Version.from("1.20.3") + + expect(version.major).to(equal(1)) + expect(version.minor).to(equal(20)) + expect(version.patch).to(equal(3)) + } + + it("correctly exposes a string value") { + let version: Version = Version(major: 1, minor: 20, patch: 3) + + expect(version.stringValue).to(equal("1.20.3")) + } + + context("when checking equality") { + it("returns true if the values match") { + let version1: Version = Version.from("1.0.0") + let version2: Version = Version.from("1.0.0") + + expect(version1 == version2) + .to(beTrue()) + } + + it("returns false if the values do not match") { + let version1: Version = Version.from("1.0.0") + let version2: Version = Version.from("1.0.1") + + expect(version1 == version2) + .to(beFalse()) + } + } + + context("when comparing versions") { + it("returns correctly for a simple major difference") { + let version1: Version = Version.from("1.0.0") + let version2: Version = Version.from("2.0.0") + + expect(version1 < version2).to(beTrue()) + expect(version2 > version1).to(beTrue()) + } + + it("returns correctly for a complex major difference") { + let version1: Version = Version.from("2.90.90") + let version2: Version = Version.from("10.0.0") + + expect(version1 < version2).to(beTrue()) + expect(version2 > version1).to(beTrue()) + } + + it("returns correctly for a simple minor difference") { + let version1: Version = Version.from("1.0.0") + let version2: Version = Version.from("1.1.0") + + expect(version1 < version2).to(beTrue()) + expect(version2 > version1).to(beTrue()) + } + + it("returns correctly for a complex minor difference") { + let version1: Version = Version.from("90.2.90") + let version2: Version = Version.from("90.10.0") + + expect(version1 < version2).to(beTrue()) + expect(version2 > version1).to(beTrue()) + } + + it("returns correctly for a simple patch difference") { + let version1: Version = Version.from("1.0.0") + let version2: Version = Version.from("1.0.1") + + expect(version1 < version2).to(beTrue()) + expect(version2 > version1).to(beTrue()) + } + + it("returns correctly for a complex patch difference") { + let version1: Version = Version.from("90.90.2") + let version2: Version = Version.from("90.90.10") + + expect(version1 < version2).to(beTrue()) + expect(version2 > version1).to(beTrue()) + } + } + } + } +} diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 11f829932..5884bb82d 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -567,6 +567,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { loadingView.startAnimating() LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sink( receiveCompletion: { [weak self] result in diff --git a/_SharedTestUtilities/CombineExtensions.swift b/_SharedTestUtilities/CombineExtensions.swift index 1c8ac8505..f040a6008 100644 --- a/_SharedTestUtilities/CombineExtensions.swift +++ b/_SharedTestUtilities/CombineExtensions.swift @@ -7,8 +7,8 @@ import SessionUtilitiesKit public extension Publisher { func sinkAndStore(in storage: inout C) where C: RangeReplaceableCollection, C.Element == AnyCancellable { self - .subscribeOnMain(immediately: true) - .receiveOnMain(immediately: true) + .subscribe(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { _ in } @@ -22,7 +22,7 @@ public extension AnyPublisher { var value: Output? _ = self - .receiveOnMain(immediately: true) + .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sink( receiveCompletion: { _ in }, receiveValue: { result in value = result }