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:
parent
59dac34fe8
commit
d8fd3b35b4
|
@ -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 */,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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]
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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).")
|
||||||
|
|
|
@ -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).")
|
||||||
|
|
|
@ -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))")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
//
|
|
||||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
@interface NSData (messagePadding)
|
|
||||||
|
|
||||||
- (NSData *)removePadding;
|
|
||||||
|
|
||||||
- (NSData *)paddedMessageBody;
|
|
||||||
|
|
||||||
@end
|
|
|
@ -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
|
|
|
@ -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)
|
||||||
|
|
|
@ -14,6 +14,9 @@ public enum SNSnodeKit { // Just to make the external API nice
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
_003_YDBToGRDBMigration.self
|
_003_YDBToGRDBMigration.self
|
||||||
|
],
|
||||||
|
[
|
||||||
|
_004_FlagMessageHashAsDeletedOrInvalid.self
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue