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 */; };
|
||||
FD37EA1928AC5CCA003AE748 /* NotificationSoundViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA1828AC5CCA003AE748 /* NotificationSoundViewModel.swift */; };
|
||||
FD39352C28F382920084DADA /* VersionFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD39352B28F382920084DADA /* VersionFooterView.swift */; };
|
||||
FD39353628F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD39353528F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift */; };
|
||||
FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; };
|
||||
FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */; };
|
||||
FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */; };
|
||||
|
@ -716,7 +717,6 @@
|
|||
FD716E6C28505E1C00C96BF4 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */; };
|
||||
FD716E7128505E5200C96BF4 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */; };
|
||||
FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; };
|
||||
FD716E732850647900C96BF4 /* NSData+messagePadding.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
FD7728962849E7E90018502F /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728952849E7E90018502F /* String+Utilities.swift */; };
|
||||
FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728972849E8110018502F /* UITableView+ReusableView.swift */; };
|
||||
FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD772899284AF1BD0018502F /* Sodium+Utilities.swift */; };
|
||||
|
@ -746,7 +746,6 @@
|
|||
FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */; };
|
||||
FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF927C2F5C500510D0C /* MockGenericHash.swift */; };
|
||||
FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFB27C2F60700510D0C /* MockEd25519.swift */; };
|
||||
FD86585828507B24008B6CF9 /* NSData+messagePadding.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D4825589FF20043A11F /* NSData+messagePadding.m */; };
|
||||
FD87DCFA28B74DB300AF0F98 /* ConversationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */; };
|
||||
FD87DCFC28B755B800AF0F98 /* BlockedContactsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFB28B755B800AF0F98 /* BlockedContactsViewController.swift */; };
|
||||
FD87DCFE28B7582C00AF0F98 /* BlockedContactsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */; };
|
||||
|
@ -1540,8 +1539,6 @@
|
|||
C3A71D0A2558989C0043A11F /* MessageWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageWrapper.swift; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1721,6 +1718,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -3129,8 +3127,6 @@
|
|||
C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */,
|
||||
C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */,
|
||||
C3A71D0A2558989C0043A11F /* MessageWrapper.swift */,
|
||||
C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */,
|
||||
C3A71D4825589FF20043A11F /* NSData+messagePadding.m */,
|
||||
FD09797E27FCFBFF00936362 /* OWSAES256Key+Utilities.swift */,
|
||||
C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */,
|
||||
C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */,
|
||||
|
@ -3570,6 +3566,7 @@
|
|||
FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */,
|
||||
FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */,
|
||||
FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */,
|
||||
FD39353528F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift */,
|
||||
);
|
||||
path = Migrations;
|
||||
sourceTree = "<group>";
|
||||
|
@ -4208,7 +4205,6 @@
|
|||
files = (
|
||||
C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */,
|
||||
C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */,
|
||||
FD716E732850647900C96BF4 /* NSData+messagePadding.h in Headers */,
|
||||
B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */,
|
||||
B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */,
|
||||
);
|
||||
|
@ -5257,6 +5253,7 @@
|
|||
C3C2A5E42553860B00C340D1 /* Data+Utilities.swift in Sources */,
|
||||
FD17D7A727F41AF000122BE0 /* SSKLegacy.swift in Sources */,
|
||||
FDC438B327BB15B400C60D73 /* ResponseInfo.swift in Sources */,
|
||||
FD39353628F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift in Sources */,
|
||||
C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */,
|
||||
FD17D7D827F658E200122BE0 /* OnionRequestAPIDestination.swift in Sources */,
|
||||
FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */,
|
||||
|
@ -5379,7 +5376,6 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7B81682828B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift in Sources */,
|
||||
FD86585828507B24008B6CF9 /* NSData+messagePadding.m in Sources */,
|
||||
FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */,
|
||||
B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */,
|
||||
C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */,
|
||||
|
|
|
@ -53,6 +53,10 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
var scrollDistanceToBottomBeforeUpdate: CGFloat?
|
||||
var baselineKeyboardHeight: CGFloat = 0
|
||||
|
||||
/// This flag is true between `viewDidAppear` and `viewWillDisappear` and is used to prevent keyboard changes
|
||||
/// from trying to animate (as the animations can cause staggering with push transitions)
|
||||
var viewIsFocussed = false
|
||||
|
||||
// Reaction
|
||||
var currentReactionListSheet: ReactionListSheet?
|
||||
var reactionExpandedMessageIds: Set<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
|
||||
// of different behaviours)
|
||||
didFinishInitialLayout = true
|
||||
viewIsFocussed = true
|
||||
|
||||
if delayFirstResponder || isShowingSearchUI {
|
||||
delayFirstResponder = false
|
||||
|
@ -420,6 +425,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
viewIsFocussed = false
|
||||
|
||||
// Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard
|
||||
// to appear to remain focussed)
|
||||
guard !isReplacingThread else { return }
|
||||
|
@ -499,8 +506,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
// Note: We want to load the interaction data into the UI after the initial thread data
|
||||
// has loaded to prevent an issue where the conversation loads with the wrong offset
|
||||
if self?.viewModel.onInteractionChange == nil {
|
||||
self?.viewModel.onInteractionChange = { [weak self] updatedInteractionData in
|
||||
self?.handleInteractionUpdates(updatedInteractionData)
|
||||
self?.viewModel.onInteractionChange = { [weak self] updatedInteractionData, changeset in
|
||||
self?.handleInteractionUpdates(updatedInteractionData, changeset: changeset)
|
||||
}
|
||||
|
||||
// Note: When returning from the background we could have received notifications but the
|
||||
|
@ -524,9 +531,18 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
// Ensure the first load or a load when returning from a child screen runs without animations (if
|
||||
// we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition)
|
||||
guard hasLoadedInitialThreadData && hasReloadedThreadDataAfterDisappearance else {
|
||||
// Need to correctly determine if it's the initial load otherwise we would be needlesly updating
|
||||
// extra UI elements
|
||||
let isInitialLoad: Bool = (
|
||||
!hasLoadedInitialThreadData &&
|
||||
hasReloadedThreadDataAfterDisappearance
|
||||
)
|
||||
hasLoadedInitialThreadData = true
|
||||
hasReloadedThreadDataAfterDisappearance = true
|
||||
UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) }
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
handleThreadUpdates(updatedThreadData, initialLoad: isInitialLoad)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -621,7 +637,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
}
|
||||
}
|
||||
|
||||
private func handleInteractionUpdates(_ updatedData: [ConversationViewModel.SectionModel], initialLoad: Bool = false) {
|
||||
private func handleInteractionUpdates(
|
||||
_ updatedData: [ConversationViewModel.SectionModel],
|
||||
changeset: StagedChangeset<[ConversationViewModel.SectionModel]>,
|
||||
initialLoad: Bool = false
|
||||
) {
|
||||
// Ensure the first load or a load when returning from a child screen runs without
|
||||
// animations (if we don't do this the cells will animate in from a frame of
|
||||
// CGRect.zero or have a buggy transition)
|
||||
|
@ -682,10 +702,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
}
|
||||
}
|
||||
|
||||
let changeset: StagedChangeset<[ConversationViewModel.SectionModel]> = StagedChangeset(
|
||||
source: viewModel.interactionData,
|
||||
target: updatedData
|
||||
)
|
||||
let numItemsInserted: Int = changeset.map { $0.elementInserted.count }.reduce(0, +)
|
||||
let isInsert: Bool = (numItemsInserted > 0)
|
||||
let wasLoadingMore: Bool = self.isLoadingMore
|
||||
|
@ -955,7 +971,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
|
||||
self?.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
// Attachments are loaded in descending order so 'loadOlder' actually corresponds with
|
||||
// 'pageAfter' in this case
|
||||
self?.viewModel.pagedDataObserver?.load(shouldLoadOlder ?
|
||||
|
@ -1050,6 +1066,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
// MARK: - Notifications
|
||||
|
||||
@objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) {
|
||||
guard viewIsFocussed || !didFinishInitialLayout else { return }
|
||||
|
||||
// Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600
|
||||
// and https://stackoverflow.com/a/25260930 to better understand what we are
|
||||
// doing with the UIViewAnimationOptions
|
||||
|
@ -1096,7 +1114,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
}
|
||||
|
||||
// Perform the changes (don't animate if the initial layout hasn't been completed)
|
||||
guard hasDoneLayout else {
|
||||
guard hasDoneLayout && didFinishInitialLayout else {
|
||||
UIView.performWithoutAnimation {
|
||||
changes()
|
||||
}
|
||||
|
@ -1113,6 +1131,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
}
|
||||
|
||||
@objc func handleKeyboardWillHideNotification(_ notification: Notification) {
|
||||
guard viewIsFocussed else { return }
|
||||
|
||||
// Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600
|
||||
// and https://stackoverflow.com/a/25260930 to better understand what we are
|
||||
// doing with the UIViewAnimationOptions
|
||||
|
@ -1273,7 +1293,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
case .loadOlder, .loadNewer:
|
||||
self.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
// Messages are loaded in descending order so 'loadOlder' actually corresponds with
|
||||
// 'pageAfter' in this case
|
||||
self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ?
|
||||
|
@ -1543,7 +1563,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
self.isLoadingMore = true
|
||||
self.searchController.resultsBar.startLoading()
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
if isJumpingToLastInteraction {
|
||||
self?.viewModel.pagedDataObserver?.load(.jumpTo(
|
||||
id: interactionId,
|
||||
|
|
|
@ -86,7 +86,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
)
|
||||
|
||||
// Run the initial query on a background thread so we don't block the push transition
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
|
||||
// from a `0` offset)
|
||||
guard let initialFocusedId: Int64 = targetInteractionId else {
|
||||
|
@ -150,17 +150,17 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
// MARK: - Interaction Data
|
||||
|
||||
private var lastInteractionIdMarkedAsRead: Int64?
|
||||
public private(set) var unobservedInteractionDataChanges: [SectionModel]?
|
||||
public private(set) var unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
||||
public private(set) var interactionData: [SectionModel] = []
|
||||
public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<Interaction, MessageViewModel>?
|
||||
|
||||
public var onInteractionChange: (([SectionModel]) -> ())? {
|
||||
public var onInteractionChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
|
||||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedInteractionDataChanges: [SectionModel] = self.unobservedInteractionDataChanges {
|
||||
onInteractionChange?(unobservedInteractionDataChanges)
|
||||
if let unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges {
|
||||
onInteractionChange?(unobservedInteractionDataChanges.0, unobservedInteractionDataChanges.1)
|
||||
self.unobservedInteractionDataChanges = nil
|
||||
}
|
||||
}
|
||||
|
@ -247,20 +247,32 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
)
|
||||
],
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||
return
|
||||
}
|
||||
guard
|
||||
let currentData: [SectionModel] = self?.interactionData,
|
||||
let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo)
|
||||
else { return }
|
||||
|
||||
// If we have the 'onInteractionChanged' callback then trigger it, otherwise just store the changes
|
||||
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||
// correct order)
|
||||
guard let onInteractionChange: (([SectionModel]) -> ()) = self?.onInteractionChange else {
|
||||
self?.unobservedInteractionDataChanges = updatedInteractionData
|
||||
return
|
||||
let changeset: StagedChangeset<[SectionModel]> = StagedChangeset(
|
||||
source: currentData,
|
||||
target: updatedInteractionData
|
||||
)
|
||||
|
||||
// No need to do anything if there were no changes
|
||||
guard !changeset.isEmpty else { return }
|
||||
|
||||
// Run any changes on the main thread (as they will generally trigger UI updates)
|
||||
DispatchQueue.main.async {
|
||||
// If we have the callback then trigger it, otherwise just store the changes to be sent
|
||||
// to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated
|
||||
// in the correct order)
|
||||
guard let onInteractionChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self?.onInteractionChange else {
|
||||
self?.unobservedInteractionDataChanges = (updatedInteractionData, changeset)
|
||||
return
|
||||
}
|
||||
|
||||
onInteractionChange(updatedInteractionData, changeset)
|
||||
}
|
||||
|
||||
onInteractionChange(updatedInteractionData)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -151,7 +151,7 @@ private extension MentionSelectionView {
|
|||
|
||||
// Highlight color
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.themeBackgroundColor = .settings_tabHighlight
|
||||
selectedBackgroundView.themeBackgroundColor = .highlighted(.settings_tabBackground)
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
|
||||
// Profile picture image view
|
||||
|
|
|
@ -69,9 +69,10 @@ final class MediaPlaceholderView: UIView {
|
|||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 12)
|
||||
addSubview(stackView)
|
||||
stackView.pin(to: self, withInset: Values.smallSpacing)
|
||||
stackView.pin(.top, to: .top, of: self, withInset: Values.smallSpacing)
|
||||
stackView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing)
|
||||
stackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.largeSpacing)
|
||||
stackView.pin(.bottom, to: .bottom, of: self, withInset: -Values.smallSpacing)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -399,6 +399,12 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
!cellViewModel.isLast
|
||||
)
|
||||
)
|
||||
|
||||
// Set the height of the underBubbleStackView to 0 if it has no content (need to do this
|
||||
// otherwise it can randomly stretch)
|
||||
underBubbleStackViewNoHeightConstraint.isActive = underBubbleStackView.arrangedSubviews
|
||||
.filter { !$0.isHidden }
|
||||
.isEmpty
|
||||
}
|
||||
|
||||
private func populateContentView(
|
||||
|
|
|
@ -9,12 +9,17 @@ final class ConversationTitleView: UIView {
|
|||
private static let leftInset: CGFloat = 8
|
||||
private static let leftInsetWithCallButton: CGFloat = 54
|
||||
|
||||
private var oldSize: CGSize = .zero
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
return UIView.layoutFittingExpandedSize
|
||||
}
|
||||
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var stackViewLeadingConstraint: NSLayoutConstraint = stackView.pin(.leading, to: .leading, of: self)
|
||||
private lazy var stackViewTrailingConstraint: NSLayoutConstraint = stackView.pin(.trailing, to: .trailing, of: self)
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
|
@ -37,7 +42,6 @@ final class ConversationTitleView: UIView {
|
|||
let result = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ])
|
||||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -49,7 +53,10 @@ final class ConversationTitleView: UIView {
|
|||
|
||||
addSubview(stackView)
|
||||
|
||||
stackView.pin(to: self)
|
||||
stackView.pin(.top, to: .top, of: self)
|
||||
stackViewLeadingConstraint.isActive = true
|
||||
stackViewTrailingConstraint.isActive = true
|
||||
stackView.pin(.bottom, to: .bottom, of: self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
@ -73,6 +80,21 @@ final class ConversationTitleView: UIView {
|
|||
)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
// There is an annoying issue where pushing seems to update the width of this
|
||||
// view resulting in the content shifting to the right during
|
||||
guard self.oldSize != .zero, self.oldSize != bounds.size else {
|
||||
self.oldSize = bounds.size
|
||||
return
|
||||
}
|
||||
|
||||
let diff: CGFloat = (bounds.size.width - oldSize.width)
|
||||
self.stackViewTrailingConstraint.constant = -max(0, diff)
|
||||
self.oldSize = bounds.size
|
||||
}
|
||||
|
||||
public func update(
|
||||
with name: String,
|
||||
isNoteToSelf: Bool,
|
||||
|
@ -161,14 +183,10 @@ final class ConversationTitleView: UIView {
|
|||
!isNoteToSelf &&
|
||||
threadVariant == .contact
|
||||
)
|
||||
self.stackView.layoutMargins = UIEdgeInsets(
|
||||
top: 0,
|
||||
left: (shouldShowCallButton ?
|
||||
ConversationTitleView.leftInsetWithCallButton :
|
||||
ConversationTitleView.leftInset
|
||||
),
|
||||
bottom: 0,
|
||||
right: 0
|
||||
self.stackViewLeadingConstraint.constant = (shouldShowCallButton ?
|
||||
ConversationTitleView.leftInsetWithCallButton :
|
||||
ConversationTitleView.leftInset
|
||||
)
|
||||
self.stackViewTrailingConstraint.constant = 0
|
||||
}
|
||||
}
|
||||
|
|
|
@ -101,27 +101,36 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
return result
|
||||
}()
|
||||
|
||||
private lazy var newConversationButton: UIButton = {
|
||||
let result = UIButton(type: .system)
|
||||
result.clipsToBounds = false
|
||||
result.setImage(
|
||||
private lazy var newConversationButton: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.set(.width, to: HomeVC.newConversationButtonSize)
|
||||
result.set(.height, to: HomeVC.newConversationButtonSize)
|
||||
|
||||
let button = UIButton()
|
||||
button.clipsToBounds = true
|
||||
button.setImage(
|
||||
UIImage(named: "Plus")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.contentMode = .center
|
||||
result.themeBackgroundColor = .menuButton_background
|
||||
result.themeTintColor = .menuButton_icon
|
||||
result.contentEdgeInsets = UIEdgeInsets(
|
||||
button.contentMode = .center
|
||||
button.adjustsImageWhenHighlighted = false
|
||||
button.themeTintColor = .menuButton_icon
|
||||
button.setThemeBackgroundColor(.menuButton_background, for: .normal)
|
||||
button.setThemeBackgroundColor(
|
||||
.highlighted(.menuButton_background, alwaysDarken: true),
|
||||
for: .highlighted
|
||||
)
|
||||
button.contentEdgeInsets = UIEdgeInsets(
|
||||
top: ((HomeVC.newConversationButtonSize - 24) / 2),
|
||||
leading: ((HomeVC.newConversationButtonSize - 24) / 2),
|
||||
bottom: ((HomeVC.newConversationButtonSize - 24) / 2),
|
||||
trailing: ((HomeVC.newConversationButtonSize - 24) / 2)
|
||||
)
|
||||
result.layer.cornerRadius = (HomeVC.newConversationButtonSize / 2)
|
||||
result.addTarget(self, action: #selector(createNewConversation), for: .touchUpInside)
|
||||
result.set(.width, to: HomeVC.newConversationButtonSize)
|
||||
result.set(.height, to: HomeVC.newConversationButtonSize)
|
||||
button.layer.cornerRadius = (HomeVC.newConversationButtonSize / 2)
|
||||
button.addTarget(self, action: #selector(createNewConversation), for: .touchUpInside)
|
||||
result.addSubview(button)
|
||||
button.pin(to: result)
|
||||
|
||||
// Add the outer shadow
|
||||
result.themeShadowColor = .menuButton_outerShadow
|
||||
|
@ -323,15 +332,15 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
}
|
||||
)
|
||||
|
||||
self.viewModel.onThreadChange = { [weak self] updatedThreadData in
|
||||
self?.handleThreadUpdates(updatedThreadData)
|
||||
self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
|
||||
self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
|
||||
}
|
||||
|
||||
// Note: When returning from the background we could have received notifications but the
|
||||
// PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
|
||||
// data to ensure everything is up to date
|
||||
if didReturnFromBackground {
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.reload()
|
||||
}
|
||||
}
|
||||
|
@ -372,12 +381,18 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
self.viewModel.updateState(updatedState)
|
||||
}
|
||||
|
||||
private func handleThreadUpdates(_ updatedData: [HomeViewModel.SectionModel], initialLoad: Bool = false) {
|
||||
private func handleThreadUpdates(
|
||||
_ updatedData: [HomeViewModel.SectionModel],
|
||||
changeset: StagedChangeset<[HomeViewModel.SectionModel]>,
|
||||
initialLoad: Bool = false
|
||||
) {
|
||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialThreadData else {
|
||||
hasLoadedInitialThreadData = true
|
||||
UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) }
|
||||
UIView.performWithoutAnimation {
|
||||
handleThreadUpdates(updatedData, changeset: changeset, initialLoad: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -399,7 +414,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
|
||||
// Reload the table content (animate changes after the first load)
|
||||
tableView.reload(
|
||||
using: StagedChangeset(source: viewModel.threadData, target: updatedData),
|
||||
using: changeset,
|
||||
deleteSectionsAnimation: .none,
|
||||
insertSectionsAnimation: .none,
|
||||
reloadSectionsAnimation: .none,
|
||||
|
@ -438,7 +453,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
|
||||
self?.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
}
|
||||
|
@ -559,7 +574,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
case .loadMore:
|
||||
self.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
|
||||
|
|
|
@ -150,20 +150,42 @@ public class HomeViewModel {
|
|||
orderSQL: SessionThreadViewModel.homeOrderSQL
|
||||
),
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||
return
|
||||
guard
|
||||
let currentData: [SectionModel] = self?.threadData,
|
||||
let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo)
|
||||
else { return }
|
||||
|
||||
let changeset: StagedChangeset<[SectionModel]> = StagedChangeset(
|
||||
source: currentData,
|
||||
target: updatedThreadData
|
||||
)
|
||||
|
||||
// No need to do anything if there were no changes
|
||||
guard !changeset.isEmpty else { return }
|
||||
|
||||
let performUpdates = {
|
||||
// If we have the callback then trigger it, otherwise just store the changes to be sent
|
||||
// to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated
|
||||
// in the correct order)
|
||||
guard let onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self?.onThreadChange else {
|
||||
self?.unobservedThreadDataChanges = (updatedThreadData, changeset)
|
||||
return
|
||||
}
|
||||
|
||||
onThreadChange(updatedThreadData, changeset)
|
||||
}
|
||||
|
||||
// If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes
|
||||
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||
// correct order)
|
||||
guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else {
|
||||
self?.unobservedThreadDataChanges = updatedThreadData
|
||||
return
|
||||
// Note: On the initial launch the data will be fetched on the main thread and we want it
|
||||
// to block so don't dispatch to the next run loop
|
||||
guard !Thread.isMainThread else {
|
||||
return performUpdates()
|
||||
}
|
||||
|
||||
// Run any changes on the main thread (as they will generally trigger UI updates)
|
||||
DispatchQueue.main.async {
|
||||
performUpdates()
|
||||
}
|
||||
|
||||
onThreadChange(updatedThreadData)
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -219,30 +241,39 @@ public class HomeViewModel {
|
|||
else { return }
|
||||
|
||||
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
|
||||
let currentData: [SessionThreadViewModel] = (self.unobservedThreadDataChanges ?? self.threadData)
|
||||
.flatMap { $0.elements }
|
||||
let updatedThreadData: [SectionModel] = self.process(data: currentData, for: currentPageInfo)
|
||||
let currentData: [SectionModel] = (self.unobservedThreadDataChanges?.0 ?? self.threadData)
|
||||
let updatedThreadData: [SectionModel] = self.process(
|
||||
data: currentData.flatMap { $0.elements },
|
||||
for: currentPageInfo
|
||||
)
|
||||
let changeset: StagedChangeset<[SectionModel]> = StagedChangeset(
|
||||
source: currentData,
|
||||
target: updatedThreadData
|
||||
)
|
||||
|
||||
guard let onThreadChange: (([SectionModel]) -> ()) = self.onThreadChange else {
|
||||
self.unobservedThreadDataChanges = updatedThreadData
|
||||
// No need to do anything if there were no changes
|
||||
guard !changeset.isEmpty else { return }
|
||||
|
||||
guard let onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self.onThreadChange else {
|
||||
self.unobservedThreadDataChanges = (updatedThreadData, changeset)
|
||||
return
|
||||
}
|
||||
|
||||
onThreadChange(updatedThreadData)
|
||||
onThreadChange(updatedThreadData, changeset)
|
||||
}
|
||||
|
||||
// MARK: - Thread Data
|
||||
|
||||
public private(set) var unobservedThreadDataChanges: [SectionModel]?
|
||||
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
||||
public private(set) var threadData: [SectionModel] = []
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
||||
|
||||
public var onThreadChange: (([SectionModel]) -> ())? {
|
||||
public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
|
||||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges {
|
||||
onThreadChange?(unobservedThreadDataChanges)
|
||||
if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
||||
onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1)
|
||||
self.unobservedThreadDataChanges = nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -204,8 +204,8 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
// MARK: - Updating
|
||||
|
||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||
self.viewModel.onThreadChange = { [weak self] updatedThreadData in
|
||||
self?.handleThreadUpdates(updatedThreadData)
|
||||
self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
|
||||
self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
|
||||
}
|
||||
|
||||
// Note: When returning from the background we could have received notifications but the
|
||||
|
@ -216,12 +216,18 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
}
|
||||
}
|
||||
|
||||
private func handleThreadUpdates(_ updatedData: [MessageRequestsViewModel.SectionModel], initialLoad: Bool = false) {
|
||||
private func handleThreadUpdates(
|
||||
_ updatedData: [MessageRequestsViewModel.SectionModel],
|
||||
changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>,
|
||||
initialLoad: Bool = false
|
||||
) {
|
||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialThreadData else {
|
||||
hasLoadedInitialThreadData = true
|
||||
UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) }
|
||||
UIView.performWithoutAnimation {
|
||||
handleThreadUpdates(updatedData, changeset: changeset, initialLoad: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -241,7 +247,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
|
||||
// Reload the table content (animate changes after the first load)
|
||||
tableView.reload(
|
||||
using: StagedChangeset(source: viewModel.threadData, target: updatedData),
|
||||
using: changeset,
|
||||
deleteSectionsAnimation: .none,
|
||||
insertSectionsAnimation: .none,
|
||||
reloadSectionsAnimation: .none,
|
||||
|
@ -280,7 +286,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
|
||||
self?.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
}
|
||||
|
@ -352,7 +358,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
case .loadMore:
|
||||
self.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
|
||||
|
|
|
@ -98,25 +98,37 @@ public class MessageRequestsViewModel {
|
|||
orderSQL: SessionThreadViewModel.messageRequetsOrderSQL
|
||||
),
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||
return
|
||||
}
|
||||
guard
|
||||
let currentData: [SectionModel] = self?.threadData,
|
||||
let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo)
|
||||
else { return }
|
||||
|
||||
// If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes
|
||||
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||
// correct order)
|
||||
guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else {
|
||||
self?.unobservedThreadDataChanges = updatedThreadData
|
||||
return
|
||||
let changeset: StagedChangeset<[SectionModel]> = StagedChangeset(
|
||||
source: currentData,
|
||||
target: updatedThreadData
|
||||
)
|
||||
|
||||
// No need to do anything if there were no changes
|
||||
guard !changeset.isEmpty else { return }
|
||||
|
||||
// Run any changes on the main thread (as they will generally trigger UI updates)
|
||||
DispatchQueue.main.async {
|
||||
// If we have the callback then trigger it, otherwise just store the changes to be sent
|
||||
// to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated
|
||||
// in the correct order)
|
||||
guard let onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self?.onThreadChange else {
|
||||
self?.unobservedThreadDataChanges = (updatedThreadData, changeset)
|
||||
return
|
||||
}
|
||||
|
||||
onThreadChange(updatedThreadData, changeset)
|
||||
}
|
||||
|
||||
onThreadChange(updatedThreadData)
|
||||
}
|
||||
)
|
||||
|
||||
// Run the initial query on a background thread so we don't block the push transition
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
// The `.pageBefore` will query from a `0` offset loading the first page
|
||||
self?.pagedDataObserver?.load(.pageBefore)
|
||||
}
|
||||
|
@ -124,16 +136,16 @@ public class MessageRequestsViewModel {
|
|||
|
||||
// MARK: - Thread Data
|
||||
|
||||
public private(set) var unobservedThreadDataChanges: [SectionModel]?
|
||||
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
||||
public private(set) var threadData: [SectionModel] = []
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
||||
|
||||
public var onThreadChange: (([SectionModel]) -> ())? {
|
||||
public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
|
||||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges {
|
||||
onThreadChange?(unobservedThreadDataChanges)
|
||||
if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
||||
self.onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1)
|
||||
self.unobservedThreadDataChanges = nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -78,7 +78,7 @@ class MessageRequestsCell: UITableViewCell {
|
|||
private func setUpViewHierarchy() {
|
||||
themeBackgroundColor = .conversationButton_unreadBackground
|
||||
selectedBackgroundView = UIView()
|
||||
selectedBackgroundView?.themeBackgroundColor = .conversationButton_unreadHighlight
|
||||
selectedBackgroundView?.themeBackgroundColor = .highlighted(.conversationButton_unreadBackground)
|
||||
|
||||
contentView.addSubview(iconContainerView)
|
||||
contentView.addSubview(titleLabel)
|
||||
|
|
|
@ -230,7 +230,7 @@ private final class NewConversationButton: UIView {
|
|||
|
||||
private let selectedBackgroundView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.themeBackgroundColor = .settings_tabHighlight
|
||||
result.themeBackgroundColor = .highlighted(.settings_tabBackground)
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
|
|
|
@ -166,10 +166,12 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
case .loadNewer, .loadOlder:
|
||||
// Attachments are loaded in descending order so 'loadOlder' actually corresponds with
|
||||
// 'pageAfter' in this case
|
||||
self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ?
|
||||
.pageAfter :
|
||||
.pageBefore
|
||||
)
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ?
|
||||
.pageAfter :
|
||||
.pageBefore
|
||||
)
|
||||
}
|
||||
return
|
||||
|
||||
default: continue
|
||||
|
@ -180,8 +182,8 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
|
||||
private func startObservingChanges() {
|
||||
// Start observing for data changes (will callback on the main thread)
|
||||
self.viewModel.onGalleryChange = { [weak self] updatedGalleryData in
|
||||
self?.handleUpdates(updatedGalleryData)
|
||||
self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, changeset in
|
||||
self?.handleUpdates(updatedGalleryData, changeset: changeset)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -191,7 +193,10 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
self.viewModel.onGalleryChange = nil
|
||||
}
|
||||
|
||||
private func handleUpdates(_ updatedGalleryData: [MediaGalleryViewModel.SectionModel]) {
|
||||
private func handleUpdates(
|
||||
_ updatedGalleryData: [MediaGalleryViewModel.SectionModel],
|
||||
changeset: StagedChangeset<[MediaGalleryViewModel.SectionModel]>
|
||||
) {
|
||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialData else {
|
||||
|
@ -227,7 +232,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
if isInsertingAtTop { CATransaction.setDisableActions(true) }
|
||||
|
||||
self.tableView.reload(
|
||||
using: StagedChangeset(source: self.viewModel.galleryData, target: updatedGalleryData),
|
||||
using: changeset,
|
||||
with: .automatic,
|
||||
interrupt: { $0.changeCount > MediaTileViewController.itemPageSize }
|
||||
) { [weak self] updatedData in
|
||||
|
@ -418,7 +423,7 @@ class DocumentCell: UITableViewCell {
|
|||
backgroundView?.layer.cornerRadius = 5
|
||||
|
||||
selectedBackgroundView = UIView()
|
||||
selectedBackgroundView?.themeBackgroundColor = .settings_tabHighlight
|
||||
selectedBackgroundView?.themeBackgroundColor = .highlighted(.settings_tabBackground)
|
||||
selectedBackgroundView?.layer.cornerRadius = 5
|
||||
|
||||
contentView.addSubview(iconImageView)
|
||||
|
|
|
@ -42,14 +42,14 @@ public class MediaGalleryViewModel {
|
|||
public private(set) var pagedDataObserver: PagedDatabaseObserver<Attachment, Item>?
|
||||
|
||||
/// This value is the current state of a gallery view
|
||||
private var unobservedGalleryDataChanges: [SectionModel]?
|
||||
private var unobservedGalleryDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
||||
public private(set) var galleryData: [SectionModel] = []
|
||||
public var onGalleryChange: (([SectionModel]) -> ())? {
|
||||
public var onGalleryChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
|
||||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedGalleryDataChanges: [SectionModel] = self.unobservedGalleryDataChanges {
|
||||
onGalleryChange?(unobservedGalleryDataChanges)
|
||||
if let unobservedGalleryDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges {
|
||||
onGalleryChange?(unobservedGalleryDataChanges.0, unobservedGalleryDataChanges.1)
|
||||
self.unobservedGalleryDataChanges = nil
|
||||
}
|
||||
}
|
||||
|
@ -93,20 +93,32 @@ public class MediaGalleryViewModel {
|
|||
orderSQL: Item.galleryOrderSQL,
|
||||
dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL),
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
guard let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||
return
|
||||
}
|
||||
guard
|
||||
let currentData: [SectionModel] = self?.galleryData,
|
||||
let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo)
|
||||
else { return }
|
||||
|
||||
// If we have the 'onGalleryChange' callback then trigger it, otherwise just store the changes
|
||||
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||
// correct order)
|
||||
guard let onGalleryChange: (([SectionModel]) -> ()) = self?.onGalleryChange else {
|
||||
self?.unobservedGalleryDataChanges = updatedGalleryData
|
||||
return
|
||||
let changeset: StagedChangeset<[SectionModel]> = StagedChangeset(
|
||||
source: currentData,
|
||||
target: updatedGalleryData
|
||||
)
|
||||
|
||||
// No need to do anything if there were no changes
|
||||
guard !changeset.isEmpty else { return }
|
||||
|
||||
// Run any changes on the main thread (as they will generally trigger UI updates)
|
||||
DispatchQueue.main.async {
|
||||
// If we have the callback then trigger it, otherwise just store the changes to be sent
|
||||
// to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated
|
||||
// in the correct order)
|
||||
guard let onGalleryChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self?.onGalleryChange else {
|
||||
self?.unobservedGalleryDataChanges = (updatedGalleryData, changeset)
|
||||
return
|
||||
}
|
||||
|
||||
onGalleryChange(updatedGalleryData, changeset)
|
||||
}
|
||||
|
||||
onGalleryChange(updatedGalleryData)
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -128,11 +140,11 @@ public class MediaGalleryViewModel {
|
|||
// we don't want to mess with the initial view controller behaviour)
|
||||
guard !performInitialQuerySync else {
|
||||
loadInitialData()
|
||||
updateGalleryData(self.unobservedGalleryDataChanges ?? [])
|
||||
updateGalleryData(self.unobservedGalleryDataChanges?.0 ?? [])
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .default).async {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
loadInitialData()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -262,10 +262,12 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
case .loadNewer, .loadOlder:
|
||||
// Attachments are loaded in descending order so 'loadOlder' actually corresponds with
|
||||
// 'pageAfter' in this case
|
||||
self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ?
|
||||
.pageAfter :
|
||||
.pageBefore
|
||||
)
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ?
|
||||
.pageAfter :
|
||||
.pageBefore
|
||||
)
|
||||
}
|
||||
return
|
||||
|
||||
default: continue
|
||||
|
@ -276,8 +278,8 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
|
||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||
// Start observing for data changes (will callback on the main thread)
|
||||
self.viewModel.onGalleryChange = { [weak self] updatedGalleryData in
|
||||
self?.handleUpdates(updatedGalleryData)
|
||||
self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, changeset in
|
||||
self?.handleUpdates(updatedGalleryData, changeset: changeset)
|
||||
}
|
||||
|
||||
// Note: When returning from the background we could have received notifications but the
|
||||
|
@ -294,7 +296,10 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
self.viewModel.onGalleryChange = nil
|
||||
}
|
||||
|
||||
private func handleUpdates(_ updatedGalleryData: [MediaGalleryViewModel.SectionModel]) {
|
||||
private func handleUpdates(
|
||||
_ updatedGalleryData: [MediaGalleryViewModel.SectionModel],
|
||||
changeset: StagedChangeset<[MediaGalleryViewModel.SectionModel]>
|
||||
) {
|
||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialData else {
|
||||
|
@ -341,7 +346,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
self.mediaTileViewLayout.isInsertingCellsToTop = isInsertingAtTop
|
||||
self.mediaTileViewLayout.contentSizeBeforeInsertingToTop = self.collectionView.contentSize
|
||||
self.collectionView.reload(
|
||||
using: StagedChangeset(source: self.viewModel.galleryData, target: updatedGalleryData),
|
||||
using: changeset,
|
||||
interrupt: { $0.changeCount > MediaTileViewController.itemPageSize }
|
||||
) { [weak self] updatedData in
|
||||
self?.viewModel.updateGalleryData(updatedData)
|
||||
|
@ -456,10 +461,12 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
UIScrollView.fastEndScrollingThen(collectionView, self.currentTargetOffset) { [weak self] in
|
||||
// Attachments are loaded in descending order so 'loadOlder' actually corresponds with
|
||||
// 'pageAfter' in this case
|
||||
self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ?
|
||||
.pageAfter :
|
||||
.pageBefore
|
||||
)
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ?
|
||||
.pageAfter :
|
||||
.pageBefore
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case .emptyGallery, .galleryMonth: break
|
||||
|
|
|
@ -186,8 +186,8 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
// MARK: - Updating
|
||||
|
||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||
self.viewModel.onContactChange = { [weak self] updatedContactData in
|
||||
self?.handleContactUpdates(updatedContactData)
|
||||
self.viewModel.onContactChange = { [weak self] updatedContactData, changeset in
|
||||
self?.handleContactUpdates(updatedContactData, changeset: changeset)
|
||||
}
|
||||
|
||||
// Note: When returning from the background we could have received notifications but the
|
||||
|
@ -198,12 +198,18 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
}
|
||||
}
|
||||
|
||||
private func handleContactUpdates(_ updatedData: [BlockedContactsViewModel.SectionModel], initialLoad: Bool = false) {
|
||||
private func handleContactUpdates(
|
||||
_ updatedData: [BlockedContactsViewModel.SectionModel],
|
||||
changeset: StagedChangeset<[BlockedContactsViewModel.SectionModel]>,
|
||||
initialLoad: Bool = false
|
||||
) {
|
||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialContactData else {
|
||||
hasLoadedInitialContactData = true
|
||||
UIView.performWithoutAnimation { handleContactUpdates(updatedData, initialLoad: true) }
|
||||
UIView.performWithoutAnimation {
|
||||
handleContactUpdates(updatedData, changeset: changeset, initialLoad: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -225,7 +231,7 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
|
||||
// Reload the table content (animate changes after the first load)
|
||||
tableView.reload(
|
||||
using: StagedChangeset(source: viewModel.contactData, target: updatedData),
|
||||
using: changeset,
|
||||
deleteSectionsAnimation: .none,
|
||||
insertSectionsAnimation: .none,
|
||||
reloadSectionsAnimation: .none,
|
||||
|
@ -266,7 +272,7 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
|
||||
self?.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
}
|
||||
|
@ -351,7 +357,7 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
case .loadMore:
|
||||
self.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
|
||||
|
|
|
@ -62,25 +62,37 @@ public class BlockedContactsViewModel {
|
|||
orderSQL: DataModel.orderSQL
|
||||
),
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
guard let updatedContactData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||
return
|
||||
}
|
||||
guard
|
||||
let currentData: [SectionModel] = self?.contactData,
|
||||
let updatedContactData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo)
|
||||
else { return }
|
||||
|
||||
// If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes
|
||||
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||
// correct order)
|
||||
guard let onContactChange: (([SectionModel]) -> ()) = self?.onContactChange else {
|
||||
self?.unobservedContactDataChanges = updatedContactData
|
||||
return
|
||||
let changeset: StagedChangeset<[SectionModel]> = StagedChangeset(
|
||||
source: currentData,
|
||||
target: updatedContactData
|
||||
)
|
||||
|
||||
// No need to do anything if there were no changes
|
||||
guard !changeset.isEmpty else { return }
|
||||
|
||||
// Run any changes on the main thread (as they will generally trigger UI updates)
|
||||
DispatchQueue.main.async {
|
||||
// If we have the callback then trigger it, otherwise just store the changes to be sent
|
||||
// to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated
|
||||
// in the correct order)
|
||||
guard let onContactChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = self?.onContactChange else {
|
||||
self?.unobservedContactDataChanges = (updatedContactData, changeset)
|
||||
return
|
||||
}
|
||||
|
||||
onContactChange(updatedContactData, changeset)
|
||||
}
|
||||
|
||||
onContactChange(updatedContactData)
|
||||
}
|
||||
)
|
||||
|
||||
// Run the initial query on a background thread so we don't block the push transition
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
// The `.pageBefore` will query from a `0` offset loading the first page
|
||||
self?.pagedDataObserver?.load(.pageBefore)
|
||||
}
|
||||
|
@ -89,16 +101,16 @@ public class BlockedContactsViewModel {
|
|||
// MARK: - Contact Data
|
||||
|
||||
public private(set) var selectedContactIds: Set<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 pagedDataObserver: PagedDatabaseObserver<Profile, DataModel>?
|
||||
|
||||
public var onContactChange: (([SectionModel]) -> ())? {
|
||||
public var onContactChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
|
||||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedContactDataChanges: [SectionModel] = self.unobservedContactDataChanges {
|
||||
onContactChange?(unobservedContactDataChanges)
|
||||
if let unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedContactDataChanges {
|
||||
onContactChange?(unobservedContactDataChanges.0 , unobservedContactDataChanges.1)
|
||||
self.unobservedContactDataChanges = nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ class BlockedContactCell: UITableViewCell {
|
|||
|
||||
// Highlight color
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.themeBackgroundColor = .conversationButton_highlight
|
||||
selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background)
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
|
||||
// Add the UI
|
||||
|
|
|
@ -16,7 +16,7 @@ class ThemeSelectionView: UIView {
|
|||
let result: UIButton = UIButton()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setThemeBackgroundColor(.appearance_buttonBackground, for: .normal)
|
||||
result.setThemeBackgroundColor(.appearance_buttonHighlight, for: .highlighted)
|
||||
result.setThemeBackgroundColor(.highlighted(.appearance_buttonBackground), for: .highlighted)
|
||||
result.addTarget(self, action: #selector(itemSelected), for: .touchUpInside)
|
||||
|
||||
return result
|
||||
|
|
|
@ -148,7 +148,7 @@ public final class FullConversationCell: UITableViewCell {
|
|||
|
||||
// Highlight color
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.themeBackgroundColor = .conversationButton_highlight
|
||||
selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background)
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
|
||||
// Accent line view
|
||||
|
@ -340,14 +340,12 @@ public final class FullConversationCell: UITableViewCell {
|
|||
|
||||
public func update(with cellViewModel: SessionThreadViewModel) {
|
||||
let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0)
|
||||
themeBackgroundColor = (unreadCount > 0 ?
|
||||
let themeBackgroundColor: ThemeValue = (unreadCount > 0 ?
|
||||
.conversationButton_unreadBackground :
|
||||
.conversationButton_background
|
||||
)
|
||||
self.selectedBackgroundView?.themeBackgroundColor = (unreadCount > 0 ?
|
||||
.conversationButton_unreadHighlight :
|
||||
.conversationButton_highlight
|
||||
)
|
||||
self.themeBackgroundColor = themeBackgroundColor
|
||||
self.selectedBackgroundView?.themeBackgroundColor = .highlighted(themeBackgroundColor)
|
||||
|
||||
if cellViewModel.threadIsBlocked == true {
|
||||
accentLineView.themeBackgroundColor = .danger
|
||||
|
|
|
@ -30,7 +30,7 @@ public class SessionCell: UITableViewCell {
|
|||
private var botSeparatorLeftConstraint: NSLayoutConstraint = NSLayoutConstraint()
|
||||
private var botSeparatorRightConstraint: NSLayoutConstraint = NSLayoutConstraint()
|
||||
private lazy var leftAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: leftAccessoryView)
|
||||
private lazy var rightAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: rightAccessoryView)// .heightAnchor.constraint(equalTo: iconImageView.heightAnchor)
|
||||
private lazy var rightAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: rightAccessoryView)
|
||||
|
||||
private let cellBackgroundView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
|
@ -44,7 +44,7 @@ public class SessionCell: UITableViewCell {
|
|||
private let cellSelectedBackgroundView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.themeBackgroundColor = .settings_tabHighlight
|
||||
result.themeBackgroundColor = .highlighted(.settings_tabBackground)
|
||||
result.alpha = 0
|
||||
|
||||
return result
|
||||
|
|
|
@ -55,7 +55,7 @@ public class SessionHighlightingBackgroundLabel: UIView {
|
|||
|
||||
func setHighlighted(_ highlighted: Bool, animated: Bool) {
|
||||
self.themeBackgroundColor = (highlighted ?
|
||||
.solidButton_highlight :
|
||||
.highlighted(.solidButton_background) :
|
||||
.solidButton_background
|
||||
)
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ public class SessionHighlightingBackgroundLabel: UIView {
|
|||
// need to swap back into the "highlighted" state so we can properly unhighlight within
|
||||
// the "deselect" animation
|
||||
guard !selected else {
|
||||
self.themeBackgroundColor = .solidButton_highlight
|
||||
self.themeBackgroundColor = .highlighted(.solidButton_background)
|
||||
return
|
||||
}
|
||||
guard animated else {
|
||||
|
|
|
@ -94,10 +94,12 @@ public final class BackgroundPoller {
|
|||
guard let snode = swarm.randomElement() else { throw SnodeAPIError.generic }
|
||||
|
||||
return SnodeAPI.getMessages(from: snode, associatedWith: publicKey)
|
||||
.then(on: DispatchQueue.main) { messages -> Promise<Void> in
|
||||
.then(on: DispatchQueue.main) { messages, lastHash -> Promise<Void> in
|
||||
guard !messages.isEmpty, BackgroundPoller.isValid else { return Promise.value(()) }
|
||||
|
||||
var jobsToRun: [Job] = []
|
||||
var messageCount: Int = 0
|
||||
var hadValidHashUpdate: Bool = false
|
||||
|
||||
Storage.shared.write { db in
|
||||
messages
|
||||
|
@ -115,6 +117,10 @@ public final class BackgroundPoller {
|
|||
MessageReceiverError.duplicateControlMessage,
|
||||
MessageReceiverError.selfSend:
|
||||
break
|
||||
|
||||
case MessageReceiverError.duplicateMessageNewSnode:
|
||||
hadValidHashUpdate = true
|
||||
break
|
||||
|
||||
// In the background ignore 'SQLITE_ABORT' (it generally means
|
||||
// the BackgroundPoller has timed out
|
||||
|
@ -128,6 +134,8 @@ public final class BackgroundPoller {
|
|||
}
|
||||
.grouped { threadId, _, _ in (threadId ?? Message.nonThreadMessageId) }
|
||||
.forEach { threadId, threadMessages in
|
||||
messageCount += threadMessages.count
|
||||
|
||||
let maybeJob: Job? = Job(
|
||||
variant: .messageReceive,
|
||||
behaviour: .runOnce,
|
||||
|
@ -145,6 +153,15 @@ public final class BackgroundPoller {
|
|||
JobRunner.add(db, job: job, canStartJob: false)
|
||||
jobsToRun.append(job)
|
||||
}
|
||||
|
||||
if messageCount == 0 && !hadValidHashUpdate, let lastHash: String = lastHash {
|
||||
// Update the cached validity of the messages
|
||||
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
|
||||
db,
|
||||
potentiallyInvalidHashes: [lastHash],
|
||||
otherKnownValidHashes: messages.map { $0.info.hash }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let promises: [Promise<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
|
||||
// the database before we inserted it for this node then we can ignore this message as a
|
||||
// duplicate
|
||||
guard numExistingHashes == 0 else { throw MessageReceiverError.duplicateMessage }
|
||||
guard numExistingHashes == 0 else { throw MessageReceiverError.duplicateMessageNewSnode }
|
||||
|
||||
return processedMessage
|
||||
}
|
||||
|
|
|
@ -4,6 +4,5 @@ FOUNDATION_EXPORT double SessionMessagingKitVersionNumber;
|
|||
FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[];
|
||||
|
||||
#import <SessionMessagingKit/AppReadiness.h>
|
||||
#import <SessionMessagingKit/NSData+messagePadding.h>
|
||||
#import <SessionMessagingKit/OWSAudioPlayer.h>
|
||||
#import <SessionMessagingKit/OWSWindowManager.h>
|
||||
|
|
|
@ -4,6 +4,7 @@ import Foundation
|
|||
|
||||
public enum MessageReceiverError: LocalizedError {
|
||||
case duplicateMessage
|
||||
case duplicateMessageNewSnode
|
||||
case duplicateControlMessage
|
||||
case invalidMessage
|
||||
case unknownMessage
|
||||
|
@ -21,8 +22,9 @@ public enum MessageReceiverError: LocalizedError {
|
|||
|
||||
public var isRetryable: Bool {
|
||||
switch self {
|
||||
case .duplicateMessage, .duplicateControlMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType,
|
||||
.invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed:
|
||||
case .duplicateMessage, .duplicateMessageNewSnode, .duplicateControlMessage,
|
||||
.invalidMessage, .unknownMessage, .unknownEnvelopeType, .invalidSignature,
|
||||
.noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed:
|
||||
return false
|
||||
|
||||
default: return true
|
||||
|
@ -32,6 +34,7 @@ public enum MessageReceiverError: LocalizedError {
|
|||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .duplicateMessage: return "Duplicate message."
|
||||
case .duplicateMessageNewSnode: return "Duplicate message from different service node."
|
||||
case .duplicateControlMessage: return "Duplicate control message."
|
||||
case .invalidMessage: return "Invalid message."
|
||||
case .unknownMessage: return "Unknown message type."
|
||||
|
|
|
@ -51,6 +51,13 @@ extension MessageReceiver {
|
|||
|
||||
_ = try interaction.attachments
|
||||
.deleteAll(db)
|
||||
|
||||
if let serverHash: String = interaction.serverHash {
|
||||
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
|
||||
db,
|
||||
potentiallyInvalidHashes: [serverHash]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -118,7 +118,7 @@ public enum MessageReceiver {
|
|||
let proto: SNProtoContent
|
||||
|
||||
do {
|
||||
proto = try SNProtoContent.parseData((plaintext as NSData).removePadding())
|
||||
proto = try SNProtoContent.parseData(plaintext.removePadding())
|
||||
}
|
||||
catch {
|
||||
SNLog("Couldn't parse proto due to error: \(error).")
|
||||
|
|
|
@ -134,7 +134,7 @@ public final class MessageSender {
|
|||
let plaintext: Data
|
||||
|
||||
do {
|
||||
plaintext = (try proto.serializedData() as NSData).paddedMessageBody()
|
||||
plaintext = try proto.serializedData().paddedMessageBody()
|
||||
}
|
||||
catch {
|
||||
SNLog("Couldn't serialize proto due to error: \(error).")
|
||||
|
@ -411,7 +411,7 @@ public final class MessageSender {
|
|||
let plaintext: Data
|
||||
|
||||
do {
|
||||
plaintext = (try proto.serializedData() as NSData).paddedMessageBody()
|
||||
plaintext = try proto.serializedData().paddedMessageBody()
|
||||
}
|
||||
catch {
|
||||
SNLog("Couldn't serialize proto due to error: \(error).")
|
||||
|
@ -510,7 +510,7 @@ public final class MessageSender {
|
|||
let plaintext: Data
|
||||
|
||||
do {
|
||||
plaintext = (try proto.serializedData() as NSData).paddedMessageBody()
|
||||
plaintext = try proto.serializedData().paddedMessageBody()
|
||||
}
|
||||
catch {
|
||||
SNLog("Couldn't serialize proto due to error: \(error).")
|
||||
|
|
|
@ -160,7 +160,7 @@ public final class ClosedGroupPoller {
|
|||
poller?.isPolling.wrappedValue[groupPublicKey] == true
|
||||
else { return Promise(error: Error.pollingCanceled) }
|
||||
|
||||
let promises: [Promise<[SnodeReceivedMessage]>] = {
|
||||
let promises: [Promise<([SnodeReceivedMessage], String?)>] = {
|
||||
if SnodeAPI.hardfork >= 19 && SnodeAPI.softfork >= 1 {
|
||||
return [ SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey, authenticated: false) ]
|
||||
}
|
||||
|
@ -187,11 +187,20 @@ public final class ClosedGroupPoller {
|
|||
let allMessages: [SnodeReceivedMessage] = messageResults
|
||||
.reduce([]) { result, next in
|
||||
switch next {
|
||||
case .fulfilled(let messages): return result.appending(contentsOf: messages)
|
||||
case .fulfilled(let data): return result.appending(contentsOf: data.0)
|
||||
default: return result
|
||||
}
|
||||
}
|
||||
let allHashes: [String] = messageResults
|
||||
.reduce([]) { result, next in
|
||||
switch next {
|
||||
case .fulfilled(let data): return result.appending(data.1)
|
||||
default: return result
|
||||
}
|
||||
}
|
||||
.compactMap { $0 }
|
||||
var messageCount: Int = 0
|
||||
var hadValidHashUpdate: Bool = false
|
||||
|
||||
// No need to do anything if there are no messages
|
||||
guard !allMessages.isEmpty else {
|
||||
|
@ -218,6 +227,10 @@ public final class ClosedGroupPoller {
|
|||
MessageReceiverError.selfSend:
|
||||
break
|
||||
|
||||
case MessageReceiverError.duplicateMessageNewSnode:
|
||||
hadValidHashUpdate = true
|
||||
break
|
||||
|
||||
// In the background ignore 'SQLITE_ABORT' (it generally means
|
||||
// the BackgroundPoller has timed out
|
||||
case DatabaseError.SQLITE_ABORT:
|
||||
|
@ -248,6 +261,17 @@ public final class ClosedGroupPoller {
|
|||
// If we are force-polling then add to the JobRunner so they are persistent and will retry on
|
||||
// the next app run if they fail but don't let them auto-start
|
||||
JobRunner.add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller)
|
||||
|
||||
if messageCount == 0 && !hadValidHashUpdate, !allHashes.isEmpty {
|
||||
SNLog("Received \(allMessages.count) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey), all duplicates - marking the hashes we polled with as invalid")
|
||||
|
||||
// Update the cached validity of the messages
|
||||
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
|
||||
db,
|
||||
potentiallyInvalidHashes: allHashes,
|
||||
otherKnownValidHashes: allMessages.map { $0.info.hash }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if calledFromBackgroundPoller {
|
||||
|
@ -269,7 +293,7 @@ public final class ClosedGroupPoller {
|
|||
}
|
||||
)
|
||||
}
|
||||
else {
|
||||
else if messageCount > 0 || hadValidHashUpdate {
|
||||
SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (duplicates: \(allMessages.count - messageCount))")
|
||||
}
|
||||
|
||||
|
|
|
@ -129,11 +129,12 @@ public final class Poller {
|
|||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
||||
|
||||
return SnodeAPI.getMessages(from: snode, associatedWith: userPublicKey)
|
||||
.then(on: Threading.pollerQueue) { [weak self] messages -> Promise<Void> in
|
||||
.then(on: Threading.pollerQueue) { [weak self] messages, lastHash -> Promise<Void> in
|
||||
guard self?.isPolling.wrappedValue == true else { return Promise { $0.fulfill(()) } }
|
||||
|
||||
if !messages.isEmpty {
|
||||
var messageCount: Int = 0
|
||||
var hadValidHashUpdate: Bool = false
|
||||
|
||||
Storage.shared.write { db in
|
||||
messages
|
||||
|
@ -151,6 +152,10 @@ public final class Poller {
|
|||
MessageReceiverError.selfSend:
|
||||
break
|
||||
|
||||
case MessageReceiverError.duplicateMessageNewSnode:
|
||||
hadValidHashUpdate = true
|
||||
break
|
||||
|
||||
case DatabaseError.SQLITE_ABORT:
|
||||
SNLog("Failed to the database being suspended (running in background with no background task).")
|
||||
break
|
||||
|
@ -178,9 +183,21 @@ public final class Poller {
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
if messageCount == 0 && !hadValidHashUpdate, let lastHash: String = lastHash {
|
||||
SNLog("Received \(messages.count) new message\(messages.count == 1 ? "" : "s"), all duplicates - marking the hash we polled with as invalid")
|
||||
|
||||
// Update the cached validity of the messages
|
||||
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
|
||||
db,
|
||||
potentiallyInvalidHashes: [lastHash],
|
||||
otherKnownValidHashes: messages.map { $0.info.hash }
|
||||
)
|
||||
}
|
||||
else {
|
||||
SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (duplicates: \(messages.count - messageCount))")
|
||||
}
|
||||
}
|
||||
|
||||
SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (duplicates: \(messages.count - messageCount))")
|
||||
}
|
||||
else {
|
||||
SNLog("Received no new messages")
|
||||
|
|
|
@ -21,4 +21,51 @@ public extension Data {
|
|||
throw HTTP.Error.parsingFailed
|
||||
}
|
||||
}
|
||||
|
||||
func removePadding() -> Data {
|
||||
let bytes: [UInt8] = self.bytes
|
||||
var paddingStart: Int = self.count
|
||||
|
||||
for i in 0..<(self.count - 1) {
|
||||
let targetIndex: Int = ((self.count - 1) - i)
|
||||
|
||||
if bytes[targetIndex] == 0x80 {
|
||||
paddingStart = targetIndex
|
||||
break
|
||||
}
|
||||
else if bytes[targetIndex] != 0x00 {
|
||||
SNLog("Failed to remove padding, returning unstripped padding");
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
return self.prefix(upTo: paddingStart)
|
||||
}
|
||||
|
||||
func paddedMessageBody() -> Data {
|
||||
// From
|
||||
// https://github.com/signalapp/TextSecure/blob/master/libtextsecure/src/main/java/org/whispersystems/textsecure/internal/push/PushTransportDetails.java#L55
|
||||
// NOTE: This is dumb. We have our own padding scheme, but so does the cipher.
|
||||
// The +1 -1 here is to make sure the Cipher has room to add one padding byte,
|
||||
// otherwise it'll add a full 16 extra bytes.
|
||||
let paddedMessageLength: Int = (self.paddedMessageLength(self.count + 1) - 1)
|
||||
var paddedMessage: Data = Data(count: paddedMessageLength)
|
||||
|
||||
let paddingByte: UInt8 = 0x80
|
||||
paddedMessage[0..<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
|
||||
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.themeBackgroundColor = .conversationButton_highlight
|
||||
selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background)
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
|
||||
addSubview(stackView)
|
||||
|
|
|
@ -14,6 +14,9 @@ public enum SNSnodeKit { // Just to make the external API nice
|
|||
],
|
||||
[
|
||||
_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 hash
|
||||
case expirationDateMs
|
||||
case wasDeletedOrInvalid
|
||||
}
|
||||
|
||||
/// The `id` value is auto incremented by the database, if the `Job` hasn't been inserted into
|
||||
|
@ -33,6 +34,14 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist
|
|||
/// 14 days)
|
||||
public let expirationDateMs: Int64
|
||||
|
||||
/// This flag indicates whether the interaction associated with this message hash was deleted or whether this message
|
||||
/// hash is potentially invalid (if a poll results in 100% of the `SnodeReceivedMessageInfo` entries being seen as
|
||||
/// duplicates then we assume that the `lastHash` value provided when retrieving messages was invalid and mark
|
||||
/// it as such)
|
||||
///
|
||||
/// **Note:** When retrieving the `lastNotExpired` we will ignore any entries where this flag is true
|
||||
public var wasDeletedOrInvalid: Bool?
|
||||
|
||||
// MARK: - Custom Database Interaction
|
||||
|
||||
public mutating func didInsert(with rowID: Int64, for column: String?) {
|
||||
|
@ -108,6 +117,10 @@ public extension SnodeReceivedMessageInfo {
|
|||
static func fetchLastNotExpired(for snode: Snode, namespace: Int, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? {
|
||||
return Storage.shared.read { db in
|
||||
let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo
|
||||
.filter(
|
||||
SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == nil ||
|
||||
SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == false
|
||||
)
|
||||
.filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace))
|
||||
.filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > (Date().timeIntervalSince1970 * 1000))
|
||||
.order(SnodeReceivedMessageInfo.Columns.id.desc)
|
||||
|
@ -118,9 +131,44 @@ public extension SnodeReceivedMessageInfo {
|
|||
if nonLegacyHash != nil { return nonLegacyHash }
|
||||
|
||||
return try SnodeReceivedMessageInfo
|
||||
.filter(
|
||||
SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == nil ||
|
||||
SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == false
|
||||
)
|
||||
.filter(SnodeReceivedMessageInfo.Columns.key == publicKey)
|
||||
.order(SnodeReceivedMessageInfo.Columns.id.desc)
|
||||
.fetchOne(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// There are some cases where the latest message can be removed from a swarm, if we then try to poll for that message the swarm
|
||||
/// will see it as invalid and start returning messages from the beginning which can result in a lot of wasted, duplicate downloads
|
||||
///
|
||||
/// This method should be called when deleting a message, handling an UnsendRequest or when receiving a poll response which contains
|
||||
/// solely duplicate messages (for the specific service node - if even one message in a response is new for that service node then this shouldn't
|
||||
/// be called if if the message has already been received and processed by a separate service node)
|
||||
static func handlePotentialDeletedOrInvalidHash(
|
||||
_ db: Database,
|
||||
potentiallyInvalidHashes: [String],
|
||||
otherKnownValidHashes: [String] = []
|
||||
) throws {
|
||||
_ = try SnodeReceivedMessageInfo
|
||||
.filter(potentiallyInvalidHashes.contains(SnodeReceivedMessageInfo.Columns.hash))
|
||||
.updateAll(
|
||||
db,
|
||||
SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid.set(to: true)
|
||||
)
|
||||
|
||||
// If we have any server hashes which we know are valid (eg. we fetched the oldest messages) then
|
||||
// mark them all as valid to prevent the case where we just slowly work backwards from the latest
|
||||
// message, polling for one earlier each time
|
||||
guard !otherKnownValidHashes.isEmpty else { return }
|
||||
|
||||
_ = try SnodeReceivedMessageInfo
|
||||
.filter(otherKnownValidHashes.contains(SnodeReceivedMessageInfo.Columns.hash))
|
||||
.updateAll(
|
||||
db,
|
||||
SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid.set(to: false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -489,8 +489,8 @@ public final class SnodeAPI {
|
|||
// MARK: - Retrieve
|
||||
|
||||
// Not in use until we can batch delete and store config messages
|
||||
public static func getConfigMessages(from snode: Snode, associatedWith publicKey: String) -> Promise<[SnodeReceivedMessage]> {
|
||||
let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending()
|
||||
public static func getConfigMessages(from snode: Snode, associatedWith publicKey: String) -> Promise<([SnodeReceivedMessage], String?)> {
|
||||
let (promise, seal) = Promise<([SnodeReceivedMessage], String?)>.pending()
|
||||
|
||||
Threading.workQueue.async {
|
||||
getMessagesWithAuthentication(from: snode, associatedWith: publicKey, namespace: configNamespace)
|
||||
|
@ -505,8 +505,8 @@ public final class SnodeAPI {
|
|||
return promise
|
||||
}
|
||||
|
||||
public static func getMessages(from snode: Snode, associatedWith publicKey: String, authenticated: Bool = true) -> Promise<[SnodeReceivedMessage]> {
|
||||
let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending()
|
||||
public static func getMessages(from snode: Snode, associatedWith publicKey: String, authenticated: Bool = true) -> Promise<([SnodeReceivedMessage], String?)> {
|
||||
let (promise, seal) = Promise<([SnodeReceivedMessage], String?)>.pending()
|
||||
|
||||
Threading.workQueue.async {
|
||||
let retrievePromise = (authenticated ?
|
||||
|
@ -522,8 +522,8 @@ public final class SnodeAPI {
|
|||
return promise
|
||||
}
|
||||
|
||||
public static func getClosedGroupMessagesFromDefaultNamespace(from snode: Snode, associatedWith publicKey: String) -> Promise<[SnodeReceivedMessage]> {
|
||||
let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending()
|
||||
public static func getClosedGroupMessagesFromDefaultNamespace(from snode: Snode, associatedWith publicKey: String) -> Promise<([SnodeReceivedMessage], String?)> {
|
||||
let (promise, seal) = Promise<([SnodeReceivedMessage], String?)>.pending()
|
||||
|
||||
Threading.workQueue.async {
|
||||
getMessagesUnauthenticated(from: snode, associatedWith: publicKey, namespace: defaultNamespace)
|
||||
|
@ -534,7 +534,7 @@ public final class SnodeAPI {
|
|||
return promise
|
||||
}
|
||||
|
||||
private static func getMessagesWithAuthentication(from snode: Snode, associatedWith publicKey: String, namespace: Int) -> Promise<[SnodeReceivedMessage]> {
|
||||
private static func getMessagesWithAuthentication(from snode: Snode, associatedWith publicKey: String, namespace: Int) -> Promise<([SnodeReceivedMessage], String?)> {
|
||||
/// **Note:** All authentication logic is only apply to 1-1 chats, the reason being that we can't currently support it yet for
|
||||
/// closed groups. The Storage Server requires an ed25519 key pair, but we don't have that for our closed groups.
|
||||
guard let userED25519KeyPair: Box.KeyPair = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else {
|
||||
|
@ -584,13 +584,14 @@ public final class SnodeAPI {
|
|||
)
|
||||
}
|
||||
}
|
||||
.map { ($0, lastHash) }
|
||||
}
|
||||
|
||||
private static func getMessagesUnauthenticated(
|
||||
from snode: Snode,
|
||||
associatedWith publicKey: String,
|
||||
namespace: Int = closedGroupNamespace
|
||||
) -> Promise<[SnodeReceivedMessage]> {
|
||||
) -> Promise<([SnodeReceivedMessage], String?)> {
|
||||
// Get last message hash
|
||||
SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo(for: snode, namespace: namespace, associatedWith: publicKey)
|
||||
let lastHash = SnodeReceivedMessageInfo.fetchLastNotExpired(for: snode, namespace: namespace, associatedWith: publicKey)?.hash ?? ""
|
||||
|
@ -598,7 +599,7 @@ public final class SnodeAPI {
|
|||
// Make the request
|
||||
var parameters: JSON = [
|
||||
"pubKey": (Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey),
|
||||
"lastHash": lastHash,
|
||||
"lastHash": lastHash
|
||||
]
|
||||
|
||||
// Don't include namespace if polling for 0 with no authentication
|
||||
|
@ -625,6 +626,7 @@ public final class SnodeAPI {
|
|||
)
|
||||
}
|
||||
}
|
||||
.map { ($0, lastHash) }
|
||||
}
|
||||
|
||||
// MARK: Store
|
||||
|
@ -895,6 +897,17 @@ public final class SnodeAPI {
|
|||
}
|
||||
}
|
||||
|
||||
// If we get to here then we assume it's been deleted from at least one
|
||||
// service node and as a result we need to mark the hash as invalid so
|
||||
// we don't try to fetch updates since that hash going forward (if we do
|
||||
// we would end up re-fetching all old messages)
|
||||
Storage.shared.writeAsync { db in
|
||||
try? SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
|
||||
db,
|
||||
potentiallyInvalidHashes: serverHashes
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
|
@ -121,7 +121,7 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate {
|
|||
result.setTitle(title, for: .normal)
|
||||
result.setThemeTitleColor(titleColor, for: .normal)
|
||||
result.setThemeBackgroundColor(.alert_buttonBackground, for: .normal)
|
||||
result.setThemeBackgroundColor(.alert_buttonHighlight, for: .highlighted)
|
||||
result.setThemeBackgroundColor(.highlighted(.alert_buttonBackground), for: .highlighted)
|
||||
result.set(.height, to: Values.alertButtonHeight)
|
||||
|
||||
return result
|
||||
|
|
|
@ -62,28 +62,22 @@ internal enum Theme_ClassicDark: ThemeColors {
|
|||
|
||||
// SolidButton
|
||||
.solidButton_background: .classicDark3,
|
||||
.solidButton_highlight: .classicDark4,
|
||||
|
||||
// Settings
|
||||
.settings_tabBackground: .classicDark1,
|
||||
.settings_tabHighlight: .classicDark3,
|
||||
|
||||
// Appearance
|
||||
.appearance_sectionBackground: .classicDark1,
|
||||
.appearance_buttonBackground: .classicDark1,
|
||||
.appearance_buttonHighlight: .classicDark3,
|
||||
|
||||
// Alert
|
||||
.alert_text: .classicDark6,
|
||||
.alert_background: .classicDark1,
|
||||
.alert_buttonBackground: .classicDark1,
|
||||
.alert_buttonHighlight: .classicDark3,
|
||||
|
||||
// ConversationButton
|
||||
.conversationButton_background: .classicDark1,
|
||||
.conversationButton_highlight: .classicDark3,
|
||||
.conversationButton_unreadBackground: .classicDark2,
|
||||
.conversationButton_unreadHighlight: .classicDark3,
|
||||
.conversationButton_unreadStripBackground: .primary,
|
||||
.conversationButton_unreadBubbleBackground: .classicDark3,
|
||||
.conversationButton_unreadBubbleText: .classicDark6,
|
||||
|
|
|
@ -62,28 +62,22 @@ internal enum Theme_ClassicLight: ThemeColors {
|
|||
|
||||
// SolidButton
|
||||
.solidButton_background: .classicLight3,
|
||||
.solidButton_highlight: .classicLight4,
|
||||
|
||||
// Settings
|
||||
.settings_tabBackground: .classicLight5,
|
||||
.settings_tabHighlight: .classicLight3,
|
||||
|
||||
// AppearanceButton
|
||||
.appearance_sectionBackground: .classicLight6,
|
||||
.appearance_buttonBackground: .classicLight6,
|
||||
.appearance_buttonHighlight: .classicLight4,
|
||||
|
||||
// Alert
|
||||
.alert_text: .classicLight0,
|
||||
.alert_background: .classicLight6,
|
||||
.alert_buttonBackground: .classicLight6,
|
||||
.alert_buttonHighlight: .classicLight4,
|
||||
|
||||
// ConversationButton
|
||||
.conversationButton_background: .classicLight6,
|
||||
.conversationButton_highlight: .classicLight4,
|
||||
.conversationButton_unreadBackground: .classicLight6,
|
||||
.conversationButton_unreadHighlight: .classicLight4,
|
||||
.conversationButton_unreadStripBackground: .primary,
|
||||
.conversationButton_unreadBubbleBackground: .classicLight3,
|
||||
.conversationButton_unreadBubbleText: .classicLight0,
|
||||
|
|
|
@ -62,28 +62,22 @@ internal enum Theme_OceanDark: ThemeColors {
|
|||
|
||||
// SolidButton
|
||||
.solidButton_background: .oceanDark2,
|
||||
.solidButton_highlight: .oceanDark4,
|
||||
|
||||
// Settings
|
||||
.settings_tabBackground: .oceanDark1,
|
||||
.settings_tabHighlight: .oceanDark3,
|
||||
|
||||
// Appearance
|
||||
.appearance_sectionBackground: .oceanDark3,
|
||||
.appearance_buttonBackground: .oceanDark3,
|
||||
.appearance_buttonHighlight: .oceanDark4,
|
||||
|
||||
// Alert
|
||||
.alert_text: .oceanDark7,
|
||||
.alert_background: .oceanDark3,
|
||||
.alert_buttonBackground: .oceanDark3,
|
||||
.alert_buttonHighlight: .oceanDark4,
|
||||
|
||||
// ConversationButton
|
||||
.conversationButton_background: .oceanDark3,
|
||||
.conversationButton_highlight: .oceanDark4,
|
||||
.conversationButton_unreadBackground: .oceanDark2,
|
||||
.conversationButton_unreadHighlight: .oceanDark4,
|
||||
.conversationButton_unreadBackground: .oceanDark4,
|
||||
.conversationButton_unreadStripBackground: .primary,
|
||||
.conversationButton_unreadBubbleBackground: .primary,
|
||||
.conversationButton_unreadBubbleText: .oceanDark0,
|
||||
|
|
|
@ -62,28 +62,22 @@ internal enum Theme_OceanLight: ThemeColors {
|
|||
|
||||
// SolidButton
|
||||
.solidButton_background: .oceanLight5,
|
||||
.solidButton_highlight: .oceanLight6,
|
||||
|
||||
// Settings
|
||||
.settings_tabBackground: .oceanLight6,
|
||||
.settings_tabHighlight: .oceanLight5,
|
||||
|
||||
// Appearance
|
||||
.appearance_sectionBackground: .oceanLight7,
|
||||
.appearance_buttonBackground: .oceanLight7,
|
||||
.appearance_buttonHighlight: .oceanLight5,
|
||||
|
||||
// Alert
|
||||
.alert_text: .oceanLight0,
|
||||
.alert_background: .oceanLight7,
|
||||
.alert_buttonBackground: .oceanLight7,
|
||||
.alert_buttonHighlight: .oceanLight5,
|
||||
|
||||
// ConversationButton
|
||||
.conversationButton_background: .oceanLight7,
|
||||
.conversationButton_highlight: .oceanLight5,
|
||||
.conversationButton_unreadBackground: .oceanLight6,
|
||||
.conversationButton_unreadHighlight: .oceanLight5,
|
||||
.conversationButton_unreadStripBackground: .primary,
|
||||
.conversationButton_unreadBubbleBackground: .primary,
|
||||
.conversationButton_unreadBubbleText: .oceanLight1,
|
||||
|
|
|
@ -55,6 +55,13 @@ public enum Theme: String, CaseIterable, Codable, EnumStringSetting {
|
|||
public func color(for value: ThemeValue) -> UIColor? {
|
||||
switch value {
|
||||
case .value(let value, let alpha): return color(for: value)?.withAlphaComponent(alpha)
|
||||
|
||||
case .highlighted(let value, let alwaysDarken):
|
||||
switch (self.interfaceStyle, alwaysDarken) {
|
||||
case (.light, _), (_, true): return color(for: value)?.brighten(by: -0.06)
|
||||
default: return color(for: value)?.brighten(by: 0.08)
|
||||
}
|
||||
|
||||
default: return colors[value]
|
||||
}
|
||||
}
|
||||
|
@ -77,6 +84,14 @@ public protocol ThemedNavigation {
|
|||
public indirect enum ThemeValue: Hashable {
|
||||
case value(ThemeValue, alpha: CGFloat)
|
||||
|
||||
// The 'highlighted' state of a colour will automatically lighten/darken a ThemeValue
|
||||
// by a fixed amount depending on wither the theme is dark/light mode
|
||||
case highlighted(ThemeValue, alwaysDarken: Bool)
|
||||
|
||||
public static func highlighted(_ value: ThemeValue) -> ThemeValue {
|
||||
return .highlighted(value, alwaysDarken: false)
|
||||
}
|
||||
|
||||
// General
|
||||
case white
|
||||
case black
|
||||
|
@ -135,28 +150,22 @@ public indirect enum ThemeValue: Hashable {
|
|||
|
||||
// SolidButton
|
||||
case solidButton_background
|
||||
case solidButton_highlight
|
||||
|
||||
// Settings
|
||||
case settings_tabBackground
|
||||
case settings_tabHighlight
|
||||
|
||||
// Appearance
|
||||
case appearance_sectionBackground
|
||||
case appearance_buttonBackground
|
||||
case appearance_buttonHighlight
|
||||
|
||||
// Alert
|
||||
case alert_text
|
||||
case alert_background
|
||||
case alert_buttonBackground
|
||||
case alert_buttonHighlight
|
||||
|
||||
// ConversationButton
|
||||
case conversationButton_background
|
||||
case conversationButton_highlight
|
||||
case conversationButton_unreadBackground
|
||||
case conversationButton_unreadHighlight
|
||||
case conversationButton_unreadStripBackground
|
||||
case conversationButton_unreadBubbleBackground
|
||||
case conversationButton_unreadBubbleText
|
||||
|
|
|
@ -36,5 +36,29 @@ public extension UIColor {
|
|||
alpha: CGFloatLerp(a0, a1, finalAlpha)
|
||||
)
|
||||
}
|
||||
|
||||
func brighten(by percentage: CGFloat) -> UIColor {
|
||||
guard percentage != 0 else { return self }
|
||||
|
||||
var hue: CGFloat = 0
|
||||
var saturation: CGFloat = 0
|
||||
var brightness: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
|
||||
// Note: Looks like as of iOS 10 devices use the kCGColorSpaceExtendedGray color
|
||||
// space for grayscale colors which seems to be compatible with the RGB color space
|
||||
// meaning we don't need to check 'getWhite:alpha:' if the below method fails, for
|
||||
// more info see: https://developer.apple.com/documentation/uikit/uicolor#overview
|
||||
guard self.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) else {
|
||||
return self
|
||||
}
|
||||
|
||||
return UIColor(
|
||||
hue: hue,
|
||||
saturation: saturation,
|
||||
brightness: (brightness + percentage),
|
||||
alpha: alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -198,10 +198,7 @@ public class PagedDatabaseObserver<ObservedTable, T>: TransactionObserver where
|
|||
// Update the cache, pageInfo and the change callback
|
||||
self?.dataCache.mutate { $0 = finalUpdatedDataCache }
|
||||
self?.pageInfo.mutate { $0 = updatedPageInfo }
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onChangeUnsorted(finalUpdatedDataCache.values, updatedPageInfo)
|
||||
}
|
||||
self?.onChangeUnsorted(finalUpdatedDataCache.values, updatedPageInfo)
|
||||
}
|
||||
|
||||
// Determing if there were any direct or related data changes
|
||||
|
@ -729,12 +726,6 @@ public class PagedDatabaseObserver<ObservedTable, T>: TransactionObserver where
|
|||
self?.isLoadingMoreData.mutate { $0 = false }
|
||||
}
|
||||
|
||||
// Make sure the updates run on the main thread
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async { triggerUpdates() }
|
||||
return
|
||||
}
|
||||
|
||||
triggerUpdates()
|
||||
}
|
||||
|
||||
|
|
|
@ -227,7 +227,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate {
|
|||
for: .normal
|
||||
)
|
||||
button.setThemeBackgroundColorForced(
|
||||
.theme(.classicLight, color: .settings_tabHighlight),
|
||||
.theme(.classicLight, color: .highlighted(.settings_tabBackground)),
|
||||
for: .highlighted
|
||||
)
|
||||
button.addTarget(self, action: #selector(audioPlayPauseButtonPressed), for: .touchUpInside)
|
||||
|
|
Loading…
Reference in New Issue