Theming tweaks and bug fixes

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

View File

@ -636,6 +636,7 @@
FD37EA1728AC5605003AE748 /* NotificationContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA1628AC5605003AE748 /* NotificationContentViewModel.swift */; };
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 */,

View File

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

View File

@ -86,7 +86,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
)
// Run the initial query on a background thread so we don't block the push transition
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)
}
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -227,7 +227,7 @@ public extension Message {
// service node, but may have done so for another node - if the hash already existed in
// 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
}

View File

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

View File

@ -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."

View File

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

View File

@ -118,7 +118,7 @@ public enum MessageReceiver {
let proto: SNProtoContent
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).")

View File

@ -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).")

View File

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

View File

@ -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")

View File

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

View File

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

View File

@ -1,60 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import <Foundation/Foundation.h>
#import "OWSAsserts.h"
#import "NSData+messagePadding.h"
@implementation NSData (messagePadding)
- (NSData *)removePadding {
unsigned long paddingStart = self.length;
Byte data[self.length];
[self getBytes:data length:self.length];
for (long i = (long)self.length - 1; i >= 0; i--) {
if (data[i] == (Byte)0x80) {
paddingStart = (unsigned long)i;
break;
} else if (data[i] != (Byte)0x00) {
OWSLogWarn(@"Failed to remove padding, returning unstripped padding");
return self;
}
}
return [self subdataWithRange:NSMakeRange(0, paddingStart)];
}
- (NSData *)paddedMessageBody {
// From
// https://github.com/signalapp/TextSecure/blob/master/libtextsecure/src/main/java/org/whispersystems/textsecure/internal/push/PushTransportDetails.java#L55
// NOTE: This is dumb. We have our own padding scheme, but so does the cipher.
// The +1 -1 here is to make sure the Cipher has room to add one padding byte,
// otherwise it'll add a full 16 extra bytes.
NSUInteger paddedMessageLength = [self paddedMessageLength:(self.length + 1)] - 1;
NSMutableData *paddedMessage = [NSMutableData dataWithLength:paddedMessageLength];
Byte paddingByte = 0x80;
[paddedMessage replaceBytesInRange:NSMakeRange(0, self.length) withBytes:[self bytes]];
[paddedMessage replaceBytesInRange:NSMakeRange(self.length, 1) withBytes:&paddingByte];
return paddedMessage;
}
- (NSUInteger)paddedMessageLength:(NSUInteger)messageLength {
NSUInteger messageLengthWithTerminator = messageLength + 1;
NSUInteger messagePartCount = messageLengthWithTerminator / 160;
if (messageLengthWithTerminator % 160 != 0) {
messagePartCount++;
}
return messagePartCount * 160;
}
@end

View File

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

View File

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

View File

@ -0,0 +1,26 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import YapDatabase
import SessionUtilitiesKit
enum _004_FlagMessageHashAsDeletedOrInvalid: Migration {
static let target: TargetMigrations.Identifier = .snodeKit
static let identifier: String = "FlagMessageHashAsDeletedOrInvalid"
static let needsConfigSync: Bool = false
/// This migration adds a flat to the `SnodeReceivedMessageInfo` so that when deleting interactions we can
/// ignore their hashes when subsequently trying to fetch new messages (which results in the storage server returning
/// messages from the beginning of time)
static let minExpectedRunDuration: TimeInterval = 0.2
static func migrate(_ db: Database) throws {
try db.alter(table: SnodeReceivedMessageInfo.self) { t in
t.add(.wasDeletedOrInvalid, .boolean)
.indexed() // Faster querying
}
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
}
}

View File

@ -13,6 +13,7 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist
case key
case 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)
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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