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)