Merge branch 'dev' into quote-standardise

This commit is contained in:
ryanzhao 2022-10-19 16:14:20 +11:00
commit 981621738a
52 changed files with 629 additions and 334 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

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

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

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

View file

@ -173,7 +173,7 @@ public enum PushRegistrationError: Error {
}
}
private func createVoipRegistryIfNecessary() {
public func createVoipRegistryIfNecessary() {
AssertIsOnMainThread()
guard voipRegistry == nil else { return }

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

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

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)