From d8fd3b35b438dc9ee88604b54f5b78a86ce23662 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 14 Oct 2022 17:09:38 +1100 Subject: [PATCH] Theming tweaks and bug fixes Made a tweak to prevent some odd looking keyboard transitions when going to conversation settings Updated the PagedDatabaseObserver to not call 'onChangeUnsorted' on the main thread (now we can generate the changeset on the background thread so there is less main thread work) Fixed an issue where the most recently received message from the swarm could be removed from the swarm yet the app would still poll for it, resulting in the swarm always returning the oldest possible messages until the user sends a new one-to-one message Fixed an issue where the initial scroll offset could be incorrect due to certain message types Fixed an issue where the title view inside a conversation could jump when pushing to the conversation settings screen Refactored a couple of ObjC functions to Swift as they were crashing (due to memory allocation?) hopefully this will fix it Tweaked some DispatchQueue priorities to ensure PagedDatabaseObserver loading is prioritised Updated buttons to use a standard convention for highlighted states Updated the new conversation button to follow the new highlighted state convention --- Session.xcodeproj/project.pbxproj | 12 +-- Session/Conversations/ConversationVC.swift | 44 ++++++++--- .../Conversations/ConversationViewModel.swift | 46 +++++++----- .../Input View/MentionSelectionView.swift | 2 +- .../Content Views/MediaPlaceholderView.swift | 7 +- .../Message Cells/VisibleMessageCell.swift | 6 ++ .../ConversationTitleView.swift | 38 +++++++--- Session/Home/HomeVC.swift | 55 +++++++++----- Session/Home/HomeViewModel.swift | 73 +++++++++++++------ .../MessageRequestsViewController.swift | 20 +++-- .../MessageRequestsViewModel.swift | 46 +++++++----- .../Views/MessageRequestsCell.swift | 2 +- .../New Conversation/NewConversationVC.swift | 2 +- .../DocumentTitleViewController.swift | 23 +++--- .../MediaGalleryViewModel.swift | 48 +++++++----- .../MediaTileViewController.swift | 31 +++++--- .../BlockedContactsViewController.swift | 20 +++-- .../Settings/BlockedContactsViewModel.swift | 46 +++++++----- .../Settings/Views/BlockedContactCell.swift | 2 +- .../Settings/Views/ThemeSelectionView.swift | 2 +- Session/Shared/FullConversationCell.swift | 10 +-- Session/Shared/Views/SessionCell.swift | 4 +- .../SessionHighlightingBackgroundLabel.swift | 4 +- Session/Utilities/BackgroundPoller.swift | 19 ++++- SessionMessagingKit/Messages/Message.swift | 2 +- .../Meta/SessionMessagingKit.h | 1 - .../Errors/MessageReceiverError.swift | 7 +- .../MessageReceiver+UnsendRequests.swift | 7 ++ .../Sending & Receiving/MessageReceiver.swift | 2 +- .../Sending & Receiving/MessageSender.swift | 6 +- .../Pollers/ClosedGroupPoller.swift | 30 +++++++- .../Sending & Receiving/Pollers/Poller.swift | 23 +++++- .../Utilities/Data+Utilities.swift | 47 ++++++++++++ .../Utilities/NSData+messagePadding.h | 11 --- .../Utilities/NSData+messagePadding.m | 60 --------------- .../SimplifiedConversationCell.swift | 2 +- SessionSnodeKit/Configuration.swift | 3 + ...04_FlagMessageHashAsDeletedOrInvalid.swift | 26 +++++++ .../Models/SnodeReceivedMessageInfo.swift | 48 ++++++++++++ SessionSnodeKit/SnodeAPI.swift | 31 +++++--- SessionUIKit/Components/Modal.swift | 2 +- .../Themes/Theme+ClassicDark.swift | 6 -- .../Themes/Theme+ClassicLight.swift | 6 -- .../Style Guide/Themes/Theme+OceanDark.swift | 8 +- .../Style Guide/Themes/Theme+OceanLight.swift | 6 -- SessionUIKit/Style Guide/Themes/Theme.swift | 21 ++++-- .../Utilities/UIColor+Utilities.swift | 24 ++++++ .../Types/PagedDatabaseObserver.swift | 11 +-- .../MediaMessageView.swift | 2 +- 49 files changed, 622 insertions(+), 332 deletions(-) delete mode 100644 SessionMessagingKit/Utilities/NSData+messagePadding.h delete mode 100644 SessionMessagingKit/Utilities/NSData+messagePadding.m create mode 100644 SessionSnodeKit/Database/Migrations/_004_FlagMessageHashAsDeletedOrInvalid.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index f43e8ea27..9834a1b66 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -636,6 +636,7 @@ FD37EA1728AC5605003AE748 /* NotificationContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA1628AC5605003AE748 /* NotificationContentViewModel.swift */; }; FD37EA1928AC5CCA003AE748 /* NotificationSoundViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA1828AC5CCA003AE748 /* NotificationSoundViewModel.swift */; }; FD39352C28F382920084DADA /* VersionFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD39352B28F382920084DADA /* VersionFooterView.swift */; }; + FD39353628F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD39353528F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift */; }; FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */; }; FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */; }; @@ -716,7 +717,6 @@ FD716E6C28505E1C00C96BF4 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */; }; FD716E7128505E5200C96BF4 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */; }; FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; - FD716E732850647900C96BF4 /* NSData+messagePadding.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */; settings = {ATTRIBUTES = (Public, ); }; }; FD7728962849E7E90018502F /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728952849E7E90018502F /* String+Utilities.swift */; }; FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728972849E8110018502F /* UITableView+ReusableView.swift */; }; FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD772899284AF1BD0018502F /* Sodium+Utilities.swift */; }; @@ -746,7 +746,6 @@ FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */; }; FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF927C2F5C500510D0C /* MockGenericHash.swift */; }; FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFB27C2F60700510D0C /* MockEd25519.swift */; }; - FD86585828507B24008B6CF9 /* NSData+messagePadding.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D4825589FF20043A11F /* NSData+messagePadding.m */; }; FD87DCFA28B74DB300AF0F98 /* ConversationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */; }; FD87DCFC28B755B800AF0F98 /* BlockedContactsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFB28B755B800AF0F98 /* BlockedContactsViewController.swift */; }; FD87DCFE28B7582C00AF0F98 /* BlockedContactsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */; }; @@ -1540,8 +1539,6 @@ C3A71D0A2558989C0043A11F /* MessageWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageWrapper.swift; sourceTree = ""; }; C3A71D1C25589AC30043A11F /* WebSocketProto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebSocketProto.swift; sourceTree = ""; }; C3A71D1D25589AC30043A11F /* WebSocketResources.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebSocketResources.pb.swift; sourceTree = ""; }; - C3A71D4825589FF20043A11F /* NSData+messagePadding.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSData+messagePadding.m"; sourceTree = ""; }; - C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSData+messagePadding.h"; sourceTree = ""; }; C3A71D662558A0170043A11F /* DiffieHellman.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiffieHellman.swift; sourceTree = ""; }; C3A71F882558BA9F0043A11F /* Mnemonic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mnemonic.swift; sourceTree = ""; }; C3A721992558C1660043A11F /* AnyPromise+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyPromise+Conversion.swift"; sourceTree = ""; }; @@ -1721,6 +1718,7 @@ FD37EA1828AC5CCA003AE748 /* NotificationSoundViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundViewModel.swift; sourceTree = ""; }; FD37EA1A28ACB51F003AE748 /* _007_HomeQueryOptimisationIndexes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _007_HomeQueryOptimisationIndexes.swift; sourceTree = ""; }; FD39352B28F382920084DADA /* VersionFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionFooterView.swift; sourceTree = ""; }; + FD39353528F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _004_FlagMessageHashAsDeletedOrInvalid.swift; sourceTree = ""; }; FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestInfoSpec.swift; sourceTree = ""; }; FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponseSpec.swift; sourceTree = ""; }; @@ -3129,8 +3127,6 @@ C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, - C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */, - C3A71D4825589FF20043A11F /* NSData+messagePadding.m */, FD09797E27FCFBFF00936362 /* OWSAES256Key+Utilities.swift */, C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */, C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */, @@ -3570,6 +3566,7 @@ FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */, FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */, FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */, + FD39353528F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift */, ); path = Migrations; sourceTree = ""; @@ -4208,7 +4205,6 @@ files = ( C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */, C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */, - FD716E732850647900C96BF4 /* NSData+messagePadding.h in Headers */, B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */, B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, ); @@ -5257,6 +5253,7 @@ C3C2A5E42553860B00C340D1 /* Data+Utilities.swift in Sources */, FD17D7A727F41AF000122BE0 /* SSKLegacy.swift in Sources */, FDC438B327BB15B400C60D73 /* ResponseInfo.swift in Sources */, + FD39353628F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift in Sources */, C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */, FD17D7D827F658E200122BE0 /* OnionRequestAPIDestination.swift in Sources */, FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */, @@ -5379,7 +5376,6 @@ buildActionMask = 2147483647; files = ( 7B81682828B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift in Sources */, - FD86585828507B24008B6CF9 /* NSData+messagePadding.m in Sources */, FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */, B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 1518c5663..620a4c4ea 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -53,6 +53,10 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl var scrollDistanceToBottomBeforeUpdate: CGFloat? var baselineKeyboardHeight: CGFloat = 0 + /// This flag is true between `viewDidAppear` and `viewWillDisappear` and is used to prevent keyboard changes + /// from trying to animate (as the animations can cause staggering with push transitions) + var viewIsFocussed = false + // Reaction var currentReactionListSheet: ReactionListSheet? var reactionExpandedMessageIds: Set = [] @@ -402,6 +406,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // Flag that the initial layout has been completed (the flag blocks and unblocks a number // of different behaviours) didFinishInitialLayout = true + viewIsFocussed = true if delayFirstResponder || isShowingSearchUI { delayFirstResponder = false @@ -420,6 +425,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + viewIsFocussed = false + // Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard // to appear to remain focussed) guard !isReplacingThread else { return } @@ -499,8 +506,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // Note: We want to load the interaction data into the UI after the initial thread data // has loaded to prevent an issue where the conversation loads with the wrong offset if self?.viewModel.onInteractionChange == nil { - self?.viewModel.onInteractionChange = { [weak self] updatedInteractionData in - self?.handleInteractionUpdates(updatedInteractionData) + self?.viewModel.onInteractionChange = { [weak self] updatedInteractionData, changeset in + self?.handleInteractionUpdates(updatedInteractionData, changeset: changeset) } // Note: When returning from the background we could have received notifications but the @@ -524,9 +531,18 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // Ensure the first load or a load when returning from a child screen runs without animations (if // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) guard hasLoadedInitialThreadData && hasReloadedThreadDataAfterDisappearance else { + // Need to correctly determine if it's the initial load otherwise we would be needlesly updating + // extra UI elements + let isInitialLoad: Bool = ( + !hasLoadedInitialThreadData && + hasReloadedThreadDataAfterDisappearance + ) hasLoadedInitialThreadData = true hasReloadedThreadDataAfterDisappearance = true - UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) } + + UIView.performWithoutAnimation { + handleThreadUpdates(updatedThreadData, initialLoad: isInitialLoad) + } return } @@ -621,7 +637,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } } - private func handleInteractionUpdates(_ updatedData: [ConversationViewModel.SectionModel], initialLoad: Bool = false) { + private func handleInteractionUpdates( + _ updatedData: [ConversationViewModel.SectionModel], + changeset: StagedChangeset<[ConversationViewModel.SectionModel]>, + initialLoad: Bool = false + ) { // Ensure the first load or a load when returning from a child screen runs without // animations (if we don't do this the cells will animate in from a frame of // CGRect.zero or have a buggy transition) @@ -682,10 +702,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } } - let changeset: StagedChangeset<[ConversationViewModel.SectionModel]> = StagedChangeset( - source: viewModel.interactionData, - target: updatedData - ) let numItemsInserted: Int = changeset.map { $0.elementInserted.count }.reduce(0, +) let isInsert: Bool = (numItemsInserted > 0) let wasLoadingMore: Bool = self.isLoadingMore @@ -955,7 +971,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl self?.isLoadingMore = true - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in // Attachments are loaded in descending order so 'loadOlder' actually corresponds with // 'pageAfter' in this case self?.viewModel.pagedDataObserver?.load(shouldLoadOlder ? @@ -1050,6 +1066,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // MARK: - Notifications @objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { + guard viewIsFocussed || !didFinishInitialLayout else { return } + // Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600 // and https://stackoverflow.com/a/25260930 to better understand what we are // doing with the UIViewAnimationOptions @@ -1096,7 +1114,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } // Perform the changes (don't animate if the initial layout hasn't been completed) - guard hasDoneLayout else { + guard hasDoneLayout && didFinishInitialLayout else { UIView.performWithoutAnimation { changes() } @@ -1113,6 +1131,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } @objc func handleKeyboardWillHideNotification(_ notification: Notification) { + guard viewIsFocussed else { return } + // Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600 // and https://stackoverflow.com/a/25260930 to better understand what we are // doing with the UIViewAnimationOptions @@ -1273,7 +1293,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl case .loadOlder, .loadNewer: self.isLoadingMore = true - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in // Messages are loaded in descending order so 'loadOlder' actually corresponds with // 'pageAfter' in this case self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ? @@ -1543,7 +1563,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl self.isLoadingMore = true self.searchController.resultsBar.startLoading() - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in if isJumpingToLastInteraction { self?.viewModel.pagedDataObserver?.load(.jumpTo( id: interactionId, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index dbc15503a..5d7d7eccd 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -86,7 +86,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { ) // Run the initial query on a background thread so we don't block the push transition - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query // from a `0` offset) guard let initialFocusedId: Int64 = targetInteractionId else { @@ -150,17 +150,17 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Interaction Data private var lastInteractionIdMarkedAsRead: Int64? - public private(set) var unobservedInteractionDataChanges: [SectionModel]? + public private(set) var unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)? public private(set) var interactionData: [SectionModel] = [] public private(set) var reactionExpandedInteractionIds: Set = [] public private(set) var pagedDataObserver: PagedDatabaseObserver? - public var onInteractionChange: (([SectionModel]) -> ())? { + public var onInteractionChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? { didSet { // When starting to observe interaction changes we want to trigger a UI update just in case the // data was changed while we weren't observing - if let unobservedInteractionDataChanges: [SectionModel] = self.unobservedInteractionDataChanges { - onInteractionChange?(unobservedInteractionDataChanges) + if let unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges { + onInteractionChange?(unobservedInteractionDataChanges.0, unobservedInteractionDataChanges.1) self.unobservedInteractionDataChanges = nil } } @@ -247,20 +247,32 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { ) ], onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in - guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { - return - } + guard + let currentData: [SectionModel] = self?.interactionData, + let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) + else { return } + + let changeset: StagedChangeset<[SectionModel]> = StagedChangeset( + source: currentData, + target: updatedInteractionData + ) - // If we have the 'onInteractionChanged' callback then trigger it, otherwise just store the changes - // to be sent to the callback if we ever start observing again (when we have the callback it needs - // to do the data updating as it's tied to UI updates and can cause crashes if not updated in the - // correct order) - guard let onInteractionChange: (([SectionModel]) -> ()) = self?.onInteractionChange else { - self?.unobservedInteractionDataChanges = updatedInteractionData - return + // No need to do anything if there were no changes + guard !changeset.isEmpty else { return } + + // Run any changes on the main thread (as they will generally trigger UI updates) + DispatchQueue.main.async { + // If we have the callback then trigger it, otherwise just store the changes to be sent + // to the callback if we ever start observing again (when we have the callback it needs + // to do the data updating as it's tied to UI updates and can cause crashes if not updated + // in the correct order) + guard let onInteractionChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self?.onInteractionChange else { + self?.unobservedInteractionDataChanges = (updatedInteractionData, changeset) + return + } + + onInteractionChange(updatedInteractionData, changeset) } - - onInteractionChange(updatedInteractionData) } ) } diff --git a/Session/Conversations/Input View/MentionSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift index 7385d77fb..70ceb6b4f 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -151,7 +151,7 @@ private extension MentionSelectionView { // Highlight color let selectedBackgroundView = UIView() - selectedBackgroundView.themeBackgroundColor = .settings_tabHighlight + selectedBackgroundView.themeBackgroundColor = .highlighted(.settings_tabBackground) self.selectedBackgroundView = selectedBackgroundView // Profile picture image view diff --git a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift index 3e4569b41..09939731c 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift @@ -69,9 +69,10 @@ final class MediaPlaceholderView: UIView { let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ]) stackView.axis = .horizontal stackView.alignment = .center - stackView.isLayoutMarginsRelativeArrangement = true - stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 12) addSubview(stackView) - stackView.pin(to: self, withInset: Values.smallSpacing) + stackView.pin(.top, to: .top, of: self, withInset: Values.smallSpacing) + stackView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing) + stackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.largeSpacing) + stackView.pin(.bottom, to: .bottom, of: self, withInset: -Values.smallSpacing) } } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 831dbbae9..84e3004ed 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -399,6 +399,12 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { !cellViewModel.isLast ) ) + + // Set the height of the underBubbleStackView to 0 if it has no content (need to do this + // otherwise it can randomly stretch) + underBubbleStackViewNoHeightConstraint.isActive = underBubbleStackView.arrangedSubviews + .filter { !$0.isHidden } + .isEmpty } private func populateContentView( diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index f9c1e549e..717d4eeba 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -9,12 +9,17 @@ final class ConversationTitleView: UIView { private static let leftInset: CGFloat = 8 private static let leftInsetWithCallButton: CGFloat = 54 + private var oldSize: CGSize = .zero + override var intrinsicContentSize: CGSize { return UIView.layoutFittingExpandedSize } // MARK: - UI Components + private lazy var stackViewLeadingConstraint: NSLayoutConstraint = stackView.pin(.leading, to: .leading, of: self) + private lazy var stackViewTrailingConstraint: NSLayoutConstraint = stackView.pin(.trailing, to: .trailing, of: self) + private lazy var titleLabel: UILabel = { let result: UILabel = UILabel() result.font = .boldSystemFont(ofSize: Values.mediumFontSize) @@ -37,7 +42,6 @@ final class ConversationTitleView: UIView { let result = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ]) result.axis = .vertical result.alignment = .center - result.isLayoutMarginsRelativeArrangement = true return result }() @@ -49,7 +53,10 @@ final class ConversationTitleView: UIView { addSubview(stackView) - stackView.pin(to: self) + stackView.pin(.top, to: .top, of: self) + stackViewLeadingConstraint.isActive = true + stackViewTrailingConstraint.isActive = true + stackView.pin(.bottom, to: .bottom, of: self) } deinit { @@ -73,6 +80,21 @@ final class ConversationTitleView: UIView { ) } + override func layoutSubviews() { + super.layoutSubviews() + + // There is an annoying issue where pushing seems to update the width of this + // view resulting in the content shifting to the right during + guard self.oldSize != .zero, self.oldSize != bounds.size else { + self.oldSize = bounds.size + return + } + + let diff: CGFloat = (bounds.size.width - oldSize.width) + self.stackViewTrailingConstraint.constant = -max(0, diff) + self.oldSize = bounds.size + } + public func update( with name: String, isNoteToSelf: Bool, @@ -161,14 +183,10 @@ final class ConversationTitleView: UIView { !isNoteToSelf && threadVariant == .contact ) - self.stackView.layoutMargins = UIEdgeInsets( - top: 0, - left: (shouldShowCallButton ? - ConversationTitleView.leftInsetWithCallButton : - ConversationTitleView.leftInset - ), - bottom: 0, - right: 0 + self.stackViewLeadingConstraint.constant = (shouldShowCallButton ? + ConversationTitleView.leftInsetWithCallButton : + ConversationTitleView.leftInset ) + self.stackViewTrailingConstraint.constant = 0 } } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 6af6842fb..686a15076 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -101,27 +101,36 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi return result }() - private lazy var newConversationButton: UIButton = { - let result = UIButton(type: .system) - result.clipsToBounds = false - result.setImage( + private lazy var newConversationButton: UIView = { + let result: UIView = UIView() + result.set(.width, to: HomeVC.newConversationButtonSize) + result.set(.height, to: HomeVC.newConversationButtonSize) + + let button = UIButton() + button.clipsToBounds = true + button.setImage( UIImage(named: "Plus")? .withRenderingMode(.alwaysTemplate), for: .normal ) - result.contentMode = .center - result.themeBackgroundColor = .menuButton_background - result.themeTintColor = .menuButton_icon - result.contentEdgeInsets = UIEdgeInsets( + button.contentMode = .center + button.adjustsImageWhenHighlighted = false + button.themeTintColor = .menuButton_icon + button.setThemeBackgroundColor(.menuButton_background, for: .normal) + button.setThemeBackgroundColor( + .highlighted(.menuButton_background, alwaysDarken: true), + for: .highlighted + ) + button.contentEdgeInsets = UIEdgeInsets( top: ((HomeVC.newConversationButtonSize - 24) / 2), leading: ((HomeVC.newConversationButtonSize - 24) / 2), bottom: ((HomeVC.newConversationButtonSize - 24) / 2), trailing: ((HomeVC.newConversationButtonSize - 24) / 2) ) - result.layer.cornerRadius = (HomeVC.newConversationButtonSize / 2) - result.addTarget(self, action: #selector(createNewConversation), for: .touchUpInside) - result.set(.width, to: HomeVC.newConversationButtonSize) - result.set(.height, to: HomeVC.newConversationButtonSize) + button.layer.cornerRadius = (HomeVC.newConversationButtonSize / 2) + button.addTarget(self, action: #selector(createNewConversation), for: .touchUpInside) + result.addSubview(button) + button.pin(to: result) // Add the outer shadow result.themeShadowColor = .menuButton_outerShadow @@ -323,15 +332,15 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi } ) - self.viewModel.onThreadChange = { [weak self] updatedThreadData in - self?.handleThreadUpdates(updatedThreadData) + self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in + self?.handleThreadUpdates(updatedThreadData, changeset: changeset) } // Note: When returning from the background we could have received notifications but the // PagedDatabaseObserver won't have them so we need to force a re-fetch of the current // data to ensure everything is up to date if didReturnFromBackground { - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.viewModel.pagedDataObserver?.reload() } } @@ -372,12 +381,18 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi self.viewModel.updateState(updatedState) } - private func handleThreadUpdates(_ updatedData: [HomeViewModel.SectionModel], initialLoad: Bool = false) { + private func handleThreadUpdates( + _ updatedData: [HomeViewModel.SectionModel], + changeset: StagedChangeset<[HomeViewModel.SectionModel]>, + initialLoad: Bool = false + ) { // Ensure the first load runs without animations (if we don't do this the cells will animate // in from a frame of CGRect.zero) guard hasLoadedInitialThreadData else { hasLoadedInitialThreadData = true - UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) } + UIView.performWithoutAnimation { + handleThreadUpdates(updatedData, changeset: changeset, initialLoad: true) + } return } @@ -399,7 +414,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi // Reload the table content (animate changes after the first load) tableView.reload( - using: StagedChangeset(source: viewModel.threadData, target: updatedData), + using: changeset, deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, @@ -438,7 +453,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi self?.isLoadingMore = true - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.viewModel.pagedDataObserver?.load(.pageAfter) } } @@ -559,7 +574,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi case .loadMore: self.isLoadingMore = true - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.viewModel.pagedDataObserver?.load(.pageAfter) } diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 37d011a0a..8a14aa19a 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -150,20 +150,42 @@ public class HomeViewModel { orderSQL: SessionThreadViewModel.homeOrderSQL ), onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in - guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { - return + guard + let currentData: [SectionModel] = self?.threadData, + let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) + else { return } + + let changeset: StagedChangeset<[SectionModel]> = StagedChangeset( + source: currentData, + target: updatedThreadData + ) + + // No need to do anything if there were no changes + guard !changeset.isEmpty else { return } + + let performUpdates = { + // If we have the callback then trigger it, otherwise just store the changes to be sent + // to the callback if we ever start observing again (when we have the callback it needs + // to do the data updating as it's tied to UI updates and can cause crashes if not updated + // in the correct order) + guard let onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self?.onThreadChange else { + self?.unobservedThreadDataChanges = (updatedThreadData, changeset) + return + } + + onThreadChange(updatedThreadData, changeset) } - // If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes - // to be sent to the callback if we ever start observing again (when we have the callback it needs - // to do the data updating as it's tied to UI updates and can cause crashes if not updated in the - // correct order) - guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else { - self?.unobservedThreadDataChanges = updatedThreadData - return + // Note: On the initial launch the data will be fetched on the main thread and we want it + // to block so don't dispatch to the next run loop + guard !Thread.isMainThread else { + return performUpdates() + } + + // Run any changes on the main thread (as they will generally trigger UI updates) + DispatchQueue.main.async { + performUpdates() } - - onThreadChange(updatedThreadData) } ) @@ -219,30 +241,39 @@ public class HomeViewModel { else { return } /// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above - let currentData: [SessionThreadViewModel] = (self.unobservedThreadDataChanges ?? self.threadData) - .flatMap { $0.elements } - let updatedThreadData: [SectionModel] = self.process(data: currentData, for: currentPageInfo) + let currentData: [SectionModel] = (self.unobservedThreadDataChanges?.0 ?? self.threadData) + let updatedThreadData: [SectionModel] = self.process( + data: currentData.flatMap { $0.elements }, + for: currentPageInfo + ) + let changeset: StagedChangeset<[SectionModel]> = StagedChangeset( + source: currentData, + target: updatedThreadData + ) + + // No need to do anything if there were no changes + guard !changeset.isEmpty else { return } - guard let onThreadChange: (([SectionModel]) -> ()) = self.onThreadChange else { - self.unobservedThreadDataChanges = updatedThreadData + guard let onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self.onThreadChange else { + self.unobservedThreadDataChanges = (updatedThreadData, changeset) return } - onThreadChange(updatedThreadData) + onThreadChange(updatedThreadData, changeset) } // MARK: - Thread Data - public private(set) var unobservedThreadDataChanges: [SectionModel]? + public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)? public private(set) var threadData: [SectionModel] = [] public private(set) var pagedDataObserver: PagedDatabaseObserver? - public var onThreadChange: (([SectionModel]) -> ())? { + public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? { didSet { // When starting to observe interaction changes we want to trigger a UI update just in case the // data was changed while we weren't observing - if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges { - onThreadChange?(unobservedThreadDataChanges) + if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges { + onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1) self.unobservedThreadDataChanges = nil } } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index e1600d0b7..b64ccfdc7 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -204,8 +204,8 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // MARK: - Updating private func startObservingChanges(didReturnFromBackground: Bool = false) { - self.viewModel.onThreadChange = { [weak self] updatedThreadData in - self?.handleThreadUpdates(updatedThreadData) + self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in + self?.handleThreadUpdates(updatedThreadData, changeset: changeset) } // Note: When returning from the background we could have received notifications but the @@ -216,12 +216,18 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat } } - private func handleThreadUpdates(_ updatedData: [MessageRequestsViewModel.SectionModel], initialLoad: Bool = false) { + private func handleThreadUpdates( + _ updatedData: [MessageRequestsViewModel.SectionModel], + changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>, + initialLoad: Bool = false + ) { // Ensure the first load runs without animations (if we don't do this the cells will animate // in from a frame of CGRect.zero) guard hasLoadedInitialThreadData else { hasLoadedInitialThreadData = true - UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) } + UIView.performWithoutAnimation { + handleThreadUpdates(updatedData, changeset: changeset, initialLoad: true) + } return } @@ -241,7 +247,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // Reload the table content (animate changes after the first load) tableView.reload( - using: StagedChangeset(source: viewModel.threadData, target: updatedData), + using: changeset, deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, @@ -280,7 +286,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat self?.isLoadingMore = true - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.viewModel.pagedDataObserver?.load(.pageAfter) } } @@ -352,7 +358,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat case .loadMore: self.isLoadingMore = true - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.viewModel.pagedDataObserver?.load(.pageAfter) } diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index aa2cf3e22..555b768e9 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -98,25 +98,37 @@ public class MessageRequestsViewModel { orderSQL: SessionThreadViewModel.messageRequetsOrderSQL ), onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in - guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { - return - } + guard + let currentData: [SectionModel] = self?.threadData, + let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) + else { return } + + let changeset: StagedChangeset<[SectionModel]> = StagedChangeset( + source: currentData, + target: updatedThreadData + ) - // If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes - // to be sent to the callback if we ever start observing again (when we have the callback it needs - // to do the data updating as it's tied to UI updates and can cause crashes if not updated in the - // correct order) - guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else { - self?.unobservedThreadDataChanges = updatedThreadData - return + // No need to do anything if there were no changes + guard !changeset.isEmpty else { return } + + // Run any changes on the main thread (as they will generally trigger UI updates) + DispatchQueue.main.async { + // If we have the callback then trigger it, otherwise just store the changes to be sent + // to the callback if we ever start observing again (when we have the callback it needs + // to do the data updating as it's tied to UI updates and can cause crashes if not updated + // in the correct order) + guard let onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self?.onThreadChange else { + self?.unobservedThreadDataChanges = (updatedThreadData, changeset) + return + } + + onThreadChange(updatedThreadData, changeset) } - - onThreadChange(updatedThreadData) } ) // Run the initial query on a background thread so we don't block the push transition - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in // The `.pageBefore` will query from a `0` offset loading the first page self?.pagedDataObserver?.load(.pageBefore) } @@ -124,16 +136,16 @@ public class MessageRequestsViewModel { // MARK: - Thread Data - public private(set) var unobservedThreadDataChanges: [SectionModel]? + public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)? public private(set) var threadData: [SectionModel] = [] public private(set) var pagedDataObserver: PagedDatabaseObserver? - public var onThreadChange: (([SectionModel]) -> ())? { + public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? { didSet { // When starting to observe interaction changes we want to trigger a UI update just in case the // data was changed while we weren't observing - if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges { - onThreadChange?(unobservedThreadDataChanges) + if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges { + self.onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1) self.unobservedThreadDataChanges = nil } } diff --git a/Session/Home/Message Requests/Views/MessageRequestsCell.swift b/Session/Home/Message Requests/Views/MessageRequestsCell.swift index 1299a20d8..5141500eb 100644 --- a/Session/Home/Message Requests/Views/MessageRequestsCell.swift +++ b/Session/Home/Message Requests/Views/MessageRequestsCell.swift @@ -78,7 +78,7 @@ class MessageRequestsCell: UITableViewCell { private func setUpViewHierarchy() { themeBackgroundColor = .conversationButton_unreadBackground selectedBackgroundView = UIView() - selectedBackgroundView?.themeBackgroundColor = .conversationButton_unreadHighlight + selectedBackgroundView?.themeBackgroundColor = .highlighted(.conversationButton_unreadBackground) contentView.addSubview(iconContainerView) contentView.addSubview(titleLabel) diff --git a/Session/Home/New Conversation/NewConversationVC.swift b/Session/Home/New Conversation/NewConversationVC.swift index 2292d0fa9..9993c6d00 100644 --- a/Session/Home/New Conversation/NewConversationVC.swift +++ b/Session/Home/New Conversation/NewConversationVC.swift @@ -230,7 +230,7 @@ private final class NewConversationButton: UIView { private let selectedBackgroundView: UIView = { let result: UIView = UIView() - result.themeBackgroundColor = .settings_tabHighlight + result.themeBackgroundColor = .highlighted(.settings_tabBackground) result.isHidden = true return result diff --git a/Session/Media Viewing & Editing/DocumentTitleViewController.swift b/Session/Media Viewing & Editing/DocumentTitleViewController.swift index 87fc83d7e..7a1e34733 100644 --- a/Session/Media Viewing & Editing/DocumentTitleViewController.swift +++ b/Session/Media Viewing & Editing/DocumentTitleViewController.swift @@ -166,10 +166,12 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate, case .loadNewer, .loadOlder: // Attachments are loaded in descending order so 'loadOlder' actually corresponds with // 'pageAfter' in this case - self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ? - .pageAfter : - .pageBefore - ) + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ? + .pageAfter : + .pageBefore + ) + } return default: continue @@ -180,8 +182,8 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate, private func startObservingChanges() { // Start observing for data changes (will callback on the main thread) - self.viewModel.onGalleryChange = { [weak self] updatedGalleryData in - self?.handleUpdates(updatedGalleryData) + self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, changeset in + self?.handleUpdates(updatedGalleryData, changeset: changeset) } } @@ -191,7 +193,10 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate, self.viewModel.onGalleryChange = nil } - private func handleUpdates(_ updatedGalleryData: [MediaGalleryViewModel.SectionModel]) { + private func handleUpdates( + _ updatedGalleryData: [MediaGalleryViewModel.SectionModel], + changeset: StagedChangeset<[MediaGalleryViewModel.SectionModel]> + ) { // Ensure the first load runs without animations (if we don't do this the cells will animate // in from a frame of CGRect.zero) guard hasLoadedInitialData else { @@ -227,7 +232,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate, if isInsertingAtTop { CATransaction.setDisableActions(true) } self.tableView.reload( - using: StagedChangeset(source: self.viewModel.galleryData, target: updatedGalleryData), + using: changeset, with: .automatic, interrupt: { $0.changeCount > MediaTileViewController.itemPageSize } ) { [weak self] updatedData in @@ -418,7 +423,7 @@ class DocumentCell: UITableViewCell { backgroundView?.layer.cornerRadius = 5 selectedBackgroundView = UIView() - selectedBackgroundView?.themeBackgroundColor = .settings_tabHighlight + selectedBackgroundView?.themeBackgroundColor = .highlighted(.settings_tabBackground) selectedBackgroundView?.layer.cornerRadius = 5 contentView.addSubview(iconImageView) diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index cec1a3a0d..c9e25c925 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -42,14 +42,14 @@ public class MediaGalleryViewModel { public private(set) var pagedDataObserver: PagedDatabaseObserver? /// This value is the current state of a gallery view - private var unobservedGalleryDataChanges: [SectionModel]? + private var unobservedGalleryDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)? public private(set) var galleryData: [SectionModel] = [] - public var onGalleryChange: (([SectionModel]) -> ())? { + public var onGalleryChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? { didSet { // When starting to observe interaction changes we want to trigger a UI update just in case the // data was changed while we weren't observing - if let unobservedGalleryDataChanges: [SectionModel] = self.unobservedGalleryDataChanges { - onGalleryChange?(unobservedGalleryDataChanges) + if let unobservedGalleryDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges { + onGalleryChange?(unobservedGalleryDataChanges.0, unobservedGalleryDataChanges.1) self.unobservedGalleryDataChanges = nil } } @@ -93,20 +93,32 @@ public class MediaGalleryViewModel { orderSQL: Item.galleryOrderSQL, dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL), onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in - guard let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { - return - } + guard + let currentData: [SectionModel] = self?.galleryData, + let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) + else { return } + + let changeset: StagedChangeset<[SectionModel]> = StagedChangeset( + source: currentData, + target: updatedGalleryData + ) - // If we have the 'onGalleryChange' callback then trigger it, otherwise just store the changes - // to be sent to the callback if we ever start observing again (when we have the callback it needs - // to do the data updating as it's tied to UI updates and can cause crashes if not updated in the - // correct order) - guard let onGalleryChange: (([SectionModel]) -> ()) = self?.onGalleryChange else { - self?.unobservedGalleryDataChanges = updatedGalleryData - return + // No need to do anything if there were no changes + guard !changeset.isEmpty else { return } + + // Run any changes on the main thread (as they will generally trigger UI updates) + DispatchQueue.main.async { + // If we have the callback then trigger it, otherwise just store the changes to be sent + // to the callback if we ever start observing again (when we have the callback it needs + // to do the data updating as it's tied to UI updates and can cause crashes if not updated + // in the correct order) + guard let onGalleryChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self?.onGalleryChange else { + self?.unobservedGalleryDataChanges = (updatedGalleryData, changeset) + return + } + + onGalleryChange(updatedGalleryData, changeset) } - - onGalleryChange(updatedGalleryData) } ) @@ -128,11 +140,11 @@ public class MediaGalleryViewModel { // we don't want to mess with the initial view controller behaviour) guard !performInitialQuerySync else { loadInitialData() - updateGalleryData(self.unobservedGalleryDataChanges ?? []) + updateGalleryData(self.unobservedGalleryDataChanges?.0 ?? []) return } - DispatchQueue.global(qos: .default).async { + DispatchQueue.global(qos: .userInitiated).async { loadInitialData() } } diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 3456b4896..bbd5506c4 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -262,10 +262,12 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour case .loadNewer, .loadOlder: // Attachments are loaded in descending order so 'loadOlder' actually corresponds with // 'pageAfter' in this case - self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ? - .pageAfter : - .pageBefore - ) + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ? + .pageAfter : + .pageBefore + ) + } return default: continue @@ -276,8 +278,8 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour private func startObservingChanges(didReturnFromBackground: Bool = false) { // Start observing for data changes (will callback on the main thread) - self.viewModel.onGalleryChange = { [weak self] updatedGalleryData in - self?.handleUpdates(updatedGalleryData) + self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, changeset in + self?.handleUpdates(updatedGalleryData, changeset: changeset) } // Note: When returning from the background we could have received notifications but the @@ -294,7 +296,10 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour self.viewModel.onGalleryChange = nil } - private func handleUpdates(_ updatedGalleryData: [MediaGalleryViewModel.SectionModel]) { + private func handleUpdates( + _ updatedGalleryData: [MediaGalleryViewModel.SectionModel], + changeset: StagedChangeset<[MediaGalleryViewModel.SectionModel]> + ) { // Ensure the first load runs without animations (if we don't do this the cells will animate // in from a frame of CGRect.zero) guard hasLoadedInitialData else { @@ -341,7 +346,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour self.mediaTileViewLayout.isInsertingCellsToTop = isInsertingAtTop self.mediaTileViewLayout.contentSizeBeforeInsertingToTop = self.collectionView.contentSize self.collectionView.reload( - using: StagedChangeset(source: self.viewModel.galleryData, target: updatedGalleryData), + using: changeset, interrupt: { $0.changeCount > MediaTileViewController.itemPageSize } ) { [weak self] updatedData in self?.viewModel.updateGalleryData(updatedData) @@ -456,10 +461,12 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour UIScrollView.fastEndScrollingThen(collectionView, self.currentTargetOffset) { [weak self] in // Attachments are loaded in descending order so 'loadOlder' actually corresponds with // 'pageAfter' in this case - self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ? - .pageAfter : - .pageBefore - ) + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ? + .pageAfter : + .pageBefore + ) + } } case .emptyGallery, .galleryMonth: break diff --git a/Session/Settings/BlockedContactsViewController.swift b/Session/Settings/BlockedContactsViewController.swift index c725f08af..7fbdb00af 100644 --- a/Session/Settings/BlockedContactsViewController.swift +++ b/Session/Settings/BlockedContactsViewController.swift @@ -186,8 +186,8 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat // MARK: - Updating private func startObservingChanges(didReturnFromBackground: Bool = false) { - self.viewModel.onContactChange = { [weak self] updatedContactData in - self?.handleContactUpdates(updatedContactData) + self.viewModel.onContactChange = { [weak self] updatedContactData, changeset in + self?.handleContactUpdates(updatedContactData, changeset: changeset) } // Note: When returning from the background we could have received notifications but the @@ -198,12 +198,18 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat } } - private func handleContactUpdates(_ updatedData: [BlockedContactsViewModel.SectionModel], initialLoad: Bool = false) { + private func handleContactUpdates( + _ updatedData: [BlockedContactsViewModel.SectionModel], + changeset: StagedChangeset<[BlockedContactsViewModel.SectionModel]>, + initialLoad: Bool = false + ) { // Ensure the first load runs without animations (if we don't do this the cells will animate // in from a frame of CGRect.zero) guard hasLoadedInitialContactData else { hasLoadedInitialContactData = true - UIView.performWithoutAnimation { handleContactUpdates(updatedData, initialLoad: true) } + UIView.performWithoutAnimation { + handleContactUpdates(updatedData, changeset: changeset, initialLoad: true) + } return } @@ -225,7 +231,7 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat // Reload the table content (animate changes after the first load) tableView.reload( - using: StagedChangeset(source: viewModel.contactData, target: updatedData), + using: changeset, deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, @@ -266,7 +272,7 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat self?.isLoadingMore = true - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.viewModel.pagedDataObserver?.load(.pageAfter) } } @@ -351,7 +357,7 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat case .loadMore: self.isLoadingMore = true - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.viewModel.pagedDataObserver?.load(.pageAfter) } diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index 61c454450..68ff46a86 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -62,25 +62,37 @@ public class BlockedContactsViewModel { orderSQL: DataModel.orderSQL ), onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in - guard let updatedContactData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { - return - } + guard + let currentData: [SectionModel] = self?.contactData, + let updatedContactData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) + else { return } + + let changeset: StagedChangeset<[SectionModel]> = StagedChangeset( + source: currentData, + target: updatedContactData + ) - // If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes - // to be sent to the callback if we ever start observing again (when we have the callback it needs - // to do the data updating as it's tied to UI updates and can cause crashes if not updated in the - // correct order) - guard let onContactChange: (([SectionModel]) -> ()) = self?.onContactChange else { - self?.unobservedContactDataChanges = updatedContactData - return + // No need to do anything if there were no changes + guard !changeset.isEmpty else { return } + + // Run any changes on the main thread (as they will generally trigger UI updates) + DispatchQueue.main.async { + // If we have the callback then trigger it, otherwise just store the changes to be sent + // to the callback if we ever start observing again (when we have the callback it needs + // to do the data updating as it's tied to UI updates and can cause crashes if not updated + // in the correct order) + guard let onContactChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self?.onContactChange else { + self?.unobservedContactDataChanges = (updatedContactData, changeset) + return + } + + onContactChange(updatedContactData, changeset) } - - onContactChange(updatedContactData) } ) // Run the initial query on a background thread so we don't block the push transition - DispatchQueue.global(qos: .default).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in // The `.pageBefore` will query from a `0` offset loading the first page self?.pagedDataObserver?.load(.pageBefore) } @@ -89,16 +101,16 @@ public class BlockedContactsViewModel { // MARK: - Contact Data public private(set) var selectedContactIds: Set = [] - public private(set) var unobservedContactDataChanges: [SectionModel]? + public private(set) var unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)? public private(set) var contactData: [SectionModel] = [] public private(set) var pagedDataObserver: PagedDatabaseObserver? - public var onContactChange: (([SectionModel]) -> ())? { + public var onContactChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? { didSet { // When starting to observe interaction changes we want to trigger a UI update just in case the // data was changed while we weren't observing - if let unobservedContactDataChanges: [SectionModel] = self.unobservedContactDataChanges { - onContactChange?(unobservedContactDataChanges) + if let unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedContactDataChanges { + onContactChange?(unobservedContactDataChanges.0 , unobservedContactDataChanges.1) self.unobservedContactDataChanges = nil } } diff --git a/Session/Settings/Views/BlockedContactCell.swift b/Session/Settings/Views/BlockedContactCell.swift index be232a3fc..3cf838fb8 100644 --- a/Session/Settings/Views/BlockedContactCell.swift +++ b/Session/Settings/Views/BlockedContactCell.swift @@ -41,7 +41,7 @@ class BlockedContactCell: UITableViewCell { // Highlight color let selectedBackgroundView = UIView() - selectedBackgroundView.themeBackgroundColor = .conversationButton_highlight + selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background) self.selectedBackgroundView = selectedBackgroundView // Add the UI diff --git a/Session/Settings/Views/ThemeSelectionView.swift b/Session/Settings/Views/ThemeSelectionView.swift index 5ce038ade..865fb21f0 100644 --- a/Session/Settings/Views/ThemeSelectionView.swift +++ b/Session/Settings/Views/ThemeSelectionView.swift @@ -16,7 +16,7 @@ class ThemeSelectionView: UIView { let result: UIButton = UIButton() result.translatesAutoresizingMaskIntoConstraints = false result.setThemeBackgroundColor(.appearance_buttonBackground, for: .normal) - result.setThemeBackgroundColor(.appearance_buttonHighlight, for: .highlighted) + result.setThemeBackgroundColor(.highlighted(.appearance_buttonBackground), for: .highlighted) result.addTarget(self, action: #selector(itemSelected), for: .touchUpInside) return result diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index a3404ddeb..914941126 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -148,7 +148,7 @@ public final class FullConversationCell: UITableViewCell { // Highlight color let selectedBackgroundView = UIView() - selectedBackgroundView.themeBackgroundColor = .conversationButton_highlight + selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background) self.selectedBackgroundView = selectedBackgroundView // Accent line view @@ -340,14 +340,12 @@ public final class FullConversationCell: UITableViewCell { public func update(with cellViewModel: SessionThreadViewModel) { let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0) - themeBackgroundColor = (unreadCount > 0 ? + let themeBackgroundColor: ThemeValue = (unreadCount > 0 ? .conversationButton_unreadBackground : .conversationButton_background ) - self.selectedBackgroundView?.themeBackgroundColor = (unreadCount > 0 ? - .conversationButton_unreadHighlight : - .conversationButton_highlight - ) + self.themeBackgroundColor = themeBackgroundColor + self.selectedBackgroundView?.themeBackgroundColor = .highlighted(themeBackgroundColor) if cellViewModel.threadIsBlocked == true { accentLineView.themeBackgroundColor = .danger diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 42ceb7fbd..0cc4c6a4a 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -30,7 +30,7 @@ public class SessionCell: UITableViewCell { private var botSeparatorLeftConstraint: NSLayoutConstraint = NSLayoutConstraint() private var botSeparatorRightConstraint: NSLayoutConstraint = NSLayoutConstraint() private lazy var leftAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: leftAccessoryView) - private lazy var rightAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: rightAccessoryView)// .heightAnchor.constraint(equalTo: iconImageView.heightAnchor) + private lazy var rightAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: rightAccessoryView) private let cellBackgroundView: UIView = { let result: UIView = UIView() @@ -44,7 +44,7 @@ public class SessionCell: UITableViewCell { private let cellSelectedBackgroundView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false - result.themeBackgroundColor = .settings_tabHighlight + result.themeBackgroundColor = .highlighted(.settings_tabBackground) result.alpha = 0 return result diff --git a/Session/Shared/Views/SessionHighlightingBackgroundLabel.swift b/Session/Shared/Views/SessionHighlightingBackgroundLabel.swift index 00b825f4e..bceacc396 100644 --- a/Session/Shared/Views/SessionHighlightingBackgroundLabel.swift +++ b/Session/Shared/Views/SessionHighlightingBackgroundLabel.swift @@ -55,7 +55,7 @@ public class SessionHighlightingBackgroundLabel: UIView { func setHighlighted(_ highlighted: Bool, animated: Bool) { self.themeBackgroundColor = (highlighted ? - .solidButton_highlight : + .highlighted(.solidButton_background) : .solidButton_background ) } @@ -66,7 +66,7 @@ public class SessionHighlightingBackgroundLabel: UIView { // need to swap back into the "highlighted" state so we can properly unhighlight within // the "deselect" animation guard !selected else { - self.themeBackgroundColor = .solidButton_highlight + self.themeBackgroundColor = .highlighted(.solidButton_background) return } guard animated else { diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index faef01712..391f8005a 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -94,10 +94,12 @@ public final class BackgroundPoller { guard let snode = swarm.randomElement() else { throw SnodeAPIError.generic } return SnodeAPI.getMessages(from: snode, associatedWith: publicKey) - .then(on: DispatchQueue.main) { messages -> Promise in + .then(on: DispatchQueue.main) { messages, lastHash -> Promise in guard !messages.isEmpty, BackgroundPoller.isValid else { return Promise.value(()) } var jobsToRun: [Job] = [] + var messageCount: Int = 0 + var hadValidHashUpdate: Bool = false Storage.shared.write { db in messages @@ -115,6 +117,10 @@ public final class BackgroundPoller { MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: break + + case MessageReceiverError.duplicateMessageNewSnode: + hadValidHashUpdate = true + break // In the background ignore 'SQLITE_ABORT' (it generally means // the BackgroundPoller has timed out @@ -128,6 +134,8 @@ public final class BackgroundPoller { } .grouped { threadId, _, _ in (threadId ?? Message.nonThreadMessageId) } .forEach { threadId, threadMessages in + messageCount += threadMessages.count + let maybeJob: Job? = Job( variant: .messageReceive, behaviour: .runOnce, @@ -145,6 +153,15 @@ public final class BackgroundPoller { JobRunner.add(db, job: job, canStartJob: false) jobsToRun.append(job) } + + if messageCount == 0 && !hadValidHashUpdate, let lastHash: String = lastHash { + // Update the cached validity of the messages + try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: [lastHash], + otherKnownValidHashes: messages.map { $0.info.hash } + ) + } } let promises: [Promise] = jobsToRun.map { job -> Promise in diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index dbd6acc86..bac6dbb5e 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -227,7 +227,7 @@ public extension Message { // service node, but may have done so for another node - if the hash already existed in // the database before we inserted it for this node then we can ignore this message as a // duplicate - guard numExistingHashes == 0 else { throw MessageReceiverError.duplicateMessage } + guard numExistingHashes == 0 else { throw MessageReceiverError.duplicateMessageNewSnode } return processedMessage } diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index 1a9c8d33f..560e485bc 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -4,6 +4,5 @@ FOUNDATION_EXPORT double SessionMessagingKitVersionNumber; FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import -#import #import #import diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift index 2d94b8946..74ff59dfc 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift @@ -4,6 +4,7 @@ import Foundation public enum MessageReceiverError: LocalizedError { case duplicateMessage + case duplicateMessageNewSnode case duplicateControlMessage case invalidMessage case unknownMessage @@ -21,8 +22,9 @@ public enum MessageReceiverError: LocalizedError { public var isRetryable: Bool { switch self { - case .duplicateMessage, .duplicateControlMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, - .invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed: + case .duplicateMessage, .duplicateMessageNewSnode, .duplicateControlMessage, + .invalidMessage, .unknownMessage, .unknownEnvelopeType, .invalidSignature, + .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed: return false default: return true @@ -32,6 +34,7 @@ public enum MessageReceiverError: LocalizedError { public var errorDescription: String? { switch self { case .duplicateMessage: return "Duplicate message." + case .duplicateMessageNewSnode: return "Duplicate message from different service node." case .duplicateControlMessage: return "Duplicate control message." case .invalidMessage: return "Invalid message." case .unknownMessage: return "Unknown message type." diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift index 03a9e1bf3..28d9517d7 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift @@ -51,6 +51,13 @@ extension MessageReceiver { _ = try interaction.attachments .deleteAll(db) + + if let serverHash: String = interaction.serverHash { + try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: [serverHash] + ) + } } } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 2e427fda1..17b53575d 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -118,7 +118,7 @@ public enum MessageReceiver { let proto: SNProtoContent do { - proto = try SNProtoContent.parseData((plaintext as NSData).removePadding()) + proto = try SNProtoContent.parseData(plaintext.removePadding()) } catch { SNLog("Couldn't parse proto due to error: \(error).") diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index ebd8e7ff9..d82555f0f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -134,7 +134,7 @@ public final class MessageSender { let plaintext: Data do { - plaintext = (try proto.serializedData() as NSData).paddedMessageBody() + plaintext = try proto.serializedData().paddedMessageBody() } catch { SNLog("Couldn't serialize proto due to error: \(error).") @@ -411,7 +411,7 @@ public final class MessageSender { let plaintext: Data do { - plaintext = (try proto.serializedData() as NSData).paddedMessageBody() + plaintext = try proto.serializedData().paddedMessageBody() } catch { SNLog("Couldn't serialize proto due to error: \(error).") @@ -510,7 +510,7 @@ public final class MessageSender { let plaintext: Data do { - plaintext = (try proto.serializedData() as NSData).paddedMessageBody() + plaintext = try proto.serializedData().paddedMessageBody() } catch { SNLog("Couldn't serialize proto due to error: \(error).") diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 40fccd4e4..369959742 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -160,7 +160,7 @@ public final class ClosedGroupPoller { poller?.isPolling.wrappedValue[groupPublicKey] == true else { return Promise(error: Error.pollingCanceled) } - let promises: [Promise<[SnodeReceivedMessage]>] = { + let promises: [Promise<([SnodeReceivedMessage], String?)>] = { if SnodeAPI.hardfork >= 19 && SnodeAPI.softfork >= 1 { return [ SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey, authenticated: false) ] } @@ -187,11 +187,20 @@ public final class ClosedGroupPoller { let allMessages: [SnodeReceivedMessage] = messageResults .reduce([]) { result, next in switch next { - case .fulfilled(let messages): return result.appending(contentsOf: messages) + case .fulfilled(let data): return result.appending(contentsOf: data.0) default: return result } } + let allHashes: [String] = messageResults + .reduce([]) { result, next in + switch next { + case .fulfilled(let data): return result.appending(data.1) + default: return result + } + } + .compactMap { $0 } var messageCount: Int = 0 + var hadValidHashUpdate: Bool = false // No need to do anything if there are no messages guard !allMessages.isEmpty else { @@ -218,6 +227,10 @@ public final class ClosedGroupPoller { MessageReceiverError.selfSend: break + case MessageReceiverError.duplicateMessageNewSnode: + hadValidHashUpdate = true + break + // In the background ignore 'SQLITE_ABORT' (it generally means // the BackgroundPoller has timed out case DatabaseError.SQLITE_ABORT: @@ -248,6 +261,17 @@ public final class ClosedGroupPoller { // If we are force-polling then add to the JobRunner so they are persistent and will retry on // the next app run if they fail but don't let them auto-start JobRunner.add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller) + + if messageCount == 0 && !hadValidHashUpdate, !allHashes.isEmpty { + SNLog("Received \(allMessages.count) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey), all duplicates - marking the hashes we polled with as invalid") + + // Update the cached validity of the messages + try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: allHashes, + otherKnownValidHashes: allMessages.map { $0.info.hash } + ) + } } if calledFromBackgroundPoller { @@ -269,7 +293,7 @@ public final class ClosedGroupPoller { } ) } - else { + else if messageCount > 0 || hadValidHashUpdate { SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (duplicates: \(allMessages.count - messageCount))") } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 4d6c3580a..677acbaf5 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -129,11 +129,12 @@ public final class Poller { let userPublicKey: String = getUserHexEncodedPublicKey() return SnodeAPI.getMessages(from: snode, associatedWith: userPublicKey) - .then(on: Threading.pollerQueue) { [weak self] messages -> Promise in + .then(on: Threading.pollerQueue) { [weak self] messages, lastHash -> Promise in guard self?.isPolling.wrappedValue == true else { return Promise { $0.fulfill(()) } } if !messages.isEmpty { var messageCount: Int = 0 + var hadValidHashUpdate: Bool = false Storage.shared.write { db in messages @@ -151,6 +152,10 @@ public final class Poller { MessageReceiverError.selfSend: break + case MessageReceiverError.duplicateMessageNewSnode: + hadValidHashUpdate = true + break + case DatabaseError.SQLITE_ABORT: SNLog("Failed to the database being suspended (running in background with no background task).") break @@ -178,9 +183,21 @@ public final class Poller { ) ) } + + if messageCount == 0 && !hadValidHashUpdate, let lastHash: String = lastHash { + SNLog("Received \(messages.count) new message\(messages.count == 1 ? "" : "s"), all duplicates - marking the hash we polled with as invalid") + + // Update the cached validity of the messages + try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: [lastHash], + otherKnownValidHashes: messages.map { $0.info.hash } + ) + } + else { + SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (duplicates: \(messages.count - messageCount))") + } } - - SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (duplicates: \(messages.count - messageCount))") } else { SNLog("Received no new messages") diff --git a/SessionMessagingKit/Utilities/Data+Utilities.swift b/SessionMessagingKit/Utilities/Data+Utilities.swift index c04495f6a..967e8a263 100644 --- a/SessionMessagingKit/Utilities/Data+Utilities.swift +++ b/SessionMessagingKit/Utilities/Data+Utilities.swift @@ -21,4 +21,51 @@ public extension Data { throw HTTP.Error.parsingFailed } } + + func removePadding() -> Data { + let bytes: [UInt8] = self.bytes + var paddingStart: Int = self.count + + for i in 0..<(self.count - 1) { + let targetIndex: Int = ((self.count - 1) - i) + + if bytes[targetIndex] == 0x80 { + paddingStart = targetIndex + break + } + else if bytes[targetIndex] != 0x00 { + SNLog("Failed to remove padding, returning unstripped padding"); + return self + } + } + + return self.prefix(upTo: paddingStart) + } + + func paddedMessageBody() -> Data { + // From + // https://github.com/signalapp/TextSecure/blob/master/libtextsecure/src/main/java/org/whispersystems/textsecure/internal/push/PushTransportDetails.java#L55 + // NOTE: This is dumb. We have our own padding scheme, but so does the cipher. + // The +1 -1 here is to make sure the Cipher has room to add one padding byte, + // otherwise it'll add a full 16 extra bytes. + let paddedMessageLength: Int = (self.paddedMessageLength(self.count + 1) - 1) + var paddedMessage: Data = Data(count: paddedMessageLength) + + let paddingByte: UInt8 = 0x80 + paddedMessage[0.. Int { + let messageLengthWithTerminator: Int = (unpaddedLength + 1) + var messagePartCount: Int = (messageLengthWithTerminator / 160) + + if CGFloat(messageLengthWithTerminator).truncatingRemainder(dividingBy: 160) != 0 { + messagePartCount += 1 + } + + return (messagePartCount * 160) + } } diff --git a/SessionMessagingKit/Utilities/NSData+messagePadding.h b/SessionMessagingKit/Utilities/NSData+messagePadding.h deleted file mode 100644 index 117bfa12b..000000000 --- a/SessionMessagingKit/Utilities/NSData+messagePadding.h +++ /dev/null @@ -1,11 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -@interface NSData (messagePadding) - -- (NSData *)removePadding; - -- (NSData *)paddedMessageBody; - -@end diff --git a/SessionMessagingKit/Utilities/NSData+messagePadding.m b/SessionMessagingKit/Utilities/NSData+messagePadding.m deleted file mode 100644 index 563396662..000000000 --- a/SessionMessagingKit/Utilities/NSData+messagePadding.m +++ /dev/null @@ -1,60 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import -#import "OWSAsserts.h" -#import "NSData+messagePadding.h" - -@implementation NSData (messagePadding) - -- (NSData *)removePadding { - unsigned long paddingStart = self.length; - - Byte data[self.length]; - [self getBytes:data length:self.length]; - - for (long i = (long)self.length - 1; i >= 0; i--) { - if (data[i] == (Byte)0x80) { - paddingStart = (unsigned long)i; - break; - } else if (data[i] != (Byte)0x00) { - OWSLogWarn(@"Failed to remove padding, returning unstripped padding"); - return self; - } - } - - return [self subdataWithRange:NSMakeRange(0, paddingStart)]; -} - - -- (NSData *)paddedMessageBody { - // From - // https://github.com/signalapp/TextSecure/blob/master/libtextsecure/src/main/java/org/whispersystems/textsecure/internal/push/PushTransportDetails.java#L55 - // NOTE: This is dumb. We have our own padding scheme, but so does the cipher. - // The +1 -1 here is to make sure the Cipher has room to add one padding byte, - // otherwise it'll add a full 16 extra bytes. - - NSUInteger paddedMessageLength = [self paddedMessageLength:(self.length + 1)] - 1; - NSMutableData *paddedMessage = [NSMutableData dataWithLength:paddedMessageLength]; - - Byte paddingByte = 0x80; - - [paddedMessage replaceBytesInRange:NSMakeRange(0, self.length) withBytes:[self bytes]]; - [paddedMessage replaceBytesInRange:NSMakeRange(self.length, 1) withBytes:&paddingByte]; - - return paddedMessage; -} - -- (NSUInteger)paddedMessageLength:(NSUInteger)messageLength { - NSUInteger messageLengthWithTerminator = messageLength + 1; - NSUInteger messagePartCount = messageLengthWithTerminator / 160; - - if (messageLengthWithTerminator % 160 != 0) { - messagePartCount++; - } - - return messagePartCount * 160; -} - -@end diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index d1ee8af7e..3c2fa1e32 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -60,7 +60,7 @@ final class SimplifiedConversationCell: UITableViewCell { themeBackgroundColor = .conversationButton_background let selectedBackgroundView = UIView() - selectedBackgroundView.themeBackgroundColor = .conversationButton_highlight + selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background) self.selectedBackgroundView = selectedBackgroundView addSubview(stackView) diff --git a/SessionSnodeKit/Configuration.swift b/SessionSnodeKit/Configuration.swift index 11a6c6944..259ac87ab 100644 --- a/SessionSnodeKit/Configuration.swift +++ b/SessionSnodeKit/Configuration.swift @@ -14,6 +14,9 @@ public enum SNSnodeKit { // Just to make the external API nice ], [ _003_YDBToGRDBMigration.self + ], + [ + _004_FlagMessageHashAsDeletedOrInvalid.self ] ] ) diff --git a/SessionSnodeKit/Database/Migrations/_004_FlagMessageHashAsDeletedOrInvalid.swift b/SessionSnodeKit/Database/Migrations/_004_FlagMessageHashAsDeletedOrInvalid.swift new file mode 100644 index 000000000..a4bd7babd --- /dev/null +++ b/SessionSnodeKit/Database/Migrations/_004_FlagMessageHashAsDeletedOrInvalid.swift @@ -0,0 +1,26 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import YapDatabase +import SessionUtilitiesKit + +enum _004_FlagMessageHashAsDeletedOrInvalid: Migration { + static let target: TargetMigrations.Identifier = .snodeKit + static let identifier: String = "FlagMessageHashAsDeletedOrInvalid" + static let needsConfigSync: Bool = false + + /// This migration adds a flat to the `SnodeReceivedMessageInfo` so that when deleting interactions we can + /// ignore their hashes when subsequently trying to fetch new messages (which results in the storage server returning + /// messages from the beginning of time) + static let minExpectedRunDuration: TimeInterval = 0.2 + + static func migrate(_ db: Database) throws { + try db.alter(table: SnodeReceivedMessageInfo.self) { t in + t.add(.wasDeletedOrInvalid, .boolean) + .indexed() // Faster querying + } + + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration + } +} diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index 16b66672d..b000f6ba6 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -13,6 +13,7 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist case key case hash case expirationDateMs + case wasDeletedOrInvalid } /// The `id` value is auto incremented by the database, if the `Job` hasn't been inserted into @@ -33,6 +34,14 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist /// 14 days) public let expirationDateMs: Int64 + /// This flag indicates whether the interaction associated with this message hash was deleted or whether this message + /// hash is potentially invalid (if a poll results in 100% of the `SnodeReceivedMessageInfo` entries being seen as + /// duplicates then we assume that the `lastHash` value provided when retrieving messages was invalid and mark + /// it as such) + /// + /// **Note:** When retrieving the `lastNotExpired` we will ignore any entries where this flag is true + public var wasDeletedOrInvalid: Bool? + // MARK: - Custom Database Interaction public mutating func didInsert(with rowID: Int64, for column: String?) { @@ -108,6 +117,10 @@ public extension SnodeReceivedMessageInfo { static func fetchLastNotExpired(for snode: Snode, namespace: Int, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { return Storage.shared.read { db in let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo + .filter( + SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == nil || + SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == false + ) .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace)) .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > (Date().timeIntervalSince1970 * 1000)) .order(SnodeReceivedMessageInfo.Columns.id.desc) @@ -118,9 +131,44 @@ public extension SnodeReceivedMessageInfo { if nonLegacyHash != nil { return nonLegacyHash } return try SnodeReceivedMessageInfo + .filter( + SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == nil || + SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == false + ) .filter(SnodeReceivedMessageInfo.Columns.key == publicKey) .order(SnodeReceivedMessageInfo.Columns.id.desc) .fetchOne(db) } } + + /// There are some cases where the latest message can be removed from a swarm, if we then try to poll for that message the swarm + /// will see it as invalid and start returning messages from the beginning which can result in a lot of wasted, duplicate downloads + /// + /// This method should be called when deleting a message, handling an UnsendRequest or when receiving a poll response which contains + /// solely duplicate messages (for the specific service node - if even one message in a response is new for that service node then this shouldn't + /// be called if if the message has already been received and processed by a separate service node) + static func handlePotentialDeletedOrInvalidHash( + _ db: Database, + potentiallyInvalidHashes: [String], + otherKnownValidHashes: [String] = [] + ) throws { + _ = try SnodeReceivedMessageInfo + .filter(potentiallyInvalidHashes.contains(SnodeReceivedMessageInfo.Columns.hash)) + .updateAll( + db, + SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid.set(to: true) + ) + + // If we have any server hashes which we know are valid (eg. we fetched the oldest messages) then + // mark them all as valid to prevent the case where we just slowly work backwards from the latest + // message, polling for one earlier each time + guard !otherKnownValidHashes.isEmpty else { return } + + _ = try SnodeReceivedMessageInfo + .filter(otherKnownValidHashes.contains(SnodeReceivedMessageInfo.Columns.hash)) + .updateAll( + db, + SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid.set(to: false) + ) + } } diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index 2a9cf3b6b..db0d2903a 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -489,8 +489,8 @@ public final class SnodeAPI { // MARK: - Retrieve // Not in use until we can batch delete and store config messages - public static func getConfigMessages(from snode: Snode, associatedWith publicKey: String) -> Promise<[SnodeReceivedMessage]> { - let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending() + public static func getConfigMessages(from snode: Snode, associatedWith publicKey: String) -> Promise<([SnodeReceivedMessage], String?)> { + let (promise, seal) = Promise<([SnodeReceivedMessage], String?)>.pending() Threading.workQueue.async { getMessagesWithAuthentication(from: snode, associatedWith: publicKey, namespace: configNamespace) @@ -505,8 +505,8 @@ public final class SnodeAPI { return promise } - public static func getMessages(from snode: Snode, associatedWith publicKey: String, authenticated: Bool = true) -> Promise<[SnodeReceivedMessage]> { - let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending() + public static func getMessages(from snode: Snode, associatedWith publicKey: String, authenticated: Bool = true) -> Promise<([SnodeReceivedMessage], String?)> { + let (promise, seal) = Promise<([SnodeReceivedMessage], String?)>.pending() Threading.workQueue.async { let retrievePromise = (authenticated ? @@ -522,8 +522,8 @@ public final class SnodeAPI { return promise } - public static func getClosedGroupMessagesFromDefaultNamespace(from snode: Snode, associatedWith publicKey: String) -> Promise<[SnodeReceivedMessage]> { - let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending() + public static func getClosedGroupMessagesFromDefaultNamespace(from snode: Snode, associatedWith publicKey: String) -> Promise<([SnodeReceivedMessage], String?)> { + let (promise, seal) = Promise<([SnodeReceivedMessage], String?)>.pending() Threading.workQueue.async { getMessagesUnauthenticated(from: snode, associatedWith: publicKey, namespace: defaultNamespace) @@ -534,7 +534,7 @@ public final class SnodeAPI { return promise } - private static func getMessagesWithAuthentication(from snode: Snode, associatedWith publicKey: String, namespace: Int) -> Promise<[SnodeReceivedMessage]> { + private static func getMessagesWithAuthentication(from snode: Snode, associatedWith publicKey: String, namespace: Int) -> Promise<([SnodeReceivedMessage], String?)> { /// **Note:** All authentication logic is only apply to 1-1 chats, the reason being that we can't currently support it yet for /// closed groups. The Storage Server requires an ed25519 key pair, but we don't have that for our closed groups. guard let userED25519KeyPair: Box.KeyPair = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { @@ -584,13 +584,14 @@ public final class SnodeAPI { ) } } + .map { ($0, lastHash) } } private static func getMessagesUnauthenticated( from snode: Snode, associatedWith publicKey: String, namespace: Int = closedGroupNamespace - ) -> Promise<[SnodeReceivedMessage]> { + ) -> Promise<([SnodeReceivedMessage], String?)> { // Get last message hash SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo(for: snode, namespace: namespace, associatedWith: publicKey) let lastHash = SnodeReceivedMessageInfo.fetchLastNotExpired(for: snode, namespace: namespace, associatedWith: publicKey)?.hash ?? "" @@ -598,7 +599,7 @@ public final class SnodeAPI { // Make the request var parameters: JSON = [ "pubKey": (Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey), - "lastHash": lastHash, + "lastHash": lastHash ] // Don't include namespace if polling for 0 with no authentication @@ -625,6 +626,7 @@ public final class SnodeAPI { ) } } + .map { ($0, lastHash) } } // MARK: Store @@ -895,6 +897,17 @@ public final class SnodeAPI { } } + // If we get to here then we assume it's been deleted from at least one + // service node and as a result we need to mark the hash as invalid so + // we don't try to fetch updates since that hash going forward (if we do + // we would end up re-fetching all old messages) + Storage.shared.writeAsync { db in + try? SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: serverHashes + ) + } + return result } } diff --git a/SessionUIKit/Components/Modal.swift b/SessionUIKit/Components/Modal.swift index 25c77d1ec..f42d9bdd0 100644 --- a/SessionUIKit/Components/Modal.swift +++ b/SessionUIKit/Components/Modal.swift @@ -121,7 +121,7 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate { result.setTitle(title, for: .normal) result.setThemeTitleColor(titleColor, for: .normal) result.setThemeBackgroundColor(.alert_buttonBackground, for: .normal) - result.setThemeBackgroundColor(.alert_buttonHighlight, for: .highlighted) + result.setThemeBackgroundColor(.highlighted(.alert_buttonBackground), for: .highlighted) result.set(.height, to: Values.alertButtonHeight) return result diff --git a/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift b/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift index 3faedacab..61ed9914e 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift @@ -62,28 +62,22 @@ internal enum Theme_ClassicDark: ThemeColors { // SolidButton .solidButton_background: .classicDark3, - .solidButton_highlight: .classicDark4, // Settings .settings_tabBackground: .classicDark1, - .settings_tabHighlight: .classicDark3, // Appearance .appearance_sectionBackground: .classicDark1, .appearance_buttonBackground: .classicDark1, - .appearance_buttonHighlight: .classicDark3, // Alert .alert_text: .classicDark6, .alert_background: .classicDark1, .alert_buttonBackground: .classicDark1, - .alert_buttonHighlight: .classicDark3, // ConversationButton .conversationButton_background: .classicDark1, - .conversationButton_highlight: .classicDark3, .conversationButton_unreadBackground: .classicDark2, - .conversationButton_unreadHighlight: .classicDark3, .conversationButton_unreadStripBackground: .primary, .conversationButton_unreadBubbleBackground: .classicDark3, .conversationButton_unreadBubbleText: .classicDark6, diff --git a/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift b/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift index f037004ef..d2554fc0d 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift @@ -62,28 +62,22 @@ internal enum Theme_ClassicLight: ThemeColors { // SolidButton .solidButton_background: .classicLight3, - .solidButton_highlight: .classicLight4, // Settings .settings_tabBackground: .classicLight5, - .settings_tabHighlight: .classicLight3, // AppearanceButton .appearance_sectionBackground: .classicLight6, .appearance_buttonBackground: .classicLight6, - .appearance_buttonHighlight: .classicLight4, // Alert .alert_text: .classicLight0, .alert_background: .classicLight6, .alert_buttonBackground: .classicLight6, - .alert_buttonHighlight: .classicLight4, // ConversationButton .conversationButton_background: .classicLight6, - .conversationButton_highlight: .classicLight4, .conversationButton_unreadBackground: .classicLight6, - .conversationButton_unreadHighlight: .classicLight4, .conversationButton_unreadStripBackground: .primary, .conversationButton_unreadBubbleBackground: .classicLight3, .conversationButton_unreadBubbleText: .classicLight0, diff --git a/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift b/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift index d47d2fbd2..1775bfed0 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift @@ -62,28 +62,22 @@ internal enum Theme_OceanDark: ThemeColors { // SolidButton .solidButton_background: .oceanDark2, - .solidButton_highlight: .oceanDark4, // Settings .settings_tabBackground: .oceanDark1, - .settings_tabHighlight: .oceanDark3, // Appearance .appearance_sectionBackground: .oceanDark3, .appearance_buttonBackground: .oceanDark3, - .appearance_buttonHighlight: .oceanDark4, // Alert .alert_text: .oceanDark7, .alert_background: .oceanDark3, .alert_buttonBackground: .oceanDark3, - .alert_buttonHighlight: .oceanDark4, // ConversationButton .conversationButton_background: .oceanDark3, - .conversationButton_highlight: .oceanDark4, - .conversationButton_unreadBackground: .oceanDark2, - .conversationButton_unreadHighlight: .oceanDark4, + .conversationButton_unreadBackground: .oceanDark4, .conversationButton_unreadStripBackground: .primary, .conversationButton_unreadBubbleBackground: .primary, .conversationButton_unreadBubbleText: .oceanDark0, diff --git a/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift b/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift index a271e4173..c22f4fb34 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift @@ -62,28 +62,22 @@ internal enum Theme_OceanLight: ThemeColors { // SolidButton .solidButton_background: .oceanLight5, - .solidButton_highlight: .oceanLight6, // Settings .settings_tabBackground: .oceanLight6, - .settings_tabHighlight: .oceanLight5, // Appearance .appearance_sectionBackground: .oceanLight7, .appearance_buttonBackground: .oceanLight7, - .appearance_buttonHighlight: .oceanLight5, // Alert .alert_text: .oceanLight0, .alert_background: .oceanLight7, .alert_buttonBackground: .oceanLight7, - .alert_buttonHighlight: .oceanLight5, // ConversationButton .conversationButton_background: .oceanLight7, - .conversationButton_highlight: .oceanLight5, .conversationButton_unreadBackground: .oceanLight6, - .conversationButton_unreadHighlight: .oceanLight5, .conversationButton_unreadStripBackground: .primary, .conversationButton_unreadBubbleBackground: .primary, .conversationButton_unreadBubbleText: .oceanLight1, diff --git a/SessionUIKit/Style Guide/Themes/Theme.swift b/SessionUIKit/Style Guide/Themes/Theme.swift index da6eb4145..d81a5a3bf 100644 --- a/SessionUIKit/Style Guide/Themes/Theme.swift +++ b/SessionUIKit/Style Guide/Themes/Theme.swift @@ -55,6 +55,13 @@ public enum Theme: String, CaseIterable, Codable, EnumStringSetting { public func color(for value: ThemeValue) -> UIColor? { switch value { case .value(let value, let alpha): return color(for: value)?.withAlphaComponent(alpha) + + case .highlighted(let value, let alwaysDarken): + switch (self.interfaceStyle, alwaysDarken) { + case (.light, _), (_, true): return color(for: value)?.brighten(by: -0.06) + default: return color(for: value)?.brighten(by: 0.08) + } + default: return colors[value] } } @@ -77,6 +84,14 @@ public protocol ThemedNavigation { public indirect enum ThemeValue: Hashable { case value(ThemeValue, alpha: CGFloat) + // The 'highlighted' state of a colour will automatically lighten/darken a ThemeValue + // by a fixed amount depending on wither the theme is dark/light mode + case highlighted(ThemeValue, alwaysDarken: Bool) + + public static func highlighted(_ value: ThemeValue) -> ThemeValue { + return .highlighted(value, alwaysDarken: false) + } + // General case white case black @@ -135,28 +150,22 @@ public indirect enum ThemeValue: Hashable { // SolidButton case solidButton_background - case solidButton_highlight // Settings case settings_tabBackground - case settings_tabHighlight // Appearance case appearance_sectionBackground case appearance_buttonBackground - case appearance_buttonHighlight // Alert case alert_text case alert_background case alert_buttonBackground - case alert_buttonHighlight // ConversationButton case conversationButton_background - case conversationButton_highlight case conversationButton_unreadBackground - case conversationButton_unreadHighlight case conversationButton_unreadStripBackground case conversationButton_unreadBubbleBackground case conversationButton_unreadBubbleText diff --git a/SessionUIKit/Utilities/UIColor+Utilities.swift b/SessionUIKit/Utilities/UIColor+Utilities.swift index 65843d73c..509f0617b 100644 --- a/SessionUIKit/Utilities/UIColor+Utilities.swift +++ b/SessionUIKit/Utilities/UIColor+Utilities.swift @@ -36,5 +36,29 @@ public extension UIColor { alpha: CGFloatLerp(a0, a1, finalAlpha) ) } + + func brighten(by percentage: CGFloat) -> UIColor { + guard percentage != 0 else { return self } + + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + + // Note: Looks like as of iOS 10 devices use the kCGColorSpaceExtendedGray color + // space for grayscale colors which seems to be compatible with the RGB color space + // meaning we don't need to check 'getWhite:alpha:' if the below method fails, for + // more info see: https://developer.apple.com/documentation/uikit/uicolor#overview + guard self.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) else { + return self + } + + return UIColor( + hue: hue, + saturation: saturation, + brightness: (brightness + percentage), + alpha: alpha + ) + } } diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 569c281df..f1483b3af 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -198,10 +198,7 @@ public class PagedDatabaseObserver: TransactionObserver where // Update the cache, pageInfo and the change callback self?.dataCache.mutate { $0 = finalUpdatedDataCache } self?.pageInfo.mutate { $0 = updatedPageInfo } - - DispatchQueue.main.async { [weak self] in - self?.onChangeUnsorted(finalUpdatedDataCache.values, updatedPageInfo) - } + self?.onChangeUnsorted(finalUpdatedDataCache.values, updatedPageInfo) } // Determing if there were any direct or related data changes @@ -729,12 +726,6 @@ public class PagedDatabaseObserver: TransactionObserver where self?.isLoadingMoreData.mutate { $0 = false } } - // Make sure the updates run on the main thread - guard Thread.isMainThread else { - DispatchQueue.main.async { triggerUpdates() } - return - } - triggerUpdates() } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 929660f3d..99cd2c61b 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -227,7 +227,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { for: .normal ) button.setThemeBackgroundColorForced( - .theme(.classicLight, color: .settings_tabHighlight), + .theme(.classicLight, color: .highlighted(.settings_tabBackground)), for: .highlighted ) button.addTarget(self, action: #selector(audioPlayPauseButtonPressed), for: .touchUpInside)