mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Merge branch 'dev' into quote-standardise
This commit is contained in:
commit
981621738a
|
@ -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 */,
|
||||
|
|
|
@ -136,6 +136,10 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
|
|||
func handleCallEnded() {
|
||||
WebRTCSession.current = nil
|
||||
UserDefaults.sharedLokiProject?.set(false, forKey: "isCallOngoing")
|
||||
if CurrentAppContext().isInBackground() {
|
||||
(UIApplication.shared.delegate as? AppDelegate)?.stopPollers()
|
||||
DDLog.flushLog()
|
||||
}
|
||||
}
|
||||
|
||||
guard let call = currentCall else {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -33,6 +33,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
|
||||
Cryptography.seedRandom()
|
||||
AppVersion.sharedInstance()
|
||||
AppEnvironment.shared.pushRegistrationManager.createVoipRegistryIfNecessary()
|
||||
|
||||
// Prevent the device from sleeping during database view async registration
|
||||
// (e.g. long database upgrades).
|
||||
|
|
|
@ -173,7 +173,7 @@ public enum PushRegistrationError: Error {
|
|||
}
|
||||
}
|
||||
|
||||
private func createVoipRegistryIfNecessary() {
|
||||
public func createVoipRegistryIfNecessary() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard voipRegistry == nil else { return }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -71,7 +71,7 @@ public final class FullConversationCell: UITableViewCell {
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
result.clipsToBounds = true
|
||||
result.themeTintColor = .textPrimary
|
||||
result.themeTintColor = .textSecondary
|
||||
result.contentMode = .scaleAspectFit
|
||||
result.set(.width, to: FullConversationCell.unreadCountViewSize)
|
||||
result.set(.height, to: FullConversationCell.unreadCountViewSize)
|
||||
|
@ -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 a new issue