Theming tweaks and bug fixes

Made a tweak to prevent some odd looking keyboard transitions when going to conversation settings
Updated the PagedDatabaseObserver to not call 'onChangeUnsorted' on the main thread (now we can generate the changeset on the background thread so there is less main thread work)
Fixed an issue where the most recently received message from the swarm could be removed from the swarm yet the app would still poll for it, resulting in the swarm always returning the oldest possible messages until the user sends a new one-to-one message
Fixed an issue where the initial scroll offset could be incorrect due to certain message types
Fixed an issue where the title view inside a conversation could jump when pushing to the conversation settings screen
Refactored a couple of ObjC functions to Swift as they were crashing (due to memory allocation?) hopefully this will fix it
Tweaked some DispatchQueue priorities to ensure PagedDatabaseObserver loading is prioritised
Updated buttons to use a standard convention for highlighted states
Updated the new conversation button to follow the new highlighted state convention
This commit is contained in:
Morgan Pretty 2022-10-14 17:09:38 +11:00
parent 59dac34fe8
commit d8fd3b35b4
49 changed files with 622 additions and 332 deletions

View File

@ -636,6 +636,7 @@
FD37EA1728AC5605003AE748 /* NotificationContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA1628AC5605003AE748 /* NotificationContentViewModel.swift */; }; FD37EA1728AC5605003AE748 /* NotificationContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA1628AC5605003AE748 /* NotificationContentViewModel.swift */; };
FD37EA1928AC5CCA003AE748 /* NotificationSoundViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA1828AC5CCA003AE748 /* NotificationSoundViewModel.swift */; }; FD37EA1928AC5CCA003AE748 /* NotificationSoundViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA1828AC5CCA003AE748 /* NotificationSoundViewModel.swift */; };
FD39352C28F382920084DADA /* VersionFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD39352B28F382920084DADA /* VersionFooterView.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 */; }; FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; };
FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */; }; FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */; };
FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905F27E410F700CD579F /* FileUploadResponseSpec.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 */; }; FD716E6C28505E1C00C96BF4 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */; };
FD716E7128505E5200C96BF4 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E7028505E5100C96BF4 /* MessageRequestsCell.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 */; }; 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 */; }; 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 */; }; 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 */; }; 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 */; }; FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */; };
FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF927C2F5C500510D0C /* MockGenericHash.swift */; }; FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF927C2F5C500510D0C /* MockGenericHash.swift */; };
FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFB27C2F60700510D0C /* MockEd25519.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 */; }; FD87DCFA28B74DB300AF0F98 /* ConversationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */; };
FD87DCFC28B755B800AF0F98 /* BlockedContactsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFB28B755B800AF0F98 /* BlockedContactsViewController.swift */; }; FD87DCFC28B755B800AF0F98 /* BlockedContactsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFB28B755B800AF0F98 /* BlockedContactsViewController.swift */; };
FD87DCFE28B7582C00AF0F98 /* BlockedContactsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.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 = "<group>"; }; C3A71D0A2558989C0043A11F /* MessageWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageWrapper.swift; sourceTree = "<group>"; };
C3A71D1C25589AC30043A11F /* WebSocketProto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebSocketProto.swift; sourceTree = "<group>"; }; C3A71D1C25589AC30043A11F /* WebSocketProto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebSocketProto.swift; sourceTree = "<group>"; };
C3A71D1D25589AC30043A11F /* WebSocketResources.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebSocketResources.pb.swift; sourceTree = "<group>"; }; C3A71D1D25589AC30043A11F /* WebSocketResources.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebSocketResources.pb.swift; sourceTree = "<group>"; };
C3A71D4825589FF20043A11F /* NSData+messagePadding.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSData+messagePadding.m"; sourceTree = "<group>"; };
C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSData+messagePadding.h"; sourceTree = "<group>"; };
C3A71D662558A0170043A11F /* DiffieHellman.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiffieHellman.swift; sourceTree = "<group>"; }; C3A71D662558A0170043A11F /* DiffieHellman.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiffieHellman.swift; sourceTree = "<group>"; };
C3A71F882558BA9F0043A11F /* Mnemonic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mnemonic.swift; sourceTree = "<group>"; }; C3A71F882558BA9F0043A11F /* Mnemonic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mnemonic.swift; sourceTree = "<group>"; };
C3A721992558C1660043A11F /* AnyPromise+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyPromise+Conversion.swift"; sourceTree = "<group>"; }; C3A721992558C1660043A11F /* AnyPromise+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyPromise+Conversion.swift"; sourceTree = "<group>"; };
@ -1721,6 +1718,7 @@
FD37EA1828AC5CCA003AE748 /* NotificationSoundViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundViewModel.swift; sourceTree = "<group>"; }; FD37EA1828AC5CCA003AE748 /* NotificationSoundViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundViewModel.swift; sourceTree = "<group>"; };
FD37EA1A28ACB51F003AE748 /* _007_HomeQueryOptimisationIndexes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _007_HomeQueryOptimisationIndexes.swift; sourceTree = "<group>"; }; FD37EA1A28ACB51F003AE748 /* _007_HomeQueryOptimisationIndexes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _007_HomeQueryOptimisationIndexes.swift; sourceTree = "<group>"; };
FD39352B28F382920084DADA /* VersionFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionFooterView.swift; sourceTree = "<group>"; }; FD39352B28F382920084DADA /* VersionFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionFooterView.swift; sourceTree = "<group>"; };
FD39353528F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _004_FlagMessageHashAsDeletedOrInvalid.swift; sourceTree = "<group>"; };
FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = "<group>"; }; FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = "<group>"; };
FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestInfoSpec.swift; sourceTree = "<group>"; }; FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestInfoSpec.swift; sourceTree = "<group>"; };
FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponseSpec.swift; sourceTree = "<group>"; }; FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponseSpec.swift; sourceTree = "<group>"; };
@ -3129,8 +3127,6 @@
C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */,
C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */,
C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */,
C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */,
C3A71D4825589FF20043A11F /* NSData+messagePadding.m */,
FD09797E27FCFBFF00936362 /* OWSAES256Key+Utilities.swift */, FD09797E27FCFBFF00936362 /* OWSAES256Key+Utilities.swift */,
C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */, C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */,
C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */, C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */,
@ -3570,6 +3566,7 @@
FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */, FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */,
FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */, FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */,
FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */, FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */,
FD39353528F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift */,
); );
path = Migrations; path = Migrations;
sourceTree = "<group>"; sourceTree = "<group>";
@ -4208,7 +4205,6 @@
files = ( files = (
C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */, C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */,
C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */, C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */,
FD716E732850647900C96BF4 /* NSData+messagePadding.h in Headers */,
B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */, B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */,
B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */,
); );
@ -5257,6 +5253,7 @@
C3C2A5E42553860B00C340D1 /* Data+Utilities.swift in Sources */, C3C2A5E42553860B00C340D1 /* Data+Utilities.swift in Sources */,
FD17D7A727F41AF000122BE0 /* SSKLegacy.swift in Sources */, FD17D7A727F41AF000122BE0 /* SSKLegacy.swift in Sources */,
FDC438B327BB15B400C60D73 /* ResponseInfo.swift in Sources */, FDC438B327BB15B400C60D73 /* ResponseInfo.swift in Sources */,
FD39353628F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift in Sources */,
C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */, C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */,
FD17D7D827F658E200122BE0 /* OnionRequestAPIDestination.swift in Sources */, FD17D7D827F658E200122BE0 /* OnionRequestAPIDestination.swift in Sources */,
FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */, FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */,
@ -5379,7 +5376,6 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
7B81682828B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift in Sources */, 7B81682828B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift in Sources */,
FD86585828507B24008B6CF9 /* NSData+messagePadding.m in Sources */,
FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */, FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */,
B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */,
C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */, C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */,

View File

@ -53,6 +53,10 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
var scrollDistanceToBottomBeforeUpdate: CGFloat? var scrollDistanceToBottomBeforeUpdate: CGFloat?
var baselineKeyboardHeight: CGFloat = 0 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 // Reaction
var currentReactionListSheet: ReactionListSheet? var currentReactionListSheet: ReactionListSheet?
var reactionExpandedMessageIds: Set<String> = [] var reactionExpandedMessageIds: Set<String> = []
@ -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 // Flag that the initial layout has been completed (the flag blocks and unblocks a number
// of different behaviours) // of different behaviours)
didFinishInitialLayout = true didFinishInitialLayout = true
viewIsFocussed = true
if delayFirstResponder || isShowingSearchUI { if delayFirstResponder || isShowingSearchUI {
delayFirstResponder = false delayFirstResponder = false
@ -420,6 +425,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
viewIsFocussed = false
// Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard // Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard
// to appear to remain focussed) // to appear to remain focussed)
guard !isReplacingThread else { return } 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 // 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 // has loaded to prevent an issue where the conversation loads with the wrong offset
if self?.viewModel.onInteractionChange == nil { if self?.viewModel.onInteractionChange == nil {
self?.viewModel.onInteractionChange = { [weak self] updatedInteractionData in self?.viewModel.onInteractionChange = { [weak self] updatedInteractionData, changeset in
self?.handleInteractionUpdates(updatedInteractionData) self?.handleInteractionUpdates(updatedInteractionData, changeset: changeset)
} }
// Note: When returning from the background we could have received notifications but the // 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 // 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) // 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 { 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 hasLoadedInitialThreadData = true
hasReloadedThreadDataAfterDisappearance = true hasReloadedThreadDataAfterDisappearance = true
UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) }
UIView.performWithoutAnimation {
handleThreadUpdates(updatedThreadData, initialLoad: isInitialLoad)
}
return 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 // 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 // animations (if we don't do this the cells will animate in from a frame of
// CGRect.zero or have a buggy transition) // 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 numItemsInserted: Int = changeset.map { $0.elementInserted.count }.reduce(0, +)
let isInsert: Bool = (numItemsInserted > 0) let isInsert: Bool = (numItemsInserted > 0)
let wasLoadingMore: Bool = self.isLoadingMore let wasLoadingMore: Bool = self.isLoadingMore
@ -955,7 +971,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
self?.isLoadingMore = true 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 // Attachments are loaded in descending order so 'loadOlder' actually corresponds with
// 'pageAfter' in this case // 'pageAfter' in this case
self?.viewModel.pagedDataObserver?.load(shouldLoadOlder ? self?.viewModel.pagedDataObserver?.load(shouldLoadOlder ?
@ -1050,6 +1066,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
// MARK: - Notifications // MARK: - Notifications
@objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { @objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) {
guard viewIsFocussed || !didFinishInitialLayout else { return }
// Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600 // Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600
// and https://stackoverflow.com/a/25260930 to better understand what we are // and https://stackoverflow.com/a/25260930 to better understand what we are
// doing with the UIViewAnimationOptions // 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) // Perform the changes (don't animate if the initial layout hasn't been completed)
guard hasDoneLayout else { guard hasDoneLayout && didFinishInitialLayout else {
UIView.performWithoutAnimation { UIView.performWithoutAnimation {
changes() changes()
} }
@ -1113,6 +1131,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
} }
@objc func handleKeyboardWillHideNotification(_ notification: Notification) { @objc func handleKeyboardWillHideNotification(_ notification: Notification) {
guard viewIsFocussed else { return }
// Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600 // Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600
// and https://stackoverflow.com/a/25260930 to better understand what we are // and https://stackoverflow.com/a/25260930 to better understand what we are
// doing with the UIViewAnimationOptions // doing with the UIViewAnimationOptions
@ -1273,7 +1293,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
case .loadOlder, .loadNewer: case .loadOlder, .loadNewer:
self.isLoadingMore = true 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 // Messages are loaded in descending order so 'loadOlder' actually corresponds with
// 'pageAfter' in this case // 'pageAfter' in this case
self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ? self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ?
@ -1543,7 +1563,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
self.isLoadingMore = true self.isLoadingMore = true
self.searchController.resultsBar.startLoading() self.searchController.resultsBar.startLoading()
DispatchQueue.global(qos: .default).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
if isJumpingToLastInteraction { if isJumpingToLastInteraction {
self?.viewModel.pagedDataObserver?.load(.jumpTo( self?.viewModel.pagedDataObserver?.load(.jumpTo(
id: interactionId, id: interactionId,

View File

@ -86,7 +86,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
) )
// Run the initial query on a background thread so we don't block the push transition // 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 // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
// from a `0` offset) // from a `0` offset)
guard let initialFocusedId: Int64 = targetInteractionId else { guard let initialFocusedId: Int64 = targetInteractionId else {
@ -150,17 +150,17 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// MARK: - Interaction Data // MARK: - Interaction Data
private var lastInteractionIdMarkedAsRead: Int64? 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 interactionData: [SectionModel] = []
public private(set) var reactionExpandedInteractionIds: Set<Int64> = [] public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<Interaction, MessageViewModel>? public private(set) var pagedDataObserver: PagedDatabaseObserver<Interaction, MessageViewModel>?
public var onInteractionChange: (([SectionModel]) -> ())? { public var onInteractionChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
didSet { didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the // 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 // data was changed while we weren't observing
if let unobservedInteractionDataChanges: [SectionModel] = self.unobservedInteractionDataChanges { if let unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges {
onInteractionChange?(unobservedInteractionDataChanges) onInteractionChange?(unobservedInteractionDataChanges.0, unobservedInteractionDataChanges.1)
self.unobservedInteractionDataChanges = nil self.unobservedInteractionDataChanges = nil
} }
} }
@ -247,20 +247,32 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
) )
], ],
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { guard
return let currentData: [SectionModel] = self?.interactionData,
} let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo)
else { return }
// If we have the 'onInteractionChanged' callback then trigger it, otherwise just store the changes let changeset: StagedChangeset<[SectionModel]> = StagedChangeset(
// to be sent to the callback if we ever start observing again (when we have the callback it needs source: currentData,
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the target: updatedInteractionData
// correct order) )
guard let onInteractionChange: (([SectionModel]) -> ()) = self?.onInteractionChange else {
self?.unobservedInteractionDataChanges = updatedInteractionData // No need to do anything if there were no changes
return 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)
} }
) )
} }

View File

@ -151,7 +151,7 @@ private extension MentionSelectionView {
// Highlight color // Highlight color
let selectedBackgroundView = UIView() let selectedBackgroundView = UIView()
selectedBackgroundView.themeBackgroundColor = .settings_tabHighlight selectedBackgroundView.themeBackgroundColor = .highlighted(.settings_tabBackground)
self.selectedBackgroundView = selectedBackgroundView self.selectedBackgroundView = selectedBackgroundView
// Profile picture image view // Profile picture image view

View File

@ -69,9 +69,10 @@ final class MediaPlaceholderView: UIView {
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ]) let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
stackView.axis = .horizontal stackView.axis = .horizontal
stackView.alignment = .center stackView.alignment = .center
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 12)
addSubview(stackView) 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)
} }
} }

View File

@ -399,6 +399,12 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
!cellViewModel.isLast !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( private func populateContentView(

View File

@ -9,12 +9,17 @@ final class ConversationTitleView: UIView {
private static let leftInset: CGFloat = 8 private static let leftInset: CGFloat = 8
private static let leftInsetWithCallButton: CGFloat = 54 private static let leftInsetWithCallButton: CGFloat = 54
private var oldSize: CGSize = .zero
override var intrinsicContentSize: CGSize { override var intrinsicContentSize: CGSize {
return UIView.layoutFittingExpandedSize return UIView.layoutFittingExpandedSize
} }
// MARK: - UI Components // 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 = { private lazy var titleLabel: UILabel = {
let result: UILabel = UILabel() let result: UILabel = UILabel()
result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
@ -37,7 +42,6 @@ final class ConversationTitleView: UIView {
let result = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ]) let result = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ])
result.axis = .vertical result.axis = .vertical
result.alignment = .center result.alignment = .center
result.isLayoutMarginsRelativeArrangement = true
return result return result
}() }()
@ -49,7 +53,10 @@ final class ConversationTitleView: UIView {
addSubview(stackView) 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 { 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( public func update(
with name: String, with name: String,
isNoteToSelf: Bool, isNoteToSelf: Bool,
@ -161,14 +183,10 @@ final class ConversationTitleView: UIView {
!isNoteToSelf && !isNoteToSelf &&
threadVariant == .contact threadVariant == .contact
) )
self.stackView.layoutMargins = UIEdgeInsets( self.stackViewLeadingConstraint.constant = (shouldShowCallButton ?
top: 0, ConversationTitleView.leftInsetWithCallButton :
left: (shouldShowCallButton ? ConversationTitleView.leftInset
ConversationTitleView.leftInsetWithCallButton :
ConversationTitleView.leftInset
),
bottom: 0,
right: 0
) )
self.stackViewTrailingConstraint.constant = 0
} }
} }

View File

@ -101,27 +101,36 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
return result return result
}() }()
private lazy var newConversationButton: UIButton = { private lazy var newConversationButton: UIView = {
let result = UIButton(type: .system) let result: UIView = UIView()
result.clipsToBounds = false result.set(.width, to: HomeVC.newConversationButtonSize)
result.setImage( result.set(.height, to: HomeVC.newConversationButtonSize)
let button = UIButton()
button.clipsToBounds = true
button.setImage(
UIImage(named: "Plus")? UIImage(named: "Plus")?
.withRenderingMode(.alwaysTemplate), .withRenderingMode(.alwaysTemplate),
for: .normal for: .normal
) )
result.contentMode = .center button.contentMode = .center
result.themeBackgroundColor = .menuButton_background button.adjustsImageWhenHighlighted = false
result.themeTintColor = .menuButton_icon button.themeTintColor = .menuButton_icon
result.contentEdgeInsets = UIEdgeInsets( button.setThemeBackgroundColor(.menuButton_background, for: .normal)
button.setThemeBackgroundColor(
.highlighted(.menuButton_background, alwaysDarken: true),
for: .highlighted
)
button.contentEdgeInsets = UIEdgeInsets(
top: ((HomeVC.newConversationButtonSize - 24) / 2), top: ((HomeVC.newConversationButtonSize - 24) / 2),
leading: ((HomeVC.newConversationButtonSize - 24) / 2), leading: ((HomeVC.newConversationButtonSize - 24) / 2),
bottom: ((HomeVC.newConversationButtonSize - 24) / 2), bottom: ((HomeVC.newConversationButtonSize - 24) / 2),
trailing: ((HomeVC.newConversationButtonSize - 24) / 2) trailing: ((HomeVC.newConversationButtonSize - 24) / 2)
) )
result.layer.cornerRadius = (HomeVC.newConversationButtonSize / 2) button.layer.cornerRadius = (HomeVC.newConversationButtonSize / 2)
result.addTarget(self, action: #selector(createNewConversation), for: .touchUpInside) button.addTarget(self, action: #selector(createNewConversation), for: .touchUpInside)
result.set(.width, to: HomeVC.newConversationButtonSize) result.addSubview(button)
result.set(.height, to: HomeVC.newConversationButtonSize) button.pin(to: result)
// Add the outer shadow // Add the outer shadow
result.themeShadowColor = .menuButton_outerShadow result.themeShadowColor = .menuButton_outerShadow
@ -323,15 +332,15 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
} }
) )
self.viewModel.onThreadChange = { [weak self] updatedThreadData in self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
self?.handleThreadUpdates(updatedThreadData) self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
} }
// Note: When returning from the background we could have received notifications but the // 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 // PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
// data to ensure everything is up to date // data to ensure everything is up to date
if didReturnFromBackground { if didReturnFromBackground {
DispatchQueue.global(qos: .default).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.pagedDataObserver?.reload() self?.viewModel.pagedDataObserver?.reload()
} }
} }
@ -372,12 +381,18 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
self.viewModel.updateState(updatedState) 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 // Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero) // in from a frame of CGRect.zero)
guard hasLoadedInitialThreadData else { guard hasLoadedInitialThreadData else {
hasLoadedInitialThreadData = true hasLoadedInitialThreadData = true
UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) } UIView.performWithoutAnimation {
handleThreadUpdates(updatedData, changeset: changeset, initialLoad: true)
}
return return
} }
@ -399,7 +414,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
// Reload the table content (animate changes after the first load) // Reload the table content (animate changes after the first load)
tableView.reload( tableView.reload(
using: StagedChangeset(source: viewModel.threadData, target: updatedData), using: changeset,
deleteSectionsAnimation: .none, deleteSectionsAnimation: .none,
insertSectionsAnimation: .none, insertSectionsAnimation: .none,
reloadSectionsAnimation: .none, reloadSectionsAnimation: .none,
@ -438,7 +453,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
self?.isLoadingMore = true self?.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter) self?.viewModel.pagedDataObserver?.load(.pageAfter)
} }
} }
@ -559,7 +574,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
case .loadMore: case .loadMore:
self.isLoadingMore = true self.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter) self?.viewModel.pagedDataObserver?.load(.pageAfter)
} }

View File

@ -150,20 +150,42 @@ public class HomeViewModel {
orderSQL: SessionThreadViewModel.homeOrderSQL orderSQL: SessionThreadViewModel.homeOrderSQL
), ),
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { guard
return 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 // Note: On the initial launch the data will be fetched on the main thread and we want it
// to be sent to the callback if we ever start observing again (when we have the callback it needs // to block so don't dispatch to the next run loop
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the guard !Thread.isMainThread else {
// correct order) return performUpdates()
guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else { }
self?.unobservedThreadDataChanges = updatedThreadData
return // 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 } else { return }
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above /// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
let currentData: [SessionThreadViewModel] = (self.unobservedThreadDataChanges ?? self.threadData) let currentData: [SectionModel] = (self.unobservedThreadDataChanges?.0 ?? self.threadData)
.flatMap { $0.elements } let updatedThreadData: [SectionModel] = self.process(
let updatedThreadData: [SectionModel] = self.process(data: currentData, for: currentPageInfo) data: currentData.flatMap { $0.elements },
for: currentPageInfo
)
let changeset: StagedChangeset<[SectionModel]> = StagedChangeset(
source: currentData,
target: updatedThreadData
)
guard let onThreadChange: (([SectionModel]) -> ()) = self.onThreadChange else { // No need to do anything if there were no changes
self.unobservedThreadDataChanges = updatedThreadData guard !changeset.isEmpty else { return }
guard let onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self.onThreadChange else {
self.unobservedThreadDataChanges = (updatedThreadData, changeset)
return return
} }
onThreadChange(updatedThreadData) onThreadChange(updatedThreadData, changeset)
} }
// MARK: - Thread Data // 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 threadData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>? public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
public var onThreadChange: (([SectionModel]) -> ())? { public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
didSet { didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the // 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 // data was changed while we weren't observing
if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges { if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
onThreadChange?(unobservedThreadDataChanges) onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1)
self.unobservedThreadDataChanges = nil self.unobservedThreadDataChanges = nil
} }
} }

View File

@ -204,8 +204,8 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
// MARK: - Updating // MARK: - Updating
private func startObservingChanges(didReturnFromBackground: Bool = false) { private func startObservingChanges(didReturnFromBackground: Bool = false) {
self.viewModel.onThreadChange = { [weak self] updatedThreadData in self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
self?.handleThreadUpdates(updatedThreadData) self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
} }
// Note: When returning from the background we could have received notifications but the // 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 // Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero) // in from a frame of CGRect.zero)
guard hasLoadedInitialThreadData else { guard hasLoadedInitialThreadData else {
hasLoadedInitialThreadData = true hasLoadedInitialThreadData = true
UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) } UIView.performWithoutAnimation {
handleThreadUpdates(updatedData, changeset: changeset, initialLoad: true)
}
return return
} }
@ -241,7 +247,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
// Reload the table content (animate changes after the first load) // Reload the table content (animate changes after the first load)
tableView.reload( tableView.reload(
using: StagedChangeset(source: viewModel.threadData, target: updatedData), using: changeset,
deleteSectionsAnimation: .none, deleteSectionsAnimation: .none,
insertSectionsAnimation: .none, insertSectionsAnimation: .none,
reloadSectionsAnimation: .none, reloadSectionsAnimation: .none,
@ -280,7 +286,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
self?.isLoadingMore = true self?.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter) self?.viewModel.pagedDataObserver?.load(.pageAfter)
} }
} }
@ -352,7 +358,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
case .loadMore: case .loadMore:
self.isLoadingMore = true self.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter) self?.viewModel.pagedDataObserver?.load(.pageAfter)
} }

View File

@ -98,25 +98,37 @@ public class MessageRequestsViewModel {
orderSQL: SessionThreadViewModel.messageRequetsOrderSQL orderSQL: SessionThreadViewModel.messageRequetsOrderSQL
), ),
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { guard
return let currentData: [SectionModel] = self?.threadData,
} let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo)
else { return }
// If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes let changeset: StagedChangeset<[SectionModel]> = StagedChangeset(
// to be sent to the callback if we ever start observing again (when we have the callback it needs source: currentData,
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the target: updatedThreadData
// correct order) )
guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else {
self?.unobservedThreadDataChanges = updatedThreadData // No need to do anything if there were no changes
return 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 // 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 // The `.pageBefore` will query from a `0` offset loading the first page
self?.pagedDataObserver?.load(.pageBefore) self?.pagedDataObserver?.load(.pageBefore)
} }
@ -124,16 +136,16 @@ public class MessageRequestsViewModel {
// MARK: - Thread Data // 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 threadData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>? public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
public var onThreadChange: (([SectionModel]) -> ())? { public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
didSet { didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the // 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 // data was changed while we weren't observing
if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges { if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
onThreadChange?(unobservedThreadDataChanges) self.onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1)
self.unobservedThreadDataChanges = nil self.unobservedThreadDataChanges = nil
} }
} }

View File

@ -78,7 +78,7 @@ class MessageRequestsCell: UITableViewCell {
private func setUpViewHierarchy() { private func setUpViewHierarchy() {
themeBackgroundColor = .conversationButton_unreadBackground themeBackgroundColor = .conversationButton_unreadBackground
selectedBackgroundView = UIView() selectedBackgroundView = UIView()
selectedBackgroundView?.themeBackgroundColor = .conversationButton_unreadHighlight selectedBackgroundView?.themeBackgroundColor = .highlighted(.conversationButton_unreadBackground)
contentView.addSubview(iconContainerView) contentView.addSubview(iconContainerView)
contentView.addSubview(titleLabel) contentView.addSubview(titleLabel)

View File

@ -230,7 +230,7 @@ private final class NewConversationButton: UIView {
private let selectedBackgroundView: UIView = { private let selectedBackgroundView: UIView = {
let result: UIView = UIView() let result: UIView = UIView()
result.themeBackgroundColor = .settings_tabHighlight result.themeBackgroundColor = .highlighted(.settings_tabBackground)
result.isHidden = true result.isHidden = true
return result return result

View File

@ -166,10 +166,12 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
case .loadNewer, .loadOlder: case .loadNewer, .loadOlder:
// Attachments are loaded in descending order so 'loadOlder' actually corresponds with // Attachments are loaded in descending order so 'loadOlder' actually corresponds with
// 'pageAfter' in this case // 'pageAfter' in this case
self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ? DispatchQueue.global(qos: .userInitiated).async { [weak self] in
.pageAfter : self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ?
.pageBefore .pageAfter :
) .pageBefore
)
}
return return
default: continue default: continue
@ -180,8 +182,8 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
private func startObservingChanges() { private func startObservingChanges() {
// Start observing for data changes (will callback on the main thread) // Start observing for data changes (will callback on the main thread)
self.viewModel.onGalleryChange = { [weak self] updatedGalleryData in self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, changeset in
self?.handleUpdates(updatedGalleryData) self?.handleUpdates(updatedGalleryData, changeset: changeset)
} }
} }
@ -191,7 +193,10 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
self.viewModel.onGalleryChange = nil 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 // Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero) // in from a frame of CGRect.zero)
guard hasLoadedInitialData else { guard hasLoadedInitialData else {
@ -227,7 +232,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
if isInsertingAtTop { CATransaction.setDisableActions(true) } if isInsertingAtTop { CATransaction.setDisableActions(true) }
self.tableView.reload( self.tableView.reload(
using: StagedChangeset(source: self.viewModel.galleryData, target: updatedGalleryData), using: changeset,
with: .automatic, with: .automatic,
interrupt: { $0.changeCount > MediaTileViewController.itemPageSize } interrupt: { $0.changeCount > MediaTileViewController.itemPageSize }
) { [weak self] updatedData in ) { [weak self] updatedData in
@ -418,7 +423,7 @@ class DocumentCell: UITableViewCell {
backgroundView?.layer.cornerRadius = 5 backgroundView?.layer.cornerRadius = 5
selectedBackgroundView = UIView() selectedBackgroundView = UIView()
selectedBackgroundView?.themeBackgroundColor = .settings_tabHighlight selectedBackgroundView?.themeBackgroundColor = .highlighted(.settings_tabBackground)
selectedBackgroundView?.layer.cornerRadius = 5 selectedBackgroundView?.layer.cornerRadius = 5
contentView.addSubview(iconImageView) contentView.addSubview(iconImageView)

View File

@ -42,14 +42,14 @@ public class MediaGalleryViewModel {
public private(set) var pagedDataObserver: PagedDatabaseObserver<Attachment, Item>? public private(set) var pagedDataObserver: PagedDatabaseObserver<Attachment, Item>?
/// This value is the current state of a gallery view /// 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 private(set) var galleryData: [SectionModel] = []
public var onGalleryChange: (([SectionModel]) -> ())? { public var onGalleryChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
didSet { didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the // 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 // data was changed while we weren't observing
if let unobservedGalleryDataChanges: [SectionModel] = self.unobservedGalleryDataChanges { if let unobservedGalleryDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges {
onGalleryChange?(unobservedGalleryDataChanges) onGalleryChange?(unobservedGalleryDataChanges.0, unobservedGalleryDataChanges.1)
self.unobservedGalleryDataChanges = nil self.unobservedGalleryDataChanges = nil
} }
} }
@ -93,20 +93,32 @@ public class MediaGalleryViewModel {
orderSQL: Item.galleryOrderSQL, orderSQL: Item.galleryOrderSQL,
dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL), dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL),
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
guard let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { guard
return let currentData: [SectionModel] = self?.galleryData,
} let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo)
else { return }
// If we have the 'onGalleryChange' callback then trigger it, otherwise just store the changes let changeset: StagedChangeset<[SectionModel]> = StagedChangeset(
// to be sent to the callback if we ever start observing again (when we have the callback it needs source: currentData,
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the target: updatedGalleryData
// correct order) )
guard let onGalleryChange: (([SectionModel]) -> ()) = self?.onGalleryChange else {
self?.unobservedGalleryDataChanges = updatedGalleryData // No need to do anything if there were no changes
return 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) // we don't want to mess with the initial view controller behaviour)
guard !performInitialQuerySync else { guard !performInitialQuerySync else {
loadInitialData() loadInitialData()
updateGalleryData(self.unobservedGalleryDataChanges ?? []) updateGalleryData(self.unobservedGalleryDataChanges?.0 ?? [])
return return
} }
DispatchQueue.global(qos: .default).async { DispatchQueue.global(qos: .userInitiated).async {
loadInitialData() loadInitialData()
} }
} }

View File

@ -262,10 +262,12 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
case .loadNewer, .loadOlder: case .loadNewer, .loadOlder:
// Attachments are loaded in descending order so 'loadOlder' actually corresponds with // Attachments are loaded in descending order so 'loadOlder' actually corresponds with
// 'pageAfter' in this case // 'pageAfter' in this case
self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ? DispatchQueue.global(qos: .userInitiated).async { [weak self] in
.pageAfter : self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ?
.pageBefore .pageAfter :
) .pageBefore
)
}
return return
default: continue default: continue
@ -276,8 +278,8 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
private func startObservingChanges(didReturnFromBackground: Bool = false) { private func startObservingChanges(didReturnFromBackground: Bool = false) {
// Start observing for data changes (will callback on the main thread) // Start observing for data changes (will callback on the main thread)
self.viewModel.onGalleryChange = { [weak self] updatedGalleryData in self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, changeset in
self?.handleUpdates(updatedGalleryData) self?.handleUpdates(updatedGalleryData, changeset: changeset)
} }
// Note: When returning from the background we could have received notifications but the // 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 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 // Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero) // in from a frame of CGRect.zero)
guard hasLoadedInitialData else { guard hasLoadedInitialData else {
@ -341,7 +346,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
self.mediaTileViewLayout.isInsertingCellsToTop = isInsertingAtTop self.mediaTileViewLayout.isInsertingCellsToTop = isInsertingAtTop
self.mediaTileViewLayout.contentSizeBeforeInsertingToTop = self.collectionView.contentSize self.mediaTileViewLayout.contentSizeBeforeInsertingToTop = self.collectionView.contentSize
self.collectionView.reload( self.collectionView.reload(
using: StagedChangeset(source: self.viewModel.galleryData, target: updatedGalleryData), using: changeset,
interrupt: { $0.changeCount > MediaTileViewController.itemPageSize } interrupt: { $0.changeCount > MediaTileViewController.itemPageSize }
) { [weak self] updatedData in ) { [weak self] updatedData in
self?.viewModel.updateGalleryData(updatedData) self?.viewModel.updateGalleryData(updatedData)
@ -456,10 +461,12 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
UIScrollView.fastEndScrollingThen(collectionView, self.currentTargetOffset) { [weak self] in UIScrollView.fastEndScrollingThen(collectionView, self.currentTargetOffset) { [weak self] in
// Attachments are loaded in descending order so 'loadOlder' actually corresponds with // Attachments are loaded in descending order so 'loadOlder' actually corresponds with
// 'pageAfter' in this case // 'pageAfter' in this case
self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ? DispatchQueue.global(qos: .userInitiated).async { [weak self] in
.pageAfter : self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ?
.pageBefore .pageAfter :
) .pageBefore
)
}
} }
case .emptyGallery, .galleryMonth: break case .emptyGallery, .galleryMonth: break

View File

@ -186,8 +186,8 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat
// MARK: - Updating // MARK: - Updating
private func startObservingChanges(didReturnFromBackground: Bool = false) { private func startObservingChanges(didReturnFromBackground: Bool = false) {
self.viewModel.onContactChange = { [weak self] updatedContactData in self.viewModel.onContactChange = { [weak self] updatedContactData, changeset in
self?.handleContactUpdates(updatedContactData) self?.handleContactUpdates(updatedContactData, changeset: changeset)
} }
// Note: When returning from the background we could have received notifications but the // 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 // Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero) // in from a frame of CGRect.zero)
guard hasLoadedInitialContactData else { guard hasLoadedInitialContactData else {
hasLoadedInitialContactData = true hasLoadedInitialContactData = true
UIView.performWithoutAnimation { handleContactUpdates(updatedData, initialLoad: true) } UIView.performWithoutAnimation {
handleContactUpdates(updatedData, changeset: changeset, initialLoad: true)
}
return return
} }
@ -225,7 +231,7 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat
// Reload the table content (animate changes after the first load) // Reload the table content (animate changes after the first load)
tableView.reload( tableView.reload(
using: StagedChangeset(source: viewModel.contactData, target: updatedData), using: changeset,
deleteSectionsAnimation: .none, deleteSectionsAnimation: .none,
insertSectionsAnimation: .none, insertSectionsAnimation: .none,
reloadSectionsAnimation: .none, reloadSectionsAnimation: .none,
@ -266,7 +272,7 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat
self?.isLoadingMore = true self?.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter) self?.viewModel.pagedDataObserver?.load(.pageAfter)
} }
} }
@ -351,7 +357,7 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat
case .loadMore: case .loadMore:
self.isLoadingMore = true self.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter) self?.viewModel.pagedDataObserver?.load(.pageAfter)
} }

View File

@ -62,25 +62,37 @@ public class BlockedContactsViewModel {
orderSQL: DataModel.orderSQL orderSQL: DataModel.orderSQL
), ),
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
guard let updatedContactData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { guard
return let currentData: [SectionModel] = self?.contactData,
} let updatedContactData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo)
else { return }
// If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes let changeset: StagedChangeset<[SectionModel]> = StagedChangeset(
// to be sent to the callback if we ever start observing again (when we have the callback it needs source: currentData,
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the target: updatedContactData
// correct order) )
guard let onContactChange: (([SectionModel]) -> ()) = self?.onContactChange else {
self?.unobservedContactDataChanges = updatedContactData // No need to do anything if there were no changes
return 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 // 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 // The `.pageBefore` will query from a `0` offset loading the first page
self?.pagedDataObserver?.load(.pageBefore) self?.pagedDataObserver?.load(.pageBefore)
} }
@ -89,16 +101,16 @@ public class BlockedContactsViewModel {
// MARK: - Contact Data // MARK: - Contact Data
public private(set) var selectedContactIds: Set<String> = [] public private(set) var selectedContactIds: Set<String> = []
public private(set) var unobservedContactDataChanges: [SectionModel]? public private(set) var unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
public private(set) var contactData: [SectionModel] = [] public private(set) var contactData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<Profile, DataModel>? public private(set) var pagedDataObserver: PagedDatabaseObserver<Profile, DataModel>?
public var onContactChange: (([SectionModel]) -> ())? { public var onContactChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
didSet { didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the // 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 // data was changed while we weren't observing
if let unobservedContactDataChanges: [SectionModel] = self.unobservedContactDataChanges { if let unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedContactDataChanges {
onContactChange?(unobservedContactDataChanges) onContactChange?(unobservedContactDataChanges.0 , unobservedContactDataChanges.1)
self.unobservedContactDataChanges = nil self.unobservedContactDataChanges = nil
} }
} }

View File

@ -41,7 +41,7 @@ class BlockedContactCell: UITableViewCell {
// Highlight color // Highlight color
let selectedBackgroundView = UIView() let selectedBackgroundView = UIView()
selectedBackgroundView.themeBackgroundColor = .conversationButton_highlight selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background)
self.selectedBackgroundView = selectedBackgroundView self.selectedBackgroundView = selectedBackgroundView
// Add the UI // Add the UI

View File

@ -16,7 +16,7 @@ class ThemeSelectionView: UIView {
let result: UIButton = UIButton() let result: UIButton = UIButton()
result.translatesAutoresizingMaskIntoConstraints = false result.translatesAutoresizingMaskIntoConstraints = false
result.setThemeBackgroundColor(.appearance_buttonBackground, for: .normal) 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) result.addTarget(self, action: #selector(itemSelected), for: .touchUpInside)
return result return result

View File

@ -148,7 +148,7 @@ public final class FullConversationCell: UITableViewCell {
// Highlight color // Highlight color
let selectedBackgroundView = UIView() let selectedBackgroundView = UIView()
selectedBackgroundView.themeBackgroundColor = .conversationButton_highlight selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background)
self.selectedBackgroundView = selectedBackgroundView self.selectedBackgroundView = selectedBackgroundView
// Accent line view // Accent line view
@ -340,14 +340,12 @@ public final class FullConversationCell: UITableViewCell {
public func update(with cellViewModel: SessionThreadViewModel) { public func update(with cellViewModel: SessionThreadViewModel) {
let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0) let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0)
themeBackgroundColor = (unreadCount > 0 ? let themeBackgroundColor: ThemeValue = (unreadCount > 0 ?
.conversationButton_unreadBackground : .conversationButton_unreadBackground :
.conversationButton_background .conversationButton_background
) )
self.selectedBackgroundView?.themeBackgroundColor = (unreadCount > 0 ? self.themeBackgroundColor = themeBackgroundColor
.conversationButton_unreadHighlight : self.selectedBackgroundView?.themeBackgroundColor = .highlighted(themeBackgroundColor)
.conversationButton_highlight
)
if cellViewModel.threadIsBlocked == true { if cellViewModel.threadIsBlocked == true {
accentLineView.themeBackgroundColor = .danger accentLineView.themeBackgroundColor = .danger

View File

@ -30,7 +30,7 @@ public class SessionCell: UITableViewCell {
private var botSeparatorLeftConstraint: NSLayoutConstraint = NSLayoutConstraint() private var botSeparatorLeftConstraint: NSLayoutConstraint = NSLayoutConstraint()
private var botSeparatorRightConstraint: NSLayoutConstraint = NSLayoutConstraint() private var botSeparatorRightConstraint: NSLayoutConstraint = NSLayoutConstraint()
private lazy var leftAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: leftAccessoryView) 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 = { private let cellBackgroundView: UIView = {
let result: UIView = UIView() let result: UIView = UIView()
@ -44,7 +44,7 @@ public class SessionCell: UITableViewCell {
private let cellSelectedBackgroundView: UIView = { private let cellSelectedBackgroundView: UIView = {
let result: UIView = UIView() let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false result.translatesAutoresizingMaskIntoConstraints = false
result.themeBackgroundColor = .settings_tabHighlight result.themeBackgroundColor = .highlighted(.settings_tabBackground)
result.alpha = 0 result.alpha = 0
return result return result

View File

@ -55,7 +55,7 @@ public class SessionHighlightingBackgroundLabel: UIView {
func setHighlighted(_ highlighted: Bool, animated: Bool) { func setHighlighted(_ highlighted: Bool, animated: Bool) {
self.themeBackgroundColor = (highlighted ? self.themeBackgroundColor = (highlighted ?
.solidButton_highlight : .highlighted(.solidButton_background) :
.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 // need to swap back into the "highlighted" state so we can properly unhighlight within
// the "deselect" animation // the "deselect" animation
guard !selected else { guard !selected else {
self.themeBackgroundColor = .solidButton_highlight self.themeBackgroundColor = .highlighted(.solidButton_background)
return return
} }
guard animated else { guard animated else {

View File

@ -94,10 +94,12 @@ public final class BackgroundPoller {
guard let snode = swarm.randomElement() else { throw SnodeAPIError.generic } guard let snode = swarm.randomElement() else { throw SnodeAPIError.generic }
return SnodeAPI.getMessages(from: snode, associatedWith: publicKey) return SnodeAPI.getMessages(from: snode, associatedWith: publicKey)
.then(on: DispatchQueue.main) { messages -> Promise<Void> in .then(on: DispatchQueue.main) { messages, lastHash -> Promise<Void> in
guard !messages.isEmpty, BackgroundPoller.isValid else { return Promise.value(()) } guard !messages.isEmpty, BackgroundPoller.isValid else { return Promise.value(()) }
var jobsToRun: [Job] = [] var jobsToRun: [Job] = []
var messageCount: Int = 0
var hadValidHashUpdate: Bool = false
Storage.shared.write { db in Storage.shared.write { db in
messages messages
@ -115,6 +117,10 @@ public final class BackgroundPoller {
MessageReceiverError.duplicateControlMessage, MessageReceiverError.duplicateControlMessage,
MessageReceiverError.selfSend: MessageReceiverError.selfSend:
break break
case MessageReceiverError.duplicateMessageNewSnode:
hadValidHashUpdate = true
break
// In the background ignore 'SQLITE_ABORT' (it generally means // In the background ignore 'SQLITE_ABORT' (it generally means
// the BackgroundPoller has timed out // the BackgroundPoller has timed out
@ -128,6 +134,8 @@ public final class BackgroundPoller {
} }
.grouped { threadId, _, _ in (threadId ?? Message.nonThreadMessageId) } .grouped { threadId, _, _ in (threadId ?? Message.nonThreadMessageId) }
.forEach { threadId, threadMessages in .forEach { threadId, threadMessages in
messageCount += threadMessages.count
let maybeJob: Job? = Job( let maybeJob: Job? = Job(
variant: .messageReceive, variant: .messageReceive,
behaviour: .runOnce, behaviour: .runOnce,
@ -145,6 +153,15 @@ public final class BackgroundPoller {
JobRunner.add(db, job: job, canStartJob: false) JobRunner.add(db, job: job, canStartJob: false)
jobsToRun.append(job) 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<Void>] = jobsToRun.map { job -> Promise<Void> in let promises: [Promise<Void>] = jobsToRun.map { job -> Promise<Void> in

View File

@ -227,7 +227,7 @@ public extension Message {
// service node, but may have done so for another node - if the hash already existed in // 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 // the database before we inserted it for this node then we can ignore this message as a
// duplicate // duplicate
guard numExistingHashes == 0 else { throw MessageReceiverError.duplicateMessage } guard numExistingHashes == 0 else { throw MessageReceiverError.duplicateMessageNewSnode }
return processedMessage return processedMessage
} }

View File

@ -4,6 +4,5 @@ FOUNDATION_EXPORT double SessionMessagingKitVersionNumber;
FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[];
#import <SessionMessagingKit/AppReadiness.h> #import <SessionMessagingKit/AppReadiness.h>
#import <SessionMessagingKit/NSData+messagePadding.h>
#import <SessionMessagingKit/OWSAudioPlayer.h> #import <SessionMessagingKit/OWSAudioPlayer.h>
#import <SessionMessagingKit/OWSWindowManager.h> #import <SessionMessagingKit/OWSWindowManager.h>

View File

@ -4,6 +4,7 @@ import Foundation
public enum MessageReceiverError: LocalizedError { public enum MessageReceiverError: LocalizedError {
case duplicateMessage case duplicateMessage
case duplicateMessageNewSnode
case duplicateControlMessage case duplicateControlMessage
case invalidMessage case invalidMessage
case unknownMessage case unknownMessage
@ -21,8 +22,9 @@ public enum MessageReceiverError: LocalizedError {
public var isRetryable: Bool { public var isRetryable: Bool {
switch self { switch self {
case .duplicateMessage, .duplicateControlMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, case .duplicateMessage, .duplicateMessageNewSnode, .duplicateControlMessage,
.invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed: .invalidMessage, .unknownMessage, .unknownEnvelopeType, .invalidSignature,
.noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed:
return false return false
default: return true default: return true
@ -32,6 +34,7 @@ public enum MessageReceiverError: LocalizedError {
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
case .duplicateMessage: return "Duplicate message." case .duplicateMessage: return "Duplicate message."
case .duplicateMessageNewSnode: return "Duplicate message from different service node."
case .duplicateControlMessage: return "Duplicate control message." case .duplicateControlMessage: return "Duplicate control message."
case .invalidMessage: return "Invalid message." case .invalidMessage: return "Invalid message."
case .unknownMessage: return "Unknown message type." case .unknownMessage: return "Unknown message type."

View File

@ -51,6 +51,13 @@ extension MessageReceiver {
_ = try interaction.attachments _ = try interaction.attachments
.deleteAll(db) .deleteAll(db)
if let serverHash: String = interaction.serverHash {
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
db,
potentiallyInvalidHashes: [serverHash]
)
}
} }
} }
} }

View File

@ -118,7 +118,7 @@ public enum MessageReceiver {
let proto: SNProtoContent let proto: SNProtoContent
do { do {
proto = try SNProtoContent.parseData((plaintext as NSData).removePadding()) proto = try SNProtoContent.parseData(plaintext.removePadding())
} }
catch { catch {
SNLog("Couldn't parse proto due to error: \(error).") SNLog("Couldn't parse proto due to error: \(error).")

View File

@ -134,7 +134,7 @@ public final class MessageSender {
let plaintext: Data let plaintext: Data
do { do {
plaintext = (try proto.serializedData() as NSData).paddedMessageBody() plaintext = try proto.serializedData().paddedMessageBody()
} }
catch { catch {
SNLog("Couldn't serialize proto due to error: \(error).") SNLog("Couldn't serialize proto due to error: \(error).")
@ -411,7 +411,7 @@ public final class MessageSender {
let plaintext: Data let plaintext: Data
do { do {
plaintext = (try proto.serializedData() as NSData).paddedMessageBody() plaintext = try proto.serializedData().paddedMessageBody()
} }
catch { catch {
SNLog("Couldn't serialize proto due to error: \(error).") SNLog("Couldn't serialize proto due to error: \(error).")
@ -510,7 +510,7 @@ public final class MessageSender {
let plaintext: Data let plaintext: Data
do { do {
plaintext = (try proto.serializedData() as NSData).paddedMessageBody() plaintext = try proto.serializedData().paddedMessageBody()
} }
catch { catch {
SNLog("Couldn't serialize proto due to error: \(error).") SNLog("Couldn't serialize proto due to error: \(error).")

View File

@ -160,7 +160,7 @@ public final class ClosedGroupPoller {
poller?.isPolling.wrappedValue[groupPublicKey] == true poller?.isPolling.wrappedValue[groupPublicKey] == true
else { return Promise(error: Error.pollingCanceled) } else { return Promise(error: Error.pollingCanceled) }
let promises: [Promise<[SnodeReceivedMessage]>] = { let promises: [Promise<([SnodeReceivedMessage], String?)>] = {
if SnodeAPI.hardfork >= 19 && SnodeAPI.softfork >= 1 { if SnodeAPI.hardfork >= 19 && SnodeAPI.softfork >= 1 {
return [ SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey, authenticated: false) ] return [ SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey, authenticated: false) ]
} }
@ -187,11 +187,20 @@ public final class ClosedGroupPoller {
let allMessages: [SnodeReceivedMessage] = messageResults let allMessages: [SnodeReceivedMessage] = messageResults
.reduce([]) { result, next in .reduce([]) { result, next in
switch next { switch next {
case .fulfilled(let messages): return result.appending(contentsOf: messages) case .fulfilled(let data): return result.appending(contentsOf: data.0)
default: return result 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 messageCount: Int = 0
var hadValidHashUpdate: Bool = false
// No need to do anything if there are no messages // No need to do anything if there are no messages
guard !allMessages.isEmpty else { guard !allMessages.isEmpty else {
@ -218,6 +227,10 @@ public final class ClosedGroupPoller {
MessageReceiverError.selfSend: MessageReceiverError.selfSend:
break break
case MessageReceiverError.duplicateMessageNewSnode:
hadValidHashUpdate = true
break
// In the background ignore 'SQLITE_ABORT' (it generally means // In the background ignore 'SQLITE_ABORT' (it generally means
// the BackgroundPoller has timed out // the BackgroundPoller has timed out
case DatabaseError.SQLITE_ABORT: 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 // 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 // the next app run if they fail but don't let them auto-start
JobRunner.add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller) 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 { 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))") SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (duplicates: \(allMessages.count - messageCount))")
} }

View File

@ -129,11 +129,12 @@ public final class Poller {
let userPublicKey: String = getUserHexEncodedPublicKey() let userPublicKey: String = getUserHexEncodedPublicKey()
return SnodeAPI.getMessages(from: snode, associatedWith: userPublicKey) return SnodeAPI.getMessages(from: snode, associatedWith: userPublicKey)
.then(on: Threading.pollerQueue) { [weak self] messages -> Promise<Void> in .then(on: Threading.pollerQueue) { [weak self] messages, lastHash -> Promise<Void> in
guard self?.isPolling.wrappedValue == true else { return Promise { $0.fulfill(()) } } guard self?.isPolling.wrappedValue == true else { return Promise { $0.fulfill(()) } }
if !messages.isEmpty { if !messages.isEmpty {
var messageCount: Int = 0 var messageCount: Int = 0
var hadValidHashUpdate: Bool = false
Storage.shared.write { db in Storage.shared.write { db in
messages messages
@ -151,6 +152,10 @@ public final class Poller {
MessageReceiverError.selfSend: MessageReceiverError.selfSend:
break break
case MessageReceiverError.duplicateMessageNewSnode:
hadValidHashUpdate = true
break
case DatabaseError.SQLITE_ABORT: case DatabaseError.SQLITE_ABORT:
SNLog("Failed to the database being suspended (running in background with no background task).") SNLog("Failed to the database being suspended (running in background with no background task).")
break 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 { else {
SNLog("Received no new messages") SNLog("Received no new messages")

View File

@ -21,4 +21,51 @@ public extension Data {
throw HTTP.Error.parsingFailed 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..<self.count] = Data(self.bytes)
paddedMessage[self.count..<(self.count + 1)] = Data([paddingByte])
return paddedMessage
}
private func paddedMessageLength(_ unpaddedLength: Int) -> Int {
let messageLengthWithTerminator: Int = (unpaddedLength + 1)
var messagePartCount: Int = (messageLengthWithTerminator / 160)
if CGFloat(messageLengthWithTerminator).truncatingRemainder(dividingBy: 160) != 0 {
messagePartCount += 1
}
return (messagePartCount * 160)
}
} }

View File

@ -1,11 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
@interface NSData (messagePadding)
- (NSData *)removePadding;
- (NSData *)paddedMessageBody;
@end

View File

@ -1,60 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import <Foundation/Foundation.h>
#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

View File

@ -60,7 +60,7 @@ final class SimplifiedConversationCell: UITableViewCell {
themeBackgroundColor = .conversationButton_background themeBackgroundColor = .conversationButton_background
let selectedBackgroundView = UIView() let selectedBackgroundView = UIView()
selectedBackgroundView.themeBackgroundColor = .conversationButton_highlight selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background)
self.selectedBackgroundView = selectedBackgroundView self.selectedBackgroundView = selectedBackgroundView
addSubview(stackView) addSubview(stackView)

View File

@ -14,6 +14,9 @@ public enum SNSnodeKit { // Just to make the external API nice
], ],
[ [
_003_YDBToGRDBMigration.self _003_YDBToGRDBMigration.self
],
[
_004_FlagMessageHashAsDeletedOrInvalid.self
] ]
] ]
) )

View File

@ -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
}
}

View File

@ -13,6 +13,7 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist
case key case key
case hash case hash
case expirationDateMs case expirationDateMs
case wasDeletedOrInvalid
} }
/// The `id` value is auto incremented by the database, if the `Job` hasn't been inserted into /// 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) /// 14 days)
public let expirationDateMs: Int64 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 // MARK: - Custom Database Interaction
public mutating func didInsert(with rowID: Int64, for column: String?) { 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? { static func fetchLastNotExpired(for snode: Snode, namespace: Int, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? {
return Storage.shared.read { db in return Storage.shared.read { db in
let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo 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.key == key(for: snode, publicKey: publicKey, namespace: namespace))
.filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > (Date().timeIntervalSince1970 * 1000)) .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > (Date().timeIntervalSince1970 * 1000))
.order(SnodeReceivedMessageInfo.Columns.id.desc) .order(SnodeReceivedMessageInfo.Columns.id.desc)
@ -118,9 +131,44 @@ public extension SnodeReceivedMessageInfo {
if nonLegacyHash != nil { return nonLegacyHash } if nonLegacyHash != nil { return nonLegacyHash }
return try SnodeReceivedMessageInfo return try SnodeReceivedMessageInfo
.filter(
SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == nil ||
SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == false
)
.filter(SnodeReceivedMessageInfo.Columns.key == publicKey) .filter(SnodeReceivedMessageInfo.Columns.key == publicKey)
.order(SnodeReceivedMessageInfo.Columns.id.desc) .order(SnodeReceivedMessageInfo.Columns.id.desc)
.fetchOne(db) .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)
)
}
} }

View File

@ -489,8 +489,8 @@ public final class SnodeAPI {
// MARK: - Retrieve // MARK: - Retrieve
// Not in use until we can batch delete and store config messages // Not in use until we can batch delete and store config messages
public static func getConfigMessages(from snode: Snode, associatedWith publicKey: String) -> Promise<[SnodeReceivedMessage]> { public static func getConfigMessages(from snode: Snode, associatedWith publicKey: String) -> Promise<([SnodeReceivedMessage], String?)> {
let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending() let (promise, seal) = Promise<([SnodeReceivedMessage], String?)>.pending()
Threading.workQueue.async { Threading.workQueue.async {
getMessagesWithAuthentication(from: snode, associatedWith: publicKey, namespace: configNamespace) getMessagesWithAuthentication(from: snode, associatedWith: publicKey, namespace: configNamespace)
@ -505,8 +505,8 @@ public final class SnodeAPI {
return promise return promise
} }
public static func getMessages(from snode: Snode, associatedWith publicKey: String, authenticated: Bool = true) -> Promise<[SnodeReceivedMessage]> { public static func getMessages(from snode: Snode, associatedWith publicKey: String, authenticated: Bool = true) -> Promise<([SnodeReceivedMessage], String?)> {
let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending() let (promise, seal) = Promise<([SnodeReceivedMessage], String?)>.pending()
Threading.workQueue.async { Threading.workQueue.async {
let retrievePromise = (authenticated ? let retrievePromise = (authenticated ?
@ -522,8 +522,8 @@ public final class SnodeAPI {
return promise return promise
} }
public static func getClosedGroupMessagesFromDefaultNamespace(from snode: Snode, associatedWith publicKey: String) -> Promise<[SnodeReceivedMessage]> { public static func getClosedGroupMessagesFromDefaultNamespace(from snode: Snode, associatedWith publicKey: String) -> Promise<([SnodeReceivedMessage], String?)> {
let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending() let (promise, seal) = Promise<([SnodeReceivedMessage], String?)>.pending()
Threading.workQueue.async { Threading.workQueue.async {
getMessagesUnauthenticated(from: snode, associatedWith: publicKey, namespace: defaultNamespace) getMessagesUnauthenticated(from: snode, associatedWith: publicKey, namespace: defaultNamespace)
@ -534,7 +534,7 @@ public final class SnodeAPI {
return promise 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 /// **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. /// 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 { 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( private static func getMessagesUnauthenticated(
from snode: Snode, from snode: Snode,
associatedWith publicKey: String, associatedWith publicKey: String,
namespace: Int = closedGroupNamespace namespace: Int = closedGroupNamespace
) -> Promise<[SnodeReceivedMessage]> { ) -> Promise<([SnodeReceivedMessage], String?)> {
// Get last message hash // Get last message hash
SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo(for: snode, namespace: namespace, associatedWith: publicKey) SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo(for: snode, namespace: namespace, associatedWith: publicKey)
let lastHash = SnodeReceivedMessageInfo.fetchLastNotExpired(for: snode, namespace: namespace, associatedWith: publicKey)?.hash ?? "" let lastHash = SnodeReceivedMessageInfo.fetchLastNotExpired(for: snode, namespace: namespace, associatedWith: publicKey)?.hash ?? ""
@ -598,7 +599,7 @@ public final class SnodeAPI {
// Make the request // Make the request
var parameters: JSON = [ var parameters: JSON = [
"pubKey": (Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey), "pubKey": (Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey),
"lastHash": lastHash, "lastHash": lastHash
] ]
// Don't include namespace if polling for 0 with no authentication // Don't include namespace if polling for 0 with no authentication
@ -625,6 +626,7 @@ public final class SnodeAPI {
) )
} }
} }
.map { ($0, lastHash) }
} }
// MARK: Store // 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 return result
} }
} }

View File

@ -121,7 +121,7 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate {
result.setTitle(title, for: .normal) result.setTitle(title, for: .normal)
result.setThemeTitleColor(titleColor, for: .normal) result.setThemeTitleColor(titleColor, for: .normal)
result.setThemeBackgroundColor(.alert_buttonBackground, 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) result.set(.height, to: Values.alertButtonHeight)
return result return result

View File

@ -62,28 +62,22 @@ internal enum Theme_ClassicDark: ThemeColors {
// SolidButton // SolidButton
.solidButton_background: .classicDark3, .solidButton_background: .classicDark3,
.solidButton_highlight: .classicDark4,
// Settings // Settings
.settings_tabBackground: .classicDark1, .settings_tabBackground: .classicDark1,
.settings_tabHighlight: .classicDark3,
// Appearance // Appearance
.appearance_sectionBackground: .classicDark1, .appearance_sectionBackground: .classicDark1,
.appearance_buttonBackground: .classicDark1, .appearance_buttonBackground: .classicDark1,
.appearance_buttonHighlight: .classicDark3,
// Alert // Alert
.alert_text: .classicDark6, .alert_text: .classicDark6,
.alert_background: .classicDark1, .alert_background: .classicDark1,
.alert_buttonBackground: .classicDark1, .alert_buttonBackground: .classicDark1,
.alert_buttonHighlight: .classicDark3,
// ConversationButton // ConversationButton
.conversationButton_background: .classicDark1, .conversationButton_background: .classicDark1,
.conversationButton_highlight: .classicDark3,
.conversationButton_unreadBackground: .classicDark2, .conversationButton_unreadBackground: .classicDark2,
.conversationButton_unreadHighlight: .classicDark3,
.conversationButton_unreadStripBackground: .primary, .conversationButton_unreadStripBackground: .primary,
.conversationButton_unreadBubbleBackground: .classicDark3, .conversationButton_unreadBubbleBackground: .classicDark3,
.conversationButton_unreadBubbleText: .classicDark6, .conversationButton_unreadBubbleText: .classicDark6,

View File

@ -62,28 +62,22 @@ internal enum Theme_ClassicLight: ThemeColors {
// SolidButton // SolidButton
.solidButton_background: .classicLight3, .solidButton_background: .classicLight3,
.solidButton_highlight: .classicLight4,
// Settings // Settings
.settings_tabBackground: .classicLight5, .settings_tabBackground: .classicLight5,
.settings_tabHighlight: .classicLight3,
// AppearanceButton // AppearanceButton
.appearance_sectionBackground: .classicLight6, .appearance_sectionBackground: .classicLight6,
.appearance_buttonBackground: .classicLight6, .appearance_buttonBackground: .classicLight6,
.appearance_buttonHighlight: .classicLight4,
// Alert // Alert
.alert_text: .classicLight0, .alert_text: .classicLight0,
.alert_background: .classicLight6, .alert_background: .classicLight6,
.alert_buttonBackground: .classicLight6, .alert_buttonBackground: .classicLight6,
.alert_buttonHighlight: .classicLight4,
// ConversationButton // ConversationButton
.conversationButton_background: .classicLight6, .conversationButton_background: .classicLight6,
.conversationButton_highlight: .classicLight4,
.conversationButton_unreadBackground: .classicLight6, .conversationButton_unreadBackground: .classicLight6,
.conversationButton_unreadHighlight: .classicLight4,
.conversationButton_unreadStripBackground: .primary, .conversationButton_unreadStripBackground: .primary,
.conversationButton_unreadBubbleBackground: .classicLight3, .conversationButton_unreadBubbleBackground: .classicLight3,
.conversationButton_unreadBubbleText: .classicLight0, .conversationButton_unreadBubbleText: .classicLight0,

View File

@ -62,28 +62,22 @@ internal enum Theme_OceanDark: ThemeColors {
// SolidButton // SolidButton
.solidButton_background: .oceanDark2, .solidButton_background: .oceanDark2,
.solidButton_highlight: .oceanDark4,
// Settings // Settings
.settings_tabBackground: .oceanDark1, .settings_tabBackground: .oceanDark1,
.settings_tabHighlight: .oceanDark3,
// Appearance // Appearance
.appearance_sectionBackground: .oceanDark3, .appearance_sectionBackground: .oceanDark3,
.appearance_buttonBackground: .oceanDark3, .appearance_buttonBackground: .oceanDark3,
.appearance_buttonHighlight: .oceanDark4,
// Alert // Alert
.alert_text: .oceanDark7, .alert_text: .oceanDark7,
.alert_background: .oceanDark3, .alert_background: .oceanDark3,
.alert_buttonBackground: .oceanDark3, .alert_buttonBackground: .oceanDark3,
.alert_buttonHighlight: .oceanDark4,
// ConversationButton // ConversationButton
.conversationButton_background: .oceanDark3, .conversationButton_background: .oceanDark3,
.conversationButton_highlight: .oceanDark4, .conversationButton_unreadBackground: .oceanDark4,
.conversationButton_unreadBackground: .oceanDark2,
.conversationButton_unreadHighlight: .oceanDark4,
.conversationButton_unreadStripBackground: .primary, .conversationButton_unreadStripBackground: .primary,
.conversationButton_unreadBubbleBackground: .primary, .conversationButton_unreadBubbleBackground: .primary,
.conversationButton_unreadBubbleText: .oceanDark0, .conversationButton_unreadBubbleText: .oceanDark0,

View File

@ -62,28 +62,22 @@ internal enum Theme_OceanLight: ThemeColors {
// SolidButton // SolidButton
.solidButton_background: .oceanLight5, .solidButton_background: .oceanLight5,
.solidButton_highlight: .oceanLight6,
// Settings // Settings
.settings_tabBackground: .oceanLight6, .settings_tabBackground: .oceanLight6,
.settings_tabHighlight: .oceanLight5,
// Appearance // Appearance
.appearance_sectionBackground: .oceanLight7, .appearance_sectionBackground: .oceanLight7,
.appearance_buttonBackground: .oceanLight7, .appearance_buttonBackground: .oceanLight7,
.appearance_buttonHighlight: .oceanLight5,
// Alert // Alert
.alert_text: .oceanLight0, .alert_text: .oceanLight0,
.alert_background: .oceanLight7, .alert_background: .oceanLight7,
.alert_buttonBackground: .oceanLight7, .alert_buttonBackground: .oceanLight7,
.alert_buttonHighlight: .oceanLight5,
// ConversationButton // ConversationButton
.conversationButton_background: .oceanLight7, .conversationButton_background: .oceanLight7,
.conversationButton_highlight: .oceanLight5,
.conversationButton_unreadBackground: .oceanLight6, .conversationButton_unreadBackground: .oceanLight6,
.conversationButton_unreadHighlight: .oceanLight5,
.conversationButton_unreadStripBackground: .primary, .conversationButton_unreadStripBackground: .primary,
.conversationButton_unreadBubbleBackground: .primary, .conversationButton_unreadBubbleBackground: .primary,
.conversationButton_unreadBubbleText: .oceanLight1, .conversationButton_unreadBubbleText: .oceanLight1,

View File

@ -55,6 +55,13 @@ public enum Theme: String, CaseIterable, Codable, EnumStringSetting {
public func color(for value: ThemeValue) -> UIColor? { public func color(for value: ThemeValue) -> UIColor? {
switch value { switch value {
case .value(let value, let alpha): return color(for: value)?.withAlphaComponent(alpha) 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] default: return colors[value]
} }
} }
@ -77,6 +84,14 @@ public protocol ThemedNavigation {
public indirect enum ThemeValue: Hashable { public indirect enum ThemeValue: Hashable {
case value(ThemeValue, alpha: CGFloat) 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 // General
case white case white
case black case black
@ -135,28 +150,22 @@ public indirect enum ThemeValue: Hashable {
// SolidButton // SolidButton
case solidButton_background case solidButton_background
case solidButton_highlight
// Settings // Settings
case settings_tabBackground case settings_tabBackground
case settings_tabHighlight
// Appearance // Appearance
case appearance_sectionBackground case appearance_sectionBackground
case appearance_buttonBackground case appearance_buttonBackground
case appearance_buttonHighlight
// Alert // Alert
case alert_text case alert_text
case alert_background case alert_background
case alert_buttonBackground case alert_buttonBackground
case alert_buttonHighlight
// ConversationButton // ConversationButton
case conversationButton_background case conversationButton_background
case conversationButton_highlight
case conversationButton_unreadBackground case conversationButton_unreadBackground
case conversationButton_unreadHighlight
case conversationButton_unreadStripBackground case conversationButton_unreadStripBackground
case conversationButton_unreadBubbleBackground case conversationButton_unreadBubbleBackground
case conversationButton_unreadBubbleText case conversationButton_unreadBubbleText

View File

@ -36,5 +36,29 @@ public extension UIColor {
alpha: CGFloatLerp(a0, a1, finalAlpha) 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
)
}
} }

View File

@ -198,10 +198,7 @@ public class PagedDatabaseObserver<ObservedTable, T>: TransactionObserver where
// Update the cache, pageInfo and the change callback // Update the cache, pageInfo and the change callback
self?.dataCache.mutate { $0 = finalUpdatedDataCache } self?.dataCache.mutate { $0 = finalUpdatedDataCache }
self?.pageInfo.mutate { $0 = updatedPageInfo } self?.pageInfo.mutate { $0 = updatedPageInfo }
self?.onChangeUnsorted(finalUpdatedDataCache.values, updatedPageInfo)
DispatchQueue.main.async { [weak self] in
self?.onChangeUnsorted(finalUpdatedDataCache.values, updatedPageInfo)
}
} }
// Determing if there were any direct or related data changes // Determing if there were any direct or related data changes
@ -729,12 +726,6 @@ public class PagedDatabaseObserver<ObservedTable, T>: TransactionObserver where
self?.isLoadingMoreData.mutate { $0 = false } self?.isLoadingMoreData.mutate { $0 = false }
} }
// Make sure the updates run on the main thread
guard Thread.isMainThread else {
DispatchQueue.main.async { triggerUpdates() }
return
}
triggerUpdates() triggerUpdates()
} }

View File

@ -227,7 +227,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate {
for: .normal for: .normal
) )
button.setThemeBackgroundColorForced( button.setThemeBackgroundColorForced(
.theme(.classicLight, color: .settings_tabHighlight), .theme(.classicLight, color: .highlighted(.settings_tabBackground)),
for: .highlighted for: .highlighted
) )
button.addTarget(self, action: #selector(audioPlayPauseButtonPressed), for: .touchUpInside) button.addTarget(self, action: #selector(audioPlayPauseButtonPressed), for: .touchUpInside)