diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 21acb7d48..f025e324a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -573,6 +573,13 @@ FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */; }; FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; + FD23EA5C28ED00F80058676E /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; + FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; }; + FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; + FD23EA5F28ED00FF0058676E /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; + FD23EA6128ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; }; + FD23EA6228ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; }; + FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; }; FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; }; FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; }; FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF224255B6D5D007E1867 /* SignalAttachment.swift */; }; @@ -599,6 +606,11 @@ FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; }; FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A31225574F5200338F3E /* MessageReceiveJob.swift */; }; FD245C6D285066A400B966DD /* NotifyPushServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */; }; + FD2AAAED28ED3E1000A49611 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; }; + FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; }; + FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; + FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; + FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */; }; FD37E9C628A1D4EC003AE748 /* Theme+ClassicDark.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */; }; FD37E9C828A1D73F003AE748 /* Theme+Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */; }; @@ -1678,8 +1690,10 @@ FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacy.swift; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; + FD23EA6028ED0B260058676E /* CombineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineExtensions.swift; sourceTree = ""; }; FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; + FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = ""; }; FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+ClassicDark.swift"; sourceTree = ""; }; FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+Colors.swift"; sourceTree = ""; }; @@ -3374,7 +3388,7 @@ C3C2A5A0255385C100C340D1 /* SessionSnodeKit */, C3C2A67A255388CC00C340D1 /* SessionUtilitiesKit */, C33FD9AC255A548A00E217F9 /* SignalUtilitiesKit */, - FD83B9BC27CF2215005E1583 /* SharedTest */, + FD83B9BC27CF2215005E1583 /* _SharedTestUtilities */, FD71160A28D00BAE00B47552 /* SessionTests */, FDC4388F27B9FFC700C60D73 /* SessionMessagingKitTests */, FD83B9B027CF200A005E1583 /* SessionUtilitiesKitTests */, @@ -3897,15 +3911,18 @@ path = General; sourceTree = ""; }; - FD83B9BC27CF2215005E1583 /* SharedTest */ = { + FD83B9BC27CF2215005E1583 /* _SharedTestUtilities */ = { isa = PBXGroup; children = ( FDC290A527D860CE005DAE71 /* Mock.swift */, + FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */, FD83B9BD27CF2243005E1583 /* TestConstants.swift */, FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */, FD078E4727E02561000769AF /* CommonMockedExtensions.swift */, + FD23EA6028ED0B260058676E /* CombineExtensions.swift */, + FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */, ); - path = SharedTest; + path = _SharedTestUtilities; sourceTree = ""; }; FD83B9C127CF33EE005E1583 /* Models */ = { @@ -4052,7 +4069,6 @@ isa = PBXGroup; children = ( FDC438BC27BB2AB400C60D73 /* Mockable.swift */, - FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */, FD859EF327C2F49200510D0C /* MockSodium.swift */, FD3C906E27E43E8700CD579F /* MockBox.swift */, FD859EF927C2F5C500510D0C /* MockGenericHash.swift */, @@ -5752,8 +5768,15 @@ buildActionMask = 2147483647; files = ( FD71161728D00DA400B47552 /* ThreadSettingsViewModelSpec.swift in Sources */, + FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */, FD71161528D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift in Sources */, + FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */, + FD23EA5F28ED00FF0058676E /* CommonMockedExtensions.swift in Sources */, + FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */, FD71161A28D00E1100B47552 /* NotificationContentViewModelSpec.swift in Sources */, + FD23EA5C28ED00F80058676E /* Mock.swift in Sources */, + FD23EA6128ED0B260058676E /* CombineExtensions.swift in Sources */, + FD2AAAED28ED3E1000A49611 /* MockGeneralCache.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5761,10 +5784,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */, FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */, FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */, FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */, FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, + FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */, + FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */, FD37EA1528AB42CB003AE748 /* IdentitySpec.swift in Sources */, FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */, ); @@ -5789,6 +5815,7 @@ FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */, FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */, + FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */, FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */, FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */, FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */, @@ -5800,6 +5827,7 @@ FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */, FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */, FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */, + FD23EA6228ED0B260058676E /* CombineExtensions.swift in Sources */, FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */, FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */, FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, diff --git a/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme index 376262d1c..ea85c66b2 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme @@ -118,6 +118,18 @@ + + + + - - - - [Action]? { - // No context items for info messages - guard cellViewModel.variant != .standardIncomingDeleted else { - return [ Action.delete(cellViewModel, delegate) ] - } - guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else { - return nil + switch cellViewModel.variant { + case .standardIncomingDeleted, .infoCall, + .infoScreenshotNotification, .infoMediaSavedNotification, + .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, + .infoMessageRequestAccepted, .infoDisappearingMessagesUpdate: + // Let the user delete info messages and unsent messages + return [ Action.delete(cellViewModel, delegate) ] + + case .standardOutgoing, .standardIncoming: break } let canReply: Bool = ( diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index 3f3146df2..659686c7f 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -225,7 +225,8 @@ final class ContextMenuVC: UIViewController { menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX) emojiBar.pin(.left, to: .left, of: view, withInset: targetFrame.minX) - default: break // Should never occur + default: // Should generally only be the 'delete' action + menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX) } // Tap gesture diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 9fc3ffd4b..3365b9ef3 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -766,8 +766,9 @@ extension ConversationVC: let index = self.viewModel.interactionData[sectionIndex] .elements .firstIndex(of: cellViewModel), - let cell = tableView.cellForRow(at: IndexPath(row: index, section: sectionIndex)) as? VisibleMessageCell, - let snapshot = cell.snContentView.snapshotView(afterScreenUpdates: false), + let cell = tableView.cellForRow(at: IndexPath(row: index, section: sectionIndex)) as? MessageCell, + let contextSnapshotView: UIView = cell.contextSnapshotView, + let snapshot = contextSnapshotView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil, let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( for: cellViewModel, @@ -789,7 +790,7 @@ extension ConversationVC: self.contextMenuWindow = ContextMenuWindow() self.contextMenuVC = ContextMenuVC( snapshot: snapshot, - frame: cell.convert(cell.snContentView.frame, to: keyWindow), + frame: contextSnapshotView.convert(contextSnapshotView.bounds, to: keyWindow), cellViewModel: cellViewModel, actions: actions ) { [weak self] in @@ -1218,7 +1219,18 @@ extension ConversationVC: guard recentReactionTimestamps.count < 20 || (sentTimestamp - (recentReactionTimestamps.first ?? sentTimestamp)) > (60 * 1000) - else { return } + else { + let toastController: ToastController = ToastController( + text: "EMOJI_REACTS_RATE_LIMIT_TOAST".localized(), + background: .backgroundSecondary + ) + toastController.presentToastView( + fromBottomOfView: self.view, + inset: (snInputView.bounds.height + Values.largeSpacing), + duration: .milliseconds(2500) + ) + return + } General.cache.mutate { $0.recentReactionTimestamps = Array($0.recentReactionTimestamps @@ -1593,17 +1605,22 @@ extension ConversationVC: } func delete(_ cellViewModel: MessageViewModel) { - // Only allow deletion on incoming and outgoing messages - guard cellViewModel.variant != .standardIncomingDeleted else { - Storage.shared.writeAsync { db in - _ = try Interaction - .filter(id: cellViewModel.id) - .deleteAll(db) - } - return - } - guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else { - return + switch cellViewModel.variant { + case .standardIncomingDeleted, .infoCall, + .infoScreenshotNotification, .infoMediaSavedNotification, + .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, + .infoMessageRequestAccepted, .infoDisappearingMessagesUpdate: + // Info messages and unsent messages should just trigger a local + // deletion (they are created as side effects so we wouldn't be + // able to delete them for all participants anyway) + Storage.shared.writeAsync { db in + _ = try Interaction + .filter(id: cellViewModel.id) + .deleteAll(db) + } + return + + case .standardOutgoing, .standardIncoming: break } let threadId: String = self.viewModel.threadData.threadId diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index d9e389a4c..a84eb33ff 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -705,10 +705,23 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }), let newFirstItemIndex: Int = updatedData[newSectionIndex].elements .firstIndex(where: { item -> Bool in - item.id == self.viewModel.interactionData[oldSectionIndex].elements.first?.id + // Since the first item is probably a `DateHeaderCell` (which would likely + // be removed when inserting items above it) we check if the id matches + // either the first or second item + let messages: [MessageViewModel] = self.viewModel + .interactionData[oldSectionIndex] + .elements + + return ( + item.id == messages[safe: 0]?.id || + item.id == messages[safe: 1]?.id + ) }), let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows? - .filter({ $0.section == oldSectionIndex }) + .filter({ + $0.section == oldSectionIndex && + self.viewModel.interactionData[$0.section].elements[$0.row].cellType != .dateHeader + }) .sorted() .first, let newVisibleIndex: Int = updatedData[newSectionIndex].elements @@ -722,7 +735,9 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl return ItemChangeInfo( isInsertAtTop: ( newSectionIndex > oldSectionIndex || - newFirstItemIndex > 0 + // Note: Using `1` here instead of `0` as the first item will generally + // be a `DateHeaderCell` instead of a message + newFirstItemIndex > 1 ), firstIndexIsVisible: (firstVisibleIndexPath.row == 0), visibleIndexPath: IndexPath(row: newVisibleIndex, section: newSectionIndex), diff --git a/Session/Conversations/Message Cells/CallMessageCell.swift b/Session/Conversations/Message Cells/CallMessageCell.swift index f49e3a827..ef88e2082 100644 --- a/Session/Conversations/Message Cells/CallMessageCell.swift +++ b/Session/Conversations/Message Cells/CallMessageCell.swift @@ -9,14 +9,18 @@ final class CallMessageCell: MessageCell { private static let inset = Values.mediumSpacing private static let margin = UIScreen.main.bounds.width * 0.1 - private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0) - private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0) + private var isHandlingLongPress: Bool = false - private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0) - private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0) + override var contextSnapshotView: UIView? { return container } // MARK: - UI + private lazy var topConstraint: NSLayoutConstraint = container.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset) + private lazy var iconImageViewWidthConstraint: NSLayoutConstraint = iconImageView.set(.width, to: 0) + private lazy var iconImageViewHeightConstraint: NSLayoutConstraint = iconImageView.set(.height, to: 0) + private lazy var infoImageViewWidthConstraint: NSLayoutConstraint = infoImageView.set(.width, to: 0) + private lazy var infoImageViewHeightConstraint: NSLayoutConstraint = infoImageView.set(.height, to: 0) + private lazy var iconImageView: UIImageView = UIImageView() private lazy var infoImageView: UIImageView = { let result: UIImageView = UIImageView( @@ -28,15 +32,6 @@ final class CallMessageCell: MessageCell { return result }() - private lazy var timestampLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) - result.themeTextColor = .textPrimary - result.textAlignment = .center - - return result - }() - private lazy var label: UILabel = { let result: UILabel = UILabel() result.font = .boldSystemFont(ofSize: Values.smallFontSize) @@ -80,15 +75,6 @@ final class CallMessageCell: MessageCell { return result }() - private lazy var stackView: UIStackView = { - let result: UIStackView = UIStackView(arrangedSubviews: [ timestampLabel, container ]) - result.axis = .vertical - result.alignment = .center - result.spacing = Values.smallSpacing - - return result - }() - // MARK: - Lifecycle override func setUpViewHierarchy() { @@ -96,16 +82,18 @@ final class CallMessageCell: MessageCell { iconImageViewWidthConstraint.isActive = true iconImageViewHeightConstraint.isActive = true - addSubview(stackView) + addSubview(container) - container.autoPinWidthToSuperview() - stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin) - stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset) - stackView.pin(.right, to: .right, of: self, withInset: -CallMessageCell.margin) - stackView.pin(.bottom, to: .bottom, of: self, withInset: -CallMessageCell.inset) + topConstraint.isActive = true + container.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin) + container.pin(.right, to: .right, of: self, withInset: -CallMessageCell.margin) + container.pin(.bottom, to: .bottom, of: self, withInset: -CallMessageCell.inset) } override func setUpGestureRecognizers() { + let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) + addGestureRecognizer(longPressRecognizer) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) tapGestureRecognizer.numberOfTapsRequired = 1 addGestureRecognizer(tapGestureRecognizer) @@ -130,6 +118,7 @@ final class CallMessageCell: MessageCell { else { return } self.viewModel = cellViewModel + self.topConstraint.constant = (cellViewModel.shouldShowDateHeader ? 0 : CallMessageCell.inset) iconImageView.image = { switch messageInfo.state { @@ -157,12 +146,24 @@ final class CallMessageCell: MessageCell { infoImageViewHeightConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0) label.text = cellViewModel.body - timestampLabel.text = cellViewModel.dateForUI.formattedForDisplay } override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { } + // MARK: - Interaction + + @objc func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) { + if [ .ended, .cancelled, .failed ].contains(gestureRecognizer.state) { + isHandlingLongPress = false + return + } + guard !isHandlingLongPress, let cellViewModel: MessageViewModel = self.viewModel else { return } + + delegate?.handleItemLongPressed(cellViewModel) + isHandlingLongPress = true + } + @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { guard let cellViewModel: MessageViewModel = self.viewModel, diff --git a/Session/Conversations/Message Cells/Content Views/DocumentView.swift b/Session/Conversations/Message Cells/Content Views/DocumentView.swift index b3f892cb8..cdcfe5fed 100644 --- a/Session/Conversations/Message Cells/Content Views/DocumentView.swift +++ b/Session/Conversations/Message Cells/Content Views/DocumentView.swift @@ -76,15 +76,11 @@ final class DocumentView: UIView { stackView.axis = .horizontal stackView.spacing = Values.mediumSpacing stackView.alignment = .center - stackView.layoutMargins = UIEdgeInsets( - top: Values.smallSpacing, - leading: Values.mediumSpacing, - bottom: Values.smallSpacing, - trailing: Values.mediumSpacing - ) - stackView.isLayoutMarginsRelativeArrangement = true addSubview(stackView) - stackView.pin(to: self) + stackView.pin(.top, to: .top, of: self, withInset: Values.smallSpacing) + stackView.pin(.leading, to: .leading, of: self, withInset: Values.mediumSpacing) + stackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.mediumSpacing) + stackView.pin(.bottom, to: .bottom, of: self, withInset: -Values.smallSpacing) imageBackgroundView.pin(.top, to: .top, of: self) imageBackgroundView.pin(.leading, to: .leading, of: self) diff --git a/Session/Conversations/Message Cells/Content Views/ReactionContainerView.swift b/Session/Conversations/Message Cells/Content Views/ReactionContainerView.swift index 42378cadc..ca9fb9fce 100644 --- a/Session/Conversations/Message Cells/Content Views/ReactionContainerView.swift +++ b/Session/Conversations/Message Cells/Content Views/ReactionContainerView.swift @@ -2,11 +2,17 @@ import UIKit import SessionUIKit +import SessionUtilitiesKit import SignalUtilitiesKit final class ReactionContainerView: UIView { - var showingAllReactions = false - private var showNumbers = true + private static let arrowSize: CGSize = CGSize(width: 15, height: 13) + private static let arrowSpacing: CGFloat = Values.verySmallSpacing + + private var maxWidth: CGFloat = 0 + private var collapsedCount: Int = 0 + private var showingAllReactions: Bool = false + private var showNumbers: Bool = true private var maxEmojisPerLine = isIPhone6OrSmaller ? 5 : 6 private var oldSize: CGSize = .zero @@ -15,6 +21,16 @@ final class ReactionContainerView: UIView { // MARK: - UI + private var collapseTextLabelRightConstraint: NSLayoutConstraint? + + private let dummyReactionButton: ReactionButton = ReactionButton( + viewModel: ReactionViewModel( + emoji: EmojiWithSkinTones(baseEmoji: .a, skinTones: nil), + number: 0, + showBorder: false + ) + ) + private lazy var mainStackView: UIStackView = { let result: UIStackView = UIStackView(arrangedSubviews: [ reactionContainerView, collapseButton ]) result.axis = .vertical @@ -24,6 +40,8 @@ final class ReactionContainerView: UIView { return result }() + var expandButton: ExpandingReactionButton? + private lazy var reactionContainerView: UIStackView = { let result: UIStackView = UIStackView() result.axis = .vertical @@ -33,34 +51,36 @@ final class ReactionContainerView: UIView { return result }() - var expandButton: ExpandingReactionButton? - - var collapseButton: UIStackView = { - let arrow = UIImageView( + lazy var collapseButton: UIView = { + let arrow: UIImageView = UIImageView( image: UIImage(named: "ic_chevron_up")? - .resizedImage(to: CGSize(width: 15, height: 13))? + .resizedImage(to: ReactionContainerView.arrowSize)? .withRenderingMode(.alwaysTemplate) ) arrow.themeTintColor = .textPrimary let textLabel: UILabel = UILabel() + textLabel.setContentHuggingPriority(.required, for: .vertical) + textLabel.setContentHuggingPriority(.required, for: .horizontal) + textLabel.setContentCompressionResistancePriority(.required, for: .vertical) + textLabel.setContentCompressionResistancePriority(.required, for: .horizontal) textLabel.font = .systemFont(ofSize: Values.verySmallFontSize) textLabel.text = "Show less" textLabel.themeTextColor = .textPrimary - let leftSpacer: UIView = UIView.hStretchingSpacer() - let rightSpacer: UIView = UIView.hStretchingSpacer() - let result: UIStackView = UIStackView(arrangedSubviews: [ - leftSpacer, - arrow, - textLabel, - rightSpacer - ]) - result.isLayoutMarginsRelativeArrangement = true - result.spacing = Values.verySmallSpacing - result.alignment = .center + let result: UIView = UIView() result.isHidden = true - rightSpacer.set(.width, to: .width, of: leftSpacer) + result.addSubview(arrow) + result.addSubview(textLabel) + + arrow.pin(.top, to: .top, of: result) + arrow.pin(.leading, to: .leading, of: result) + arrow.pin(.bottom, to: .bottom, of: result) + + textLabel.pin(.top, to: .top, of: result) + textLabel.pin(.leading, to: .trailing, of: arrow, withInset: ReactionContainerView.arrowSpacing) + collapseTextLabelRightConstraint = textLabel.pin(.trailing, to: .trailing, of: result) + textLabel.pin(.bottom, to: .bottom, of: result) return result }() @@ -84,123 +104,181 @@ final class ReactionContainerView: UIView { addSubview(mainStackView) mainStackView.pin(to: self) - collapseButton.set(.width, to: .width, of: mainStackView) + reactionContainerView.set(.width, to: .width, of: mainStackView) } override func layoutSubviews() { super.layoutSubviews() - // Note: We update the 'collapseButton.layoutMargins' to try to make the "show less" + // Note: We update the 'collapseTextLabelRightConstraint' to try to make the "show less" // button appear horizontally centered (if we don't do this it gets offset to one side) guard frame != CGRect.zero, frame.size != oldSize else { return } - collapseButton.layoutMargins = UIEdgeInsets( - top: 0, - leading: -frame.minX, - bottom: 0, - trailing: -((superview?.frame.width ?? 0) - frame.maxX) - ) + var targetSuperview: UIView? = { + var result: UIView? = self.superview + + while result != nil, result?.isKind(of: UITableViewCell.self) != true { + result = result?.superview + } + + return result + }() + + if let targetSuperview: UIView = targetSuperview { + let parentWidth: CGFloat = targetSuperview.bounds.width + let frameInParent: CGRect = targetSuperview.convert(self.bounds, from: self) + let centeredWidth: CGFloat = (parentWidth - (frameInParent.minX * 2)) + let diff: CGFloat = (frameInParent.width - centeredWidth) + collapseTextLabelRightConstraint?.constant = -( + diff + + ((ReactionContainerView.arrowSize.width + ReactionContainerView.arrowSpacing) / 2) + ) + } + oldSize = frame.size } - public func update(_ reactions: [ReactionViewModel], showNumbers: Bool) { + public func update( + _ reactions: [ReactionViewModel], + maxWidth: CGFloat, + showingAllReactions: Bool, + showNumbers: Bool + ) { self.reactions = reactions + self.maxWidth = maxWidth + self.collapsedCount = { + var numReactions: Int = 0 + var runningWidth: CGFloat = 0 + let estimatedExpandingButtonWidth: CGFloat = 52 + let itemSpacing: CGFloat = self.reactionContainerView.spacing + + for reaction in reactions { + let reactionViewWidth: CGFloat = dummyReactionButton + .updating(with: reaction, showNumber: showNumbers) + .systemLayoutSizeFitting(CGSize(width: maxWidth, height: 9999)) + .width + let estimatedFullWidth: CGFloat = ( + runningWidth + + (reactionViewWidth + itemSpacing) + + estimatedExpandingButtonWidth + ) + + if estimatedFullWidth >= maxWidth { + break + } + + runningWidth += (reactionViewWidth + itemSpacing) + numReactions += 1 + } + + return numReactions + }() self.showNumbers = showNumbers + self.reactionViews = [] + self.reactionContainerView.arrangedSubviews.forEach { $0.removeFromSuperview() } - prepareForUpdate() - - if showingAllReactions { - updateAllReactions() + // Generate the lines of reactions (if the 'collapsedCount' matches the total number of + // reactions then just show them app) + if showingAllReactions || self.collapsedCount >= reactions.count { + self.updateAllReactions(reactions, maxWidth: maxWidth, showNumbers: showNumbers) } else { - updateCollapsedReactions(reactions) + self.updateCollapsedReactions(reactions, maxWidth: maxWidth, showNumbers: showNumbers) } + + // Just in case we couldn't show everything for some reason update this based on the + // internal logic + self.collapseButton.isHidden = (self.reactionContainerView.arrangedSubviews.count <= 1) + self.showingAllReactions = !self.collapseButton.isHidden + self.layoutIfNeeded() } - private func updateCollapsedReactions(_ reactions: [ReactionViewModel]) { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.spacing = Values.smallSpacing - stackView.alignment = .center + private func createLineStackView() -> UIStackView { + let result: UIStackView = UIStackView() + result.axis = .horizontal + result.spacing = Values.smallSpacing + result.alignment = .center + result.set(.height, to: ReactionButton.height) - var displayedReactions: [ReactionViewModel] - var expandButtonReactions: [EmojiWithSkinTones] + return result + } + + private func updateCollapsedReactions( + _ reactions: [ReactionViewModel], + maxWidth: CGFloat, + showNumbers: Bool + ) { + guard !reactions.isEmpty else { return } - if reactions.count > maxEmojisPerLine { - displayedReactions = Array(reactions[0...(maxEmojisPerLine - 3)]) - expandButtonReactions = Array(reactions[(maxEmojisPerLine - 2)...maxEmojisPerLine]) - .map { $0.emoji } - } - else { - displayedReactions = reactions - expandButtonReactions = [] - } + let maxSize: CGSize = CGSize(width: maxWidth, height: 9999) + let stackView: UIStackView = createLineStackView() + let displayedReactions: [ReactionViewModel] = Array(reactions.prefix(upTo: self.collapsedCount)) + let expandButtonReactions: [EmojiWithSkinTones] = reactions + .suffix(from: self.collapsedCount) + .prefix(3) + .map { $0.emoji } for reaction in displayedReactions { let reactionView = ReactionButton(viewModel: reaction, showNumber: showNumbers) + let reactionViewWidth: CGFloat = reactionView.systemLayoutSizeFitting(maxSize).width stackView.addArrangedSubview(reactionView) reactionViews.append(reactionView) + reactionView.set(.width, to: reactionViewWidth) } - if expandButtonReactions.count > 0 { - let expandButton: ExpandingReactionButton = ExpandingReactionButton(emojis: expandButtonReactions) - stackView.addArrangedSubview(expandButton) + self.expandButton = { + guard !expandButtonReactions.isEmpty else { return nil } + + let result: ExpandingReactionButton = ExpandingReactionButton(emojis: expandButtonReactions) + stackView.addArrangedSubview(result) - self.expandButton = expandButton - } - else { - expandButton = nil - } + return result + }() reactionContainerView.addArrangedSubview(stackView) } - private func updateAllReactions() { - var reactions = self.reactions - var numberOfLines = 0 + private func updateAllReactions( + _ reactions: [ReactionViewModel], + maxWidth: CGFloat, + showNumbers: Bool + ) { + guard !reactions.isEmpty else { return } - while reactions.count > 0 { - var line: [ReactionViewModel] = [] + let maxSize: CGSize = CGSize(width: maxWidth, height: 9999) + var lineStackView: UIStackView = createLineStackView() + reactionContainerView.addArrangedSubview(lineStackView) + + for reaction in self.reactions { + let reactionView: ReactionButton = ReactionButton(viewModel: reaction, showNumber: showNumbers) + let reactionViewWidth: CGFloat = reactionView.systemLayoutSizeFitting(maxSize).width + reactionViews.append(reactionView) - while reactions.count > 0 && line.count < maxEmojisPerLine { - line.append(reactions.removeFirst()) + // Check if we need to create a new line + let stackViewWidth: CGFloat = (lineStackView.arrangedSubviews.isEmpty ? + 0 : + lineStackView.systemLayoutSizeFitting(maxSize).width + ) + + if stackViewWidth + reactionViewWidth > maxWidth { + lineStackView = createLineStackView() + reactionContainerView.addArrangedSubview(lineStackView) } - updateCollapsedReactions(line) - numberOfLines += 1 + lineStackView.addArrangedSubview(reactionView) + reactionView.set(.width, to: reactionViewWidth) } - - if numberOfLines > 1 { - collapseButton.isHidden = false - } - else { - showingAllReactions = false - } - } - - private func prepareForUpdate() { - for subview in reactionContainerView.arrangedSubviews { - reactionContainerView.removeArrangedSubview(subview) - subview.removeFromSuperview() - } - - collapseButton.isHidden = true - reactionViews = [] } public func showAllEmojis() { guard !showingAllReactions else { return } - showingAllReactions = true - update(reactions, showNumbers: showNumbers) + update(reactions, maxWidth: maxWidth, showingAllReactions: true, showNumbers: showNumbers) } public func showLessEmojis() { guard showingAllReactions else { return } - showingAllReactions = false - update(reactions, showNumbers: showNumbers) + update(reactions, maxWidth: maxWidth, showingAllReactions: false, showNumbers: showNumbers) } } - - diff --git a/Session/Conversations/Message Cells/Content Views/ReactionView.swift b/Session/Conversations/Message Cells/Content Views/ReactionView.swift index 63d1aaf16..cf5377b89 100644 --- a/Session/Conversations/Message Cells/Content Views/ReactionView.swift +++ b/Session/Conversations/Message Cells/Content Views/ReactionView.swift @@ -15,10 +15,29 @@ final class ReactionButton: UIView { // MARK: - Settings - private var height: CGFloat = 22 + public static var height: CGFloat = 22 private var fontSize: CGFloat = Values.verySmallFontSize private var spacing: CGFloat = Values.verySmallSpacing + // MARK: - UI + + private lazy var emojiLabel: UILabel = { + let result: UILabel = UILabel() + result.setContentHuggingPriority(.required, for: .horizontal) + result.setContentCompressionResistancePriority(.required, for: .horizontal) + result.font = .systemFont(ofSize: fontSize) + + return result + }() + + private lazy var numberLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: fontSize) + result.themeTextColor = .textPrimary + + return result + }() + // MARK: - Lifecycle init(viewModel: ReactionViewModel, showNumber: Bool = true) { @@ -28,6 +47,7 @@ final class ReactionButton: UIView { super.init(frame: CGRect.zero) setUpViewHierarchy() + update(with: viewModel, showNumber: showNumber) } override init(frame: CGRect) { @@ -39,35 +59,45 @@ final class ReactionButton: UIView { } private func setUpViewHierarchy() { - let emojiLabel: UILabel = UILabel() - emojiLabel.font = .systemFont(ofSize: fontSize) emojiLabel.text = viewModel.emoji.rawValue - let stackView: UIStackView = UIStackView(arrangedSubviews: [ emojiLabel ]) + let stackView: UIStackView = UIStackView(arrangedSubviews: [ emojiLabel, numberLabel ]) stackView.axis = .horizontal stackView.spacing = spacing stackView.alignment = .center - stackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.smallSpacing, bottom: 0, right: Values.smallSpacing) - stackView.isLayoutMarginsRelativeArrangement = true addSubview(stackView) - stackView.pin(to: self) + stackView.pin(.top, to: .top, of: self) + stackView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing) + stackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) + stackView.pin(.bottom, to: .bottom, of: self) themeBorderColor = (viewModel.showBorder ? .primary : .clear) themeBackgroundColor = .messageBubble_incomingBackground - layer.cornerRadius = (self.height / 2) + layer.cornerRadius = (ReactionButton.height / 2) layer.borderWidth = 1 // Intentionally 1pt (instead of 'Values.separatorThickness') - set(.height, to: self.height) + set(.height, to: ReactionButton.height) - if showNumber || viewModel.number > 1 { - let numberLabel = UILabel() - numberLabel.font = .systemFont(ofSize: fontSize) - numberLabel.text = (viewModel.number < 1000 ? - "\(viewModel.number)" : - String(format: "%.1f", Float(viewModel.number) / 1000) + "k" - ) - numberLabel.themeTextColor = .textPrimary - stackView.addArrangedSubview(numberLabel) + numberLabel.isHidden = (!showNumber && viewModel.number <= 1) + } + + func update(with viewModel: ReactionViewModel, showNumber: Bool) { + _ = updating(with: viewModel, showNumber: showNumber) + } + + func updating(with viewModel: ReactionViewModel, showNumber: Bool) -> ReactionButton { + emojiLabel.text = viewModel.emoji.rawValue + numberLabel.text = (viewModel.number < 1000 ? + "\(viewModel.number)" : + String(format: "%.1f", Float(viewModel.number) / 1000) + "k" + ) + numberLabel.isHidden = (!showNumber && viewModel.number <= 1) + + UIView.performWithoutAnimation { + self.setNeedsLayout() + self.layoutIfNeeded() } + + return self } } diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index 3294b9056..2b8484f14 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -8,6 +8,10 @@ final class InfoMessageCell: MessageCell { private static let iconSize: CGFloat = 16 private static let inset = Values.mediumSpacing + private var isHandlingLongPress: Bool = false + + override var contextSnapshotView: UIView? { return label } + // MARK: - UI private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize) @@ -49,6 +53,11 @@ final class InfoMessageCell: MessageCell { stackView.pin(.right, to: .right, of: self, withInset: -InfoMessageCell.inset) stackView.pin(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset) } + + override func setUpGestureRecognizers() { + let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) + addGestureRecognizer(longPressRecognizer) + } // MARK: - Updating @@ -90,4 +99,17 @@ final class InfoMessageCell: MessageCell { override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { } + + // MARK: - Interaction + + @objc func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) { + if [ .ended, .cancelled, .failed ].contains(gestureRecognizer.state) { + isHandlingLongPress = false + return + } + guard !isHandlingLongPress, let cellViewModel: MessageViewModel = self.viewModel else { return } + + delegate?.handleItemLongPressed(cellViewModel) + isHandlingLongPress = true + } } diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index 2d6cc3622..1f7356bc2 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -10,8 +10,9 @@ public enum SwipeState { } public class MessageCell: UITableViewCell { - weak var delegate: MessageCellDelegate? var viewModel: MessageViewModel? + weak var delegate: MessageCellDelegate? + open var contextSnapshotView: UIView? { return nil } // MARK: - Lifecycle diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index e62bbbb85..7855e19e6 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -15,6 +15,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { var voiceMessageView: VoiceMessageView? var audioStateChanged: ((TimeInterval, Bool) -> ())? + override var contextSnapshotView: UIView? { return snContentView } + // Constraints private lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self) private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0) @@ -25,13 +27,14 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing) private lazy var contentViewRightConstraint1 = snContentView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing) private lazy var contentViewRightConstraint2 = snContentView.rightAnchor.constraint(lessThanOrEqualTo: rightAnchor, constant: -VisibleMessageCell.gutterSize) + private lazy var contentBottomConstraint = snContentView.bottomAnchor + .constraint(lessThanOrEqualTo: self.bottomAnchor, constant: -1) - private lazy var reactionContainerViewLeftConstraint = reactionContainerView.pin(.left, to: .left, of: snContentView) - private lazy var reactionContainerViewRightConstraint = reactionContainerView.pin(.right, to: .right, of: snContentView) - - private lazy var messageStatusImageViewTopConstraint = messageStatusImageView.pin(.top, to: .bottom, of: reactionContainerView, withInset: 0) - private lazy var messageStatusImageViewWidthConstraint = messageStatusImageView.set(.width, to: VisibleMessageCell.messageStatusImageViewSize) - private lazy var messageStatusImageViewHeightConstraint = messageStatusImageView.set(.height, to: VisibleMessageCell.messageStatusImageViewSize) + private lazy var underBubbleStackViewIncomingLeadingConstraint: NSLayoutConstraint = underBubbleStackView.pin(.leading, to: .leading, of: snContentView) + private lazy var underBubbleStackViewIncomingTrailingConstraint: NSLayoutConstraint = underBubbleStackView.pin(.trailing, to: .trailing, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing) + private lazy var underBubbleStackViewOutgoingLeadingConstraint: NSLayoutConstraint = underBubbleStackView.pin(.leading, to: .leading, of: self, withInset: VisibleMessageCell.contactThreadHSpacing) + private lazy var underBubbleStackViewOutgoingTrailingConstraint: NSLayoutConstraint = underBubbleStackView.pin(.trailing, to: .trailing, of: snContentView) + private lazy var underBubbleStackViewNoHeightConstraint: NSLayoutConstraint = underBubbleStackView.set(.height, to: 0) private lazy var timerViewOutgoingMessageConstraint = timerView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing) private lazy var timerViewIncomingMessageConstraint = timerView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing) @@ -92,16 +95,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return result }() - private lazy var reactionContainerView = ReactionContainerView() - - internal lazy var messageStatusImageView: UIImageView = { - let result = UIImageView() - result.contentMode = .scaleAspectFit - result.layer.cornerRadius = VisibleMessageCell.messageStatusImageViewSize / 2 - result.layer.masksToBounds = true - return result - }() - private lazy var replyButton: UIView = { let result = UIView() let size = VisibleMessageCell.replyButtonSize + 8 @@ -128,6 +121,27 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { }() private lazy var timerView: OWSMessageTimerView = OWSMessageTimerView() + + lazy var underBubbleStackView: UIStackView = { + let result = UIStackView(arrangedSubviews: []) + result.setContentHuggingPriority(.required, for: .vertical) + result.setContentCompressionResistancePriority(.required, for: .vertical) + result.axis = .vertical + result.spacing = Values.verySmallSpacing + result.alignment = .trailing + + return result + }() + + private lazy var reactionContainerView = ReactionContainerView() + + internal lazy var messageStatusImageView: UIImageView = { + let result = UIImageView() + result.contentMode = .scaleAspectFit + result.layer.cornerRadius = VisibleMessageCell.messageStatusImageViewSize / 2 + result.layer.masksToBounds = true + return result + }() // MARK: - Settings @@ -197,19 +211,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { timerView.center(.vertical, in: snContentView) timerViewOutgoingMessageConstraint.isActive = true - // Reaction view - addSubview(reactionContainerView) - reactionContainerView.pin(.top, to: .bottom, of: snContentView, withInset: Values.verySmallSpacing) - reactionContainerViewLeftConstraint.isActive = true - - // Message status image view - addSubview(messageStatusImageView) - messageStatusImageViewTopConstraint.isActive = true - messageStatusImageView.pin(.right, to: .right, of: snContentView, withInset: -1) - messageStatusImageView.pin(.bottom, to: .bottom, of: self, withInset: -1) - messageStatusImageViewWidthConstraint.isActive = true - messageStatusImageViewHeightConstraint.isActive = true - // Reply button addSubview(replyButton) replyButton.addSubview(replyIconImageView) @@ -219,6 +220,20 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { // Remaining constraints authorLabel.pin(.left, to: .left, of: snContentView, withInset: VisibleMessageCell.authorLabelInset) + + // Under bubble content + addSubview(underBubbleStackView) + underBubbleStackView.pin(.top, to: .bottom, of: snContentView, withInset: 5) + underBubbleStackView.pin(.bottom, to: .bottom, of: self) + + underBubbleStackView.addArrangedSubview(reactionContainerView) + underBubbleStackView.addArrangedSubview(messageStatusImageView) + + reactionContainerView.widthAnchor + .constraint(lessThanOrEqualTo: underBubbleStackView.widthAnchor) + .isActive = true + messageStatusImageView.set(.width, to: VisibleMessageCell.messageStatusImageViewSize) + messageStatusImageView.set(.height, to: VisibleMessageCell.messageStatusImageViewSize) } override func setUpGestureRecognizers() { @@ -298,12 +313,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { lastSearchText: lastSearchText ) - // Reaction view - reactionContainerView.isHidden = (cellViewModel.reactionInfo?.isEmpty == true) - reactionContainerViewLeftConstraint.isActive = (cellViewModel.variant == .standardIncoming) - reactionContainerViewRightConstraint.isActive = (cellViewModel.variant == .standardOutgoing) - populateReaction(for: cellViewModel, showExpandedReactions: showExpandedReactions) - // Author label authorLabelTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0) authorLabel.isHidden = (cellViewModel.senderName == nil) @@ -315,27 +324,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace) authorLabelHeightConstraint.constant = (cellViewModel.senderName != nil ? authorLabelSize.height : 0) - // Message status image view - let (image, tintColor) = cellViewModel.state.statusIconInfo( - variant: cellViewModel.variant, - hasAtLeastOneReadReceipt: cellViewModel.hasAtLeastOneReadReceipt - ) - messageStatusImageView.image = image - messageStatusImageView.themeTintColor = tintColor - messageStatusImageView.isHidden = ( - cellViewModel.variant != .standardOutgoing || - cellViewModel.variant == .infoCall || - ( - cellViewModel.state == .sent && - !cellViewModel.isLast - ) - ) - messageStatusImageViewTopConstraint.constant = (messageStatusImageView.isHidden ? 0 : 5) - [ messageStatusImageViewWidthConstraint, messageStatusImageViewHeightConstraint ] - .forEach { - $0.constant = (messageStatusImageView.isHidden ? 0 : VisibleMessageCell.messageStatusImageViewSize) - } - // Timer if let expiresStartedAtMs: Double = cellViewModel.expiresStartedAtMs, @@ -367,6 +355,43 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { else { addGestureRecognizer(panGestureRecognizer) } + + // Under bubble content + underBubbleStackView.alignment = (cellViewModel.variant == .standardOutgoing ? + .trailing : + .leading + ) + underBubbleStackViewIncomingLeadingConstraint.isActive = (cellViewModel.variant != .standardOutgoing) + underBubbleStackViewIncomingTrailingConstraint.isActive = (cellViewModel.variant != .standardOutgoing) + underBubbleStackViewOutgoingLeadingConstraint.isActive = (cellViewModel.variant == .standardOutgoing) + underBubbleStackViewOutgoingTrailingConstraint.isActive = (cellViewModel.variant == .standardOutgoing) + + // Reaction view + reactionContainerView.isHidden = (cellViewModel.reactionInfo?.isEmpty != false) + populateReaction( + for: cellViewModel, + maxWidth: VisibleMessageCell.getMaxWidth( + for: cellViewModel, + includingOppositeGutter: false + ), + showExpandedReactions: showExpandedReactions + ) + + // Message status image view + let (image, tintColor) = cellViewModel.state.statusIconInfo( + variant: cellViewModel.variant, + hasAtLeastOneReadReceipt: cellViewModel.hasAtLeastOneReadReceipt + ) + messageStatusImageView.image = image + messageStatusImageView.themeTintColor = tintColor + messageStatusImageView.isHidden = ( + cellViewModel.variant != .standardOutgoing || + cellViewModel.variant == .infoCall || + ( + cellViewModel.state == .sent && + !cellViewModel.isLast + ) + ) } private func populateContentView( @@ -595,7 +620,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } } - private func populateReaction(for cellViewModel: MessageViewModel, showExpandedReactions: Bool) { + private func populateReaction( + for cellViewModel: MessageViewModel, + maxWidth: CGFloat, + showExpandedReactions: Bool + ) { let reactions: OrderedDictionary = (cellViewModel.reactionInfo ?? []) .reduce(into: OrderedDictionary()) { result, reactionInfo in guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else { @@ -626,9 +655,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } } - reactionContainerView.showingAllReactions = showExpandedReactions reactionContainerView.update( reactions.orderedValues, + maxWidth: maxWidth, + showingAllReactions: showExpandedReactions, showNumbers: ( cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup @@ -752,7 +782,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let location = gestureRecognizer.location(in: self) - if reactionContainerView.frame.contains(location) { + if reactionContainerView.bounds.contains(reactionContainerView.convert(location, from: self)) { let convertedLocation = reactionContainerView.convert(location, from: self) for reactionView in reactionContainerView.reactionViews { @@ -774,7 +804,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let location = gestureRecognizer.location(in: self) - if profilePictureView.frame.contains(location), cellViewModel.shouldShowProfile { + if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)), cellViewModel.shouldShowProfile { // For open groups only attempt to start a conversation if the author has a blinded id guard cellViewModel.threadVariant != .openGroup else { guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded else { return } @@ -793,11 +823,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { openGroupPublicKey: nil ) } - else if replyButton.alpha > 0 && replyButton.frame.contains(location) { + else if replyButton.alpha > 0 && replyButton.bounds.contains(replyButton.convert(location, from: self)) { UIImpactFeedbackGenerator(style: .heavy).impactOccurred() reply() } - else if reactionContainerView.frame.contains(location) { + else if reactionContainerView.bounds.contains(reactionContainerView.convert(location, from: self)) { let convertedLocation = reactionContainerView.convert(location, from: self) for reactionView in reactionContainerView.reactionViews { @@ -813,7 +843,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } } - if let expandButton = reactionContainerView.expandButton, expandButton.frame.contains(convertedLocation) { + if let expandButton = reactionContainerView.expandButton, expandButton.bounds.contains(expandButton.convert(location, from: self)) { reactionContainerView.showAllEmojis() delegate?.needsLayout(for: cellViewModel, expandingReactions: true) } @@ -823,7 +853,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { delegate?.needsLayout(for: cellViewModel, expandingReactions: false) } } - else if snContentView.frame.contains(location) { + else if snContentView.bounds.contains(snContentView.convert(location, from: self)) { delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer) } } @@ -966,11 +996,14 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return CGSize(width: width, height: height) } - static func getMaxWidth(for cellViewModel: MessageViewModel) -> CGFloat { + static func getMaxWidth(for cellViewModel: MessageViewModel, includingOppositeGutter: Bool = true) -> CGFloat { let screen: CGRect = UIScreen.main.bounds + let oppositeEdgePadding: CGFloat = (includingOppositeGutter ? gutterSize : contactThreadHSpacing) switch cellViewModel.variant { - case .standardOutgoing: return (screen.width - contactThreadHSpacing - gutterSize) + case .standardOutgoing: + return (screen.width - contactThreadHSpacing - oppositeEdgePadding) + case .standardIncoming, .standardIncomingDeleted: let isGroupThread = ( cellViewModel.threadVariant == .openGroup || @@ -978,7 +1011,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { ) let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing) - return (screen.width - leftGutterSize - gutterSize) + return (screen.width - leftGutterSize - oppositeEdgePadding) default: preconditionFailure() } diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift index 62c707d59..aace471aa 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift @@ -28,8 +28,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel () @@ -54,13 +55,19 @@ class ThreadSettingsViewModel: SessionTableViewModel ()) { + init( + dependencies: Dependencies = Dependencies(), + threadId: String, + threadVariant: SessionThread.Variant, + didTriggerSearch: @escaping () -> () + ) { + self.dependencies = dependencies self.threadId = threadId self.threadVariant = threadVariant self.didTriggerSearch = didTriggerSearch self.oldDisplayName = (threadVariant != .contact ? nil : - Storage.shared.read { db in + dependencies.storage.read { db in try Profile .filter(id: threadId) .select(.nickname) @@ -73,55 +80,8 @@ class ThreadSettingsViewModel: SessionTableViewModel = { - Publishers - .MergeMany( - isEditing - .filter { $0 } - .map { _ in .editing } - .eraseToAnyPublisher(), - navItemTapped - .filter { $0 == .edit } - .map { _ in .editing } - .handleEvents(receiveOutput: { [weak self] _ in - self?.setIsEditing(true) - }) - .eraseToAnyPublisher(), - navItemTapped - .filter { $0 == .cancel } - .map { _ in .standard } - .handleEvents(receiveOutput: { [weak self] _ in - self?.setIsEditing(false) - self?.editedDisplayName = self?.oldDisplayName - }) - .eraseToAnyPublisher(), - navItemTapped - .filter { $0 == .done } - .filter { [weak self] _ in self?.threadVariant == .contact } - .handleEvents(receiveOutput: { [weak self] _ in - self?.setIsEditing(false) - - guard - let threadId: String = self?.threadId, - let editedDisplayName: String = self?.editedDisplayName - else { return } - - let updatedNickname: String = editedDisplayName - .trimmingCharacters(in: .whitespacesAndNewlines) - self?.oldDisplayName = (updatedNickname.isEmpty ? nil : editedDisplayName) - - Storage.shared.writeAsync { db in - try Profile - .filter(id: threadId) - .updateAll( - db, - Profile.Columns.nickname - .set(to: (updatedNickname.isEmpty ? nil : editedDisplayName)) - ) - } - }) - .map { _ in .standard } - .eraseToAnyPublisher() - ) + isEditing + .map { isEditing in (isEditing ? .editing : .standard) } .removeDuplicates() .prepend(.standard) // Initial value .eraseToAnyPublisher() @@ -139,7 +99,10 @@ class ThreadSettingsViewModel: SessionTableViewModel { navState - .map { [weak self] navState -> [NavItem] in + .map { [weak self, dependencies] navState -> [NavItem] in // Only show the 'Edit' button if it's a contact thread guard self?.threadVariant == .contact else { return [] } @@ -158,7 +121,29 @@ class ThreadSettingsViewModel: SessionTableViewModel [SectionModel] in - let userPublicKey: String = getUserHexEncodedPublicKey(db) + .trackingConstantRegion { [weak self, dependencies, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in + let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey) .fetchOne(db) @@ -387,7 +372,7 @@ class ThreadSettingsViewModel: SessionTableViewModel) { let threadId: String = self.threadId - Storage.shared.writeAsync { db in + dependencies.storage.writeAsync { db in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return } let urlString: String = "\(openGroup.server)/\(openGroup.roomToken)?public_key=\(openGroup.publicKey)" @@ -622,7 +611,7 @@ class ThreadSettingsViewModel: SessionTableViewModel = { - Publishers - .MergeMany( - isEditing - .filter { $0 } - .map { _ in .editing } - .eraseToAnyPublisher(), - navItemTapped - .filter { $0 == .cancel } - .map { _ in .standard } - .handleEvents(receiveOutput: { [weak self] _ in - self?.setIsEditing(false) - self?.editedDisplayName = self?.oldDisplayName - }) - .eraseToAnyPublisher(), - navItemTapped - .filter { $0 == .done } - .handleEvents(receiveOutput: { [weak self] _ in - let updatedNickname: String = (self?.editedDisplayName ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - - guard !updatedNickname.isEmpty else { - self?.transitionToScreen( - ConfirmationModal( - info: ConfirmationModal.Info( - title: "vc_settings_display_name_missing_error".localized(), - cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text - ) - ), - transitionType: .present - ) - return - } - guard !ProfileManager.isToLong(profileName: updatedNickname) else { - self?.transitionToScreen( - ConfirmationModal( - info: ConfirmationModal.Info( - title: "vc_settings_display_name_too_long_error".localized(), - cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text - ) - ), - transitionType: .present - ) - return - } - - self?.setIsEditing(false) - self?.oldDisplayName = updatedNickname - self?.updateProfile( - name: updatedNickname, - profilePicture: nil, - profilePictureFilePath: nil, - isUpdatingDisplayName: true, - isUpdatingProfilePicture: false - ) - }) - .map { _ in .standard } - .eraseToAnyPublisher() - ) + isEditing + .map { isEditing in (isEditing ? .editing : .standard) } .removeDuplicates() .prepend(.standard) // Initial value .eraseToAnyPublisher() @@ -149,7 +91,10 @@ class SettingsViewModel: SessionTableViewModel? = nil, storage: Storage? = nil, + scheduler: ValueObservationScheduler? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, genericHash: GenericHashType? = nil, @@ -1146,6 +1147,7 @@ extension OpenGroupManager { onionApi: onionApi, generalCache: generalCache, storage: storage, + scheduler: scheduler, sodium: sodium, box: box, genericHash: genericHash, diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 3544c79dc..90867819d 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -510,10 +510,12 @@ public extension MessageViewModel { // Interaction Info - let targetId: Int64 = (isTypingIndicator == true ? - MessageViewModel.typingIndicatorId : - MessageViewModel.genericId - ) + let targetId: Int64 = { + guard isTypingIndicator != true else { return MessageViewModel.typingIndicatorId } + guard cellType != .dateHeader else { return -timestampMs } + + return MessageViewModel.genericId + }() self.rowId = targetId self.id = targetId self.variant = variant diff --git a/SessionMessagingKit/Utilities/SMKDependencies.swift b/SessionMessagingKit/Utilities/SMKDependencies.swift index d4f32efae..9d8c713b4 100644 --- a/SessionMessagingKit/Utilities/SMKDependencies.swift +++ b/SessionMessagingKit/Utilities/SMKDependencies.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import Sodium import SessionSnodeKit import SessionUtilitiesKit @@ -66,6 +67,7 @@ public class SMKDependencies: Dependencies { onionApi: OnionRequestAPIType.Type? = nil, generalCache: Atomic? = nil, storage: Storage? = nil, + scheduler: ValueObservationScheduler? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, genericHash: GenericHashType? = nil, @@ -90,6 +92,7 @@ public class SMKDependencies: Dependencies { super.init( generalCache: generalCache, storage: storage, + scheduler: scheduler, standardUserDefaults: standardUserDefaults, date: date ) diff --git a/SessionMessagingKit/Utilities/Sodium+Utilities.swift b/SessionMessagingKit/Utilities/Sodium+Utilities.swift index bdc48469f..4e113c2e7 100644 --- a/SessionMessagingKit/Utilities/Sodium+Utilities.swift +++ b/SessionMessagingKit/Utilities/Sodium+Utilities.swift @@ -25,8 +25,9 @@ extension Sodium { /// 64-byte blake2b hash then reduce to get the blinding factor public func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? { /// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) - guard let serverPubKeyData: Data = serverPublicKey.dataFromHex() else { return nil } - guard let serverPublicKeyHashBytes: Bytes = genericHash.hash(message: [UInt8](serverPubKeyData), outputLength: 64) else { + let serverPubKeyData: Data = Data(hex: serverPublicKey) + + guard !serverPubKeyData.isEmpty, let serverPublicKeyHashBytes: Bytes = genericHash.hash(message: [UInt8](serverPubKeyData), outputLength: 64) else { return nil } diff --git a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift index 51bb86598..38f83c578 100644 --- a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import SessionSnodeKit import SessionUtilitiesKit @@ -11,6 +12,7 @@ extension SMKDependencies { onionApi: OnionRequestAPIType.Type? = nil, generalCache: Atomic? = nil, storage: Storage? = nil, + scheduler: ValueObservationScheduler? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, genericHash: GenericHashType? = nil, @@ -26,6 +28,7 @@ extension SMKDependencies { onionApi: (onionApi ?? self._onionApi.wrappedValue), generalCache: (generalCache ?? self._generalCache.wrappedValue), storage: (storage ?? self._storage.wrappedValue), + scheduler: (scheduler ?? self._scheduler.wrappedValue), sodium: (sodium ?? self._sodium.wrappedValue), box: (box ?? self._box.wrappedValue), genericHash: (genericHash ?? self._genericHash.wrappedValue), diff --git a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift index 0d2f8cee9..b297e62a8 100644 --- a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import SessionSnodeKit import SessionUtilitiesKit @@ -12,6 +13,7 @@ extension OpenGroupManager.OGMDependencies { onionApi: OnionRequestAPIType.Type? = nil, generalCache: Atomic? = nil, storage: Storage? = nil, + scheduler: ValueObservationScheduler? = nil, sodium: SodiumType? = nil, box: BoxType? = nil, genericHash: GenericHashType? = nil, @@ -28,6 +30,7 @@ extension OpenGroupManager.OGMDependencies { onionApi: (onionApi ?? self._onionApi.wrappedValue), generalCache: (generalCache ?? self._generalCache.wrappedValue), storage: (storage ?? self._storage.wrappedValue), + scheduler: (scheduler ?? self._scheduler.wrappedValue), sodium: (sodium ?? self._sodium.wrappedValue), box: (box ?? self._box.wrappedValue), genericHash: (genericHash ?? self._genericHash.wrappedValue), diff --git a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift index eecb13aa6..ee9f38147 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -14,8 +14,8 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { override func spec() { var mockStorage: Storage! - var dataChangeCancellable: AnyCancellable? - var otherCancellables: [AnyCancellable] = [] + var cancellables: [AnyCancellable] = [] + var dependencies: Dependencies! var viewModel: ThreadDisappearingMessagesViewModel! describe("a ThreadDisappearingMessagesViewModel") { @@ -31,6 +31,10 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { SNUIKit.migrations() ] ) + dependencies = Dependencies( + storage: mockStorage, + scheduler: .immediate + ) mockStorage.write { db in try SessionThread( id: "TestId", @@ -38,26 +42,26 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { ).insert(db) } viewModel = ThreadDisappearingMessagesViewModel( - storage: mockStorage, - scheduling: .immediate, + dependencies: dependencies, threadId: "TestId", config: DisappearingMessagesConfiguration.defaultWith("TestId") ) - dataChangeCancellable = viewModel.observableSettingsData - .receiveOnMain(immediately: true) - .sink( - receiveCompletion: { _ in }, - receiveValue: { viewModel.updateSettings($0) } - ) + cancellables.append( + viewModel.observableSettingsData + .receiveOnMain(immediately: true) + .sink( + receiveCompletion: { _ in }, + receiveValue: { viewModel.updateSettings($0) } + ) + ) } afterEach { - dataChangeCancellable?.cancel() - otherCancellables.forEach { $0.cancel() } + cancellables.forEach { $0.cancel() } mockStorage = nil - dataChangeCancellable = nil - otherCancellables = [] + cancellables = [] + dependencies = nil viewModel = nil } @@ -118,17 +122,18 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { _ = try config.saved(db) } viewModel = ThreadDisappearingMessagesViewModel( - storage: mockStorage, - scheduling: .immediate, + dependencies: dependencies, threadId: "TestId", config: config ) - dataChangeCancellable = viewModel.observableSettingsData - .receiveOnMain(immediately: true) - .sink( - receiveCompletion: { _ in }, - receiveValue: { viewModel.updateSettings($0) } - ) + cancellables.append( + viewModel.observableSettingsData + .receiveOnMain(immediately: true) + .sink( + receiveCompletion: { _ in }, + receiveValue: { viewModel.updateSettings($0) } + ) + ) expect(viewModel.settingsData.first?.elements.first) .to( @@ -165,7 +170,7 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { it("has no right bar button") { var items: [ParentType.NavItem]? - otherCancellables.append( + cancellables.append( viewModel.rightNavItems .receiveOnMain(immediately: true) .sink( @@ -181,7 +186,7 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { var items: [ParentType.NavItem]? beforeEach { - otherCancellables.append( + cancellables.append( viewModel.rightNavItems .receiveOnMain(immediately: true) .sink( @@ -208,7 +213,7 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { it("dismisses the screen") { var didDismissScreen: Bool = false - otherCancellables.append( + cancellables.append( viewModel.dismissScreen .receiveOnMain(immediately: true) .sink( diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index c63c668b3..365b927d9 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -1,767 +1,524 @@ -//// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// -//import Combine -//import Quick -//import Nimble -// -//@testable import Session -// -//class ThreadSettingsViewModelSpec: QuickSpec { -// typealias Item = ConversationSettingsViewModel.Item -// typealias ActionableItem = ConversationSettingsViewModel.ActionableItem -// typealias NavItem = ConversationSettingsViewModel.NavItem -// -// var disposables: Set! -// var didTriggerSearchCallbackTriggered: Bool = false -// var publicKey: String! -// var thread: TSThread! -// var uiDatabaseConnection: YapDatabaseConnection! -// var defaultContactThreadItems: [[Item]]! -// var viewModel: ConversationSettingsViewModel! -// -// -// // MARK: - Configuration -// -// override func setUpWithError() throws { -// didTriggerSearchCallbackTriggered = false -// -// // TODO: Need to mock TSThread, YapDatabaseConnection and the publicKey retrieval logic -// disposables = Set() -// didTriggerSearchCallbackTriggered = false -// publicKey = SNGeneralUtilities.getUserPublicKey() -// thread = TSContactThread(contactSessionID: "TestContactId") -// uiDatabaseConnection = OWSPrimaryStorage.shared().uiDatabaseConnection -// defaultContactThreadItems = [ -// [ -// Item( -// id: .header, -// style: .header, -// title: "Anonymous", -// subtitle: "TestContactId" -// ) -// ], -// [ -// Item( -// id: .search, -// style: .search, -// icon: UIImage(named: "conversation_settings_search")?.withRenderingMode(.alwaysTemplate), -// title: "CONVERSATION_SETTINGS_SEARCH".localized(), -// accessibilityIdentifier: "ConversationSettingsViewModel.search" -// ) -// ], -// [ -// Item( -// id: .allMedia, -// icon: UIImage(named: "actionsheet_camera_roll_black")?.withRenderingMode(.alwaysTemplate), -// title: MediaStrings.allMedia, -// accessibilityIdentifier: "ConversationSettingsViewModel.all_media" -// ), -// Item( -// id: .pinConversation, -// icon: UIImage(named: "settings_pin")?.withRenderingMode(.alwaysTemplate), -// title: "CONVERSATION_SETTINGS_PIN".localized(), -// accessibilityIdentifier: "ConversationSettingsViewModel.pin_conversation" -// ), -// Item( -// id: .disappearingMessages, -// icon: UIImage(named: "timer_55")?.withRenderingMode(.alwaysTemplate), -// title: "DISAPPEARING_MESSAGES".localized(), -// subtitle: "DISAPPEARING_MESSAGES_OFF".localized(), -// accessibilityIdentifier: "ConversationSettingsViewModel.disappearing_messages" -// ), -// Item( -// id: .notifications, -// icon: UIImage(named: "mute_unfilled")?.withRenderingMode(.alwaysTemplate), -// title: "CONVERSATION_SETTINGS_MUTE_ACTION_NEW".localized(), -// accessibilityIdentifier: "ConversationSettingsViewModel.mute" -// ) -// ], -// [ -// Item( -// id: .deleteMessages, -// icon: UIImage(named: "trash")?.withRenderingMode(.alwaysTemplate), -// title: "DELETE_MESSAGES".localized(), -// isNegativeAction: true, -// accessibilityIdentifier: "ConversationSettingsViewModel.delete_messages" -// ), -// Item( -// id: .blockUser, -// icon: UIImage(named: "table_ic_block")?.withRenderingMode(.alwaysTemplate), -// title: "CONVERSATION_SETTINGS_BLOCK_USER".localized(), -// isNegativeAction: true, -// accessibilityIdentifier: "ConversationSettingsViewModel.block" -// ) -// ] -// ] -// -// viewModel = ConversationSettingsViewModel(thread: thread, uiDatabaseConnection: uiDatabaseConnection, didTriggerSearch: { [weak self] in -// self?.didTriggerSearchCallbackTriggered = true -// }) -// } -// -// -// override func tearDownWithError() throws { -// disposables = nil -// didTriggerSearchCallbackTriggered = false -// publicKey = nil -// thread = nil -// uiDatabaseConnection = nil -// defaultContactThreadItems = nil -// viewModel = nil -// } -// -// // MARK: - Basic Tests -// // MARK: - Item -// -// func testTheItemGetsCreatedCorrectly() { -// let image: UIImage = UIImage() -// let item: Item = Item( -// id: .allMedia, -// style: .header, -// icon: image, -// title: "Test", -// subtitle: "TestSub", -// isEnabled: false, -// isEditing: true, -// isNegativeAction: true, -// accessibilityIdentifier: "TestAccessibility" -// ) -// -// expect(item.id).to(equal(.allMedia)) -// expect(item.style).to(equal(.header)) -// expect(item.icon).to(equal(image)) -// expect(item.title).to(equal("Test")) -// expect(item.subtitle).to(equal("TestSub")) -// expect(item.isEnabled).to(beFalse()) -// expect(item.isEditing).to(beTrue()) -// expect(item.isNegativeAction).to(beTrue()) -// expect(item.accessibilityIdentifier).to(equal("TestAccessibility")) -// } -// -// func testTheItemHasTheCorrectDefaultValues() { -// let item: Item = Item(id: .allMedia) -// -// expect(item.id).to(equal(.allMedia)) -// expect(item.style).to(equal(.standard)) -// expect(item.icon).to(beNil()) -// expect(item.title).to(equal("")) -// expect(item.subtitle).to(beNil()) -// expect(item.isEnabled).to(beTrue()) -// expect(item.isEditing).to(beFalse()) -// expect(item.isNegativeAction).to(beFalse()) -// expect(item.accessibilityIdentifier).to(beNil()) -// } -// -// // MARK: - ActionableItem -// -// func testTheActionableItemGetsCreatedCorrectly() { -// let item: Item = Item(id: .allMedia) -// let subject: PassthroughSubject = PassthroughSubject() -// let actionableItem: ActionableItem = ActionableItem( -// data: item, -// action: subject -// ) -// -// expect(actionableItem.data).to(equal(item)) -// expect(actionableItem.action).to(beIdenticalTo(subject)) -// } -// -// // MARK: - Basic Tests -// -// func testItHasTheCorrectTitleForAnIndividualThread() { -// expect(self.viewModel.title).to(equal("vc_settings_title".localized())) -// } -// -// -// func testItHasTheCorrectTitleForAGroupThread() { -// thread = TSGroupThread(uniqueId: "TestGroupId1") -// viewModel = ConversationSettingsViewModel(thread: thread, uiDatabaseConnection: uiDatabaseConnection, didTriggerSearch: { [weak self] in -// self?.didTriggerSearchCallbackTriggered = true -// }) -// -// -// expect(self.viewModel.title).to(equal("vc_group_settings_title".localized())) -// } -// -// -// // MARK: - All Conversation Type Shared Tests -// -// -// func testItTriggersTheSearchCallbackWhenInteractingWithSearch() { -// viewModel.interaction.tap(.search) -// -// expect(self.didTriggerSearchCallbackTriggered).to(beTrue()) -// viewModel.viewSearch.sink(receiveValue: { _ in }).store(in: &disposables) -// viewModel.searchTapped.send() -// -// expect(self.didTriggerSearchCallbackTriggered) -// .toEventually( -// beTrue(), -// timeout: .milliseconds(100) -// ) -// } -// -// -// func testItPinsAConversation() { -// viewModel.interaction.tap(.togglePinConversation) -// -// viewModel.items.sink(receiveValue: { _ in }).store(in: &disposables) -// viewModel.pinConversationTapped.send() -// -// expect(self.thread.isPinned) -// .toEventually( -// beTrue(), -// timeout: .milliseconds(100) -// ) -// } -// -// -// func testItUnPinsAConversation() { -// viewModel.interaction.tap(.togglePinConversation) -// thread.isPinned = true -// -// viewModel.items.sink(receiveValue: { _ in }).store(in: &disposables) -// viewModel.pinConversationTapped.send() -// -// expect(self.thread.isPinned) -// .toEventually( -// beTrue(), -// beFalse(), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItUpdatesTheItemTitleToReflectThePinnedState() { -// thread.isPinned = true -// -// viewModel.interaction.tap(.togglePinConversation) -// let itemsData = viewModel.items -// .map { sections in sections.map { section in section.map { $0.data } } } -// -// expect(self.thread.isPinned) -// expect(itemsData.newest) -// .toEventually( -// beFalse(), -// satisfyAllOf( -// haveCountGreaterThan(2), -// valueAt(2, haveCountGreaterThan(1)) -// ), -// timeout: .milliseconds(100) -// ) -// expect(itemsData.map { $0[2][1].title }.newest) -// .toEventually( -// equal("CONVERSATION_SETTINGS_UNPIN".localized()), -// timeout: .milliseconds(10000) -// ) -// } -// -// func testDeletingMessageShowsAndThensHidesTheLoadingState() { -// let replayLoadingState = viewModel.loadingStateVisible.shareReplay(2) -// replayLoadingState.sink(receiveValue: { _ in }).store(in: &disposables) -// -// viewModel.deleteMessages() -// -// expect(replayLoadingState.all) -// .toEventually( -// equal([ -// true, -// false -// ]), -// timeout: .milliseconds(100) -// ) -// } -// -// // MARK: - Individual & Note to Self Conversation Shared Tests -// -// -// func testItHasTheCorrectDefaultNavButtonsForAContactConversation() { -// expect(self.viewModel.leftNavItems.value).to(equal([])) -// expect(self.viewModel.rightNavItems.value) -// .to(equal([ -// ConversationSettingsViewModel.Item( -// id: .navEdit, -// style: .navigation, -// action: .startEditingDisplayName, -// icon: nil, -// title: "", -// barButtonItem: .edit, -// subtitle: nil, -// isEnabled: true, -// isNegativeAction: false, -// accessibilityIdentifier: "Edit button" -// ) -// ])) -// expect(self.viewModel.leftNavItems.newest) -// .toEventually( -// haveCount(0), -// timeout: .milliseconds(100) -// ) -// expect(self.viewModel.rightNavItems.map { items in items.map { $0.data } }.newest) -// .toEventually( -// equal([ -// NavItem( -// systemItem: .edit, -// accessibilityIdentifier: "Edit button" -// ) -// ]), -// timeout: .milliseconds(100) -// ) -// } -// -// -// func testItUpdatesTheNavButtonsWhenEnteringEditMode() { -// viewModel.interaction.tap(.startEditingDisplayName) -// -// expect(self.viewModel.leftNavItems.value) -// .to(equal([ -// ConversationSettingsViewModel.Item( -// id: .navCancel, -// style: .navigation, -// action: .cancelEditingDisplayName, -// icon: nil, -// title: "", -// barButtonItem: .cancel, -// subtitle: nil, -// isEnabled: true, -// isNegativeAction: false, -// accessibilityIdentifier: "Cancel button" -// ) -// ])) -// expect(self.viewModel.rightNavItems.value) -// .to(equal([ -// ConversationSettingsViewModel.Item( -// id: .navDone, -// style: .navigation, -// action: .saveUpdatedDisplayName, -// icon: nil, -// title: "", -// barButtonItem: .done, -// subtitle: nil, -// isEnabled: true, -// isNegativeAction: false, -// accessibilityIdentifier: "Done button" -// ) -// ])) -// let replayLeftNavItems = viewModel.leftNavItems.map { items in items.map { $0.data } }.shareReplay(1) -// let replayRightNavItems = viewModel.rightNavItems.map { items in items.map { $0.data } }.shareReplay(1) -// replayLeftNavItems.sink(receiveValue: { _ in }).store(in: &disposables) -// replayRightNavItems.sink(receiveValue: { _ in }).store(in: &disposables) -// -// viewModel.editDisplayNameTapped.send() -// -// expect(replayLeftNavItems.newest) -// .toEventually( -// equal([ -// NavItem( -// systemItem: .cancel, -// accessibilityIdentifier: "Cancel button" -// ) -// ]), -// timeout: .milliseconds(100) -// ) -// expect(replayRightNavItems.newest) -// .toEventually( -// equal([ -// NavItem( -// systemItem: .done, -// accessibilityIdentifier: "Done button" -// ) -// ]), -// timeout: .milliseconds(100) -// ) -// } -// -// -// func testItGoesBackToTheDefaultNavButtonsWhenYouCancelEditingTheDisplayName() { -// viewModel.interaction.tap(.startEditingDisplayName) -// -// expect(self.viewModel.leftNavItems.value.first?.id).to(equal(.navCancel)) -// -// viewModel.interaction.tap(.cancelEditingDisplayName) -// -// expect(self.viewModel.leftNavItems.value).to(equal([])) -// expect(self.viewModel.rightNavItems.value) -// .to(equal([ -// ConversationSettingsViewModel.Item( -// id: .navEdit, -// style: .navigation, -// action: .startEditingDisplayName, -// icon: nil, -// title: "", -// barButtonItem: .edit, -// subtitle: nil, -// isEnabled: true, -// isNegativeAction: false, -// accessibilityIdentifier: "Edit button" -// ) -// ])) -// let replayLeftNavItems = viewModel.leftNavItems.map { items in items.map { $0.data } }.shareReplay(1) -// let replayRightNavItems = viewModel.rightNavItems.map { items in items.map { $0.data } }.shareReplay(1) -// replayLeftNavItems.sink(receiveValue: { _ in }).store(in: &disposables) -// replayRightNavItems.sink(receiveValue: { _ in }).store(in: &disposables) -// -// // Change to editing state -// viewModel.editDisplayNameTapped.send() -// -// expect(replayLeftNavItems.newest) -// .toEventually( -// valueFor(\.systemItem, at: 0, to: equal(.cancel)), -// timeout: .milliseconds(100) -// ) -// -// // Change back -// viewModel.cancelEditDisplayNameTapped.send() -// -// expect(replayLeftNavItems.newest) -// .toEventually( -// haveCount(0), -// timeout: .milliseconds(100) -// ) -// expect(replayRightNavItems.newest) -// .toEventually( -// equal([ -// NavItem( -// systemItem: .edit, -// accessibilityIdentifier: "Edit button" -// ) -// ]), -// timeout: .milliseconds(100) -// ) -// } -// -// -// func testItGoesBackToTheDefaultNavButtonsWhenYouSaveTheUpdatedDisplayName() { -// viewModel.interaction.tap(.startEditingDisplayName) -// -// expect(self.viewModel.leftNavItems.value.first?.id).to(equal(.navCancel)) -// -// viewModel.interaction.tap(.saveUpdatedDisplayName) -// -// expect(self.viewModel.leftNavItems.value).to(equal([])) -// expect(self.viewModel.rightNavItems.value) -// .to(equal([ -// ConversationSettingsViewModel.Item( -// id: .navEdit, -// style: .navigation, -// action: .startEditingDisplayName, -// icon: nil, -// title: "", -// barButtonItem: .edit, -// subtitle: nil, -// isEnabled: true, -// isNegativeAction: false, -// accessibilityIdentifier: "Edit button" -// ) -// ])) -// let replayLeftNavItems = viewModel.leftNavItems.map { items in items.map { $0.data } }.shareReplay(1) -// let replayRightNavItems = viewModel.rightNavItems.map { items in items.map { $0.data } }.shareReplay(1) -// replayLeftNavItems.sink(receiveValue: { _ in }).store(in: &disposables) -// replayRightNavItems.sink(receiveValue: { _ in }).store(in: &disposables) -// -// // Change to editing state -// viewModel.editDisplayNameTapped.send() -// -// expect(replayLeftNavItems.newest) -// .toEventually( -// valueFor(\.systemItem, at: 0, to: equal(.cancel)), -// timeout: .milliseconds(100) -// ) -// -// // Change back -// viewModel.saveDisplayNameTapped.send() -// -// expect(replayLeftNavItems.newest) -// .toEventually( -// haveCount(0), -// timeout: .milliseconds(100) -// ) -// expect(replayRightNavItems.newest) -// .toEventually( -// equal([ -// NavItem( -// systemItem: .edit, -// accessibilityIdentifier: "Edit button" -// ) -// ]), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItHasTheCorrectDefaultState() throws { -// let itemsData = viewModel.items -// .map { sections in sections.map { section in section.map { $0.data } } } -// -// expect(itemsData.newest) -// .toEventually( -// equal(defaultContactThreadItems), -// timeout: .milliseconds(1000) -// ) -// } -// -// func testItUpdatesTheContactNicknameWhenSavingTheUpdatedDisplayName() { -// viewModel.interaction.tap(.startEditingDisplayName) -// viewModel.interaction.change(.changeDisplayName, data: "Test123") -// viewModel.interaction.tap(.saveUpdatedDisplayName) -// viewModel.leftNavItems.sink(receiveValue: { _ in }).store(in: &disposables) -// -// viewModel.displayName = "Test123" -// viewModel.saveDisplayNameTapped.send() -// -// expect(Storage.shared.getContact(with: "TestContactId")?.nickname) -// .toEventually( -// equal("Test123"), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItMutesAConversation() { -// viewModel.interaction.tap(.toggleMuteNotifications) -// -// -// func testItMutesAContactConversation() { -// viewModel.items.sink(receiveValue: { _ in }).store(in: &disposables) -// viewModel.notificationsTapped.send() -// -// expect(self.thread.isMuted) -// .toEventually( -// beTrue(), -// timeout: .milliseconds(100) -// ) -// } -// -// -// func testItUnMutesAConversation() { -// viewModel.interaction.tap(.toggleMuteNotifications) -// var hasWrittenToStorage: Bool = false -// -// Storage.write { transaction in -// self.thread.updateWithMuted( -// until: Date.distantFuture, -// transaction: transaction -// ) -// hasWrittenToStorage = true -// } -// -// // Note: Wait for the setup to complete -// expect(hasWrittenToStorage) -// .toEventually( -// beTrue(), -// timeout: .milliseconds(100) -// ) -// expect(self.thread.isMuted) -// .toEventually( -// beTrue(), -// timeout: .milliseconds(100) -// ) -// -// viewModel.interaction.tap(.toggleMuteNotifications) -// -// viewModel.items.sink(receiveValue: { _ in }).store(in: &disposables) -// viewModel.notificationsTapped.send() -// -// expect(self.thread.isMuted) -// .toEventually( -// beFalse(), -// timeout: .milliseconds(100) -// ) -// } -// -// -// // MARK: - Group Conversation Tests -// -// -// func testItHasNoCustomLeftNavButtons() { -// thread = TSGroupThread(uniqueId: "TestGroupId1") -// viewModel = ConversationSettingsViewModel(thread: thread, uiDatabaseConnection: uiDatabaseConnection, didTriggerSearch: { [weak self] in -// self?.didTriggerSearchCallbackTriggered = true -// }) -// -// expect(self.viewModel.leftNavItems.newest) -// .toEventually( -// haveCount(0), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItHasNoCustomRightNavButtons() { -// thread = TSGroupThread(uniqueId: "TestGroupId1") -// viewModel = ConversationSettingsViewModel(thread: thread, uiDatabaseConnection: uiDatabaseConnection, didTriggerSearch: { [weak self] in -// self?.didTriggerSearchCallbackTriggered = true -// }) -// -// expect(self.viewModel.rightNavItems.newest) -// .toEventually( -// haveCount(0), -// timeout: .milliseconds(100) -// ) -// } -// -// func testLeavingGroupShowsAndThensHidesTheLoadingState() { -// thread = TSGroupThread(uniqueId: "TestGroupId1") -// (thread as? TSGroupThread)?.groupModel = TSGroupModel( -// title: nil, -// memberIds: [], -// image: nil, -// groupId: "".data(using: .utf8)!, -// groupType: .closedGroup, -// adminIds: [] -// ) -// viewModel = ConversationSettingsViewModel(thread: thread, uiDatabaseConnection: uiDatabaseConnection, didTriggerSearch: { [weak self] in -// self?.didTriggerSearchCallbackTriggered = true -// }) -// -// let replayLoadingState = viewModel.loadingStateVisible.shareReplay(2) -// replayLoadingState.sink(receiveValue: { _ in }).store(in: &disposables) -// -// expect(self.viewModel.leftNavItems.value).to(equal([])) -// viewModel.leaveGroup() -// -// expect(replayLoadingState.all) -// .toEventually( -// equal([ -// true//, -// //false // TODO: Need to mock MessageSender for this to work -// ]), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItHasNoCustomRightNavButtons() { -// // MARK: - Transitions -// -// func testItViewsTheSearch() { -// let replayViewSearch = viewModel.viewSearch.shareReplay(1) -// replayViewSearch.sink(receiveValue: { _ in }).store(in: &disposables) -// -// viewModel.searchTapped.send() -// -// expect(replayViewSearch.all) -// .toEventually( -// haveCount(1), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItViewsAddToGroup() { -// let replayViewAddToGroup = viewModel.viewAddToGroup.shareReplay(1) -// replayViewAddToGroup.sink(receiveValue: { _ in }).store(in: &disposables) -// -// viewModel.addToGroupTapped.send() -// -// expect(replayViewAddToGroup.all) -// .toEventually( -// haveCount(1), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItViewsEditGroup() { -// let replayViewEditGroup = viewModel.viewEditGroup.shareReplay(1) -// replayViewEditGroup.sink(receiveValue: { _ in }).store(in: &disposables) -// -// viewModel.editGroupTapped.send() -// -// expect(replayViewEditGroup.all) -// .toEventually( -// haveCount(1), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItViewsAllMedia() { -// let replayViewAllMedia = viewModel.viewAllMedia.shareReplay(1) -// replayViewAllMedia.sink(receiveValue: { _ in }).store(in: &disposables) -// -// viewModel.viewAllMediaTapped.send() -// -// expect(replayViewAllMedia.all) -// .toEventually( -// haveCount(1), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItViewsDisappearingMessages() { -// let replayViewDisappearingMessages = viewModel.viewDisappearingMessages.shareReplay(1) -// replayViewDisappearingMessages.sink(receiveValue: { _ in }).store(in: &disposables) -// -// viewModel.disappearingMessagesTapped.send() -// -// expect(replayViewDisappearingMessages.all) -// .toEventually( -// haveCount(1), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItViewsNotificationSettings() { -// thread = TSGroupThread(uniqueId: "TestGroupId1") -// viewModel = ConversationSettingsViewModel(thread: thread, uiDatabaseConnection: uiDatabaseConnection, didTriggerSearch: { [weak self] in -// self?.didTriggerSearchCallbackTriggered = true -// }) -// -// let replayViewNotificationSettings = viewModel.viewNotificationSettings.shareReplay(1) -// replayViewNotificationSettings.sink(receiveValue: { _ in }).store(in: &disposables) -// -// viewModel.notificationsTapped.send() -// -// expect(replayViewNotificationSettings.all) -// .toEventually( -// haveCount(1), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItShowsTheDeleteMessagesAlert() { -// let replayViewDeleteMessagesAlert = viewModel.viewDeleteMessagesAlert.shareReplay(1) -// replayViewDeleteMessagesAlert.sink(receiveValue: { _ in }).store(in: &disposables) -// -// viewModel.deleteMessagesTapped.send() -// -// expect(replayViewDeleteMessagesAlert.all) -// .toEventually( -// haveCount(1), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItShowsTheLeaveGroupAlert() { -// thread = TSGroupThread(uniqueId: "TestGroupId1") -// viewModel = ConversationSettingsViewModel(thread: thread, uiDatabaseConnection: uiDatabaseConnection, didTriggerSearch: { [weak self] in -// self?.didTriggerSearchCallbackTriggered = true -// }) -// -// let replayViewLeaveGroupAlert = viewModel.viewLeaveGroupAlert.shareReplay(1) -// replayViewLeaveGroupAlert.sink(receiveValue: { _ in }).store(in: &disposables) -// -// viewModel.leaveGroupTapped.send() -// -// expect(replayViewLeaveGroupAlert.all) -// .toEventually( -// haveCount(1), -// timeout: .milliseconds(100) -// ) -// } -// -// func testItShowsTheBlockUserAlert() { -// let replayViewBlockUserAlert = viewModel.viewBlockUserAlert.shareReplay(1) -// replayViewBlockUserAlert.sink(receiveValue: { _ in }).store(in: &disposables) -// -// viewModel.blockTapped.send() -// -// expect(self.viewModel.rightNavItems.value).to(equal([])) -// expect(replayViewBlockUserAlert.all) -// .toEventually( -// haveCount(1), -// timeout: .milliseconds(100) -// ) -// } -// -// // TODO: Mock 'OWSProfileManager' to test 'viewProfilePicture' -// // TODO: Various item states depending on thread type -// // TODO: Group title options (need mocking?) -// // TODO: Notification item title options (need mocking?) -// // TODO: Delete All Messages (need mocking) -// // TODO: Add to Group (need mocking) -// // TODO: Leave Group (need mocking) -//} +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Combine +import GRDB +import Quick +import Nimble + +@testable import Session + +class ThreadSettingsViewModelSpec: QuickSpec { + typealias ParentType = SessionTableViewModel + + // MARK: - Spec + + override func spec() { + var mockStorage: Storage! + var mockGeneralCache: MockGeneralCache! + var cancellables: [AnyCancellable] = [] + var dependencies: Dependencies! + var viewModel: ThreadSettingsViewModel! + var didTriggerSearchCallbackTriggered: Bool = false + + describe("a ThreadSettingsViewModel") { + // MARK: - Configuration + + beforeEach { + mockStorage = SynchronousStorage( + customWriter: DatabaseQueue(), + customMigrations: [ + SNUtilitiesKit.migrations(), + SNSnodeKit.migrations(), + SNMessagingKit.migrations(), + SNUIKit.migrations() + ] + ) + mockGeneralCache = MockGeneralCache() + dependencies = Dependencies( + generalCache: Atomic(mockGeneralCache), + storage: mockStorage, + scheduler: .immediate + ) + mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(TestConstants.publicKey)") + mockStorage.write { db in + try SessionThread( + id: "TestId", + variant: .contact + ).insert(db) + + try Identity( + variant: .x25519PublicKey, + data: Data(hex: TestConstants.publicKey) + ).insert(db) + + try Profile( + id: "05\(TestConstants.publicKey)", + name: "TestMe" + ).insert(db) + + try Profile( + id: "TestId", + name: "TestUser" + ).insert(db) + } + viewModel = ThreadSettingsViewModel( + dependencies: dependencies, + threadId: "TestId", + threadVariant: .contact, + didTriggerSearch: { + didTriggerSearchCallbackTriggered = true + } + ) + cancellables.append( + viewModel.observableSettingsData + .receiveOnMain(immediately: true) + .sink( + receiveCompletion: { _ in }, + receiveValue: { viewModel.updateSettings($0) } + ) + ) + } + + afterEach { + cancellables.forEach { $0.cancel() } + + mockStorage = nil + cancellables = [] + dependencies = nil + viewModel = nil + didTriggerSearchCallbackTriggered = false + } + + // MARK: - Basic Tests + + context("with any conversation type") { + it("triggers the search callback when tapping search") { + viewModel.settingsData + .first(where: { $0.model == .content })? + .elements + .first(where: { $0.id == .searchConversation })? + .onTap?(nil) + + expect(didTriggerSearchCallbackTriggered).to(beTrue()) + } + + it("mutes a conversation") { + viewModel.settingsData + .first(where: { $0.model == .content })? + .elements + .first(where: { $0.id == .notificationMute })? + .onTap?(nil) + + expect( + mockStorage + .read { db in try SessionThread.fetchOne(db, id: "TestId") }? + .mutedUntilTimestamp + ) + .toNot(beNil()) + } + + it("unmutes a conversation") { + mockStorage.write { db in + try SessionThread + .updateAll( + db, + SessionThread.Columns.mutedUntilTimestamp.set(to: 1234567890) + ) + } + + expect( + mockStorage + .read { db in try SessionThread.fetchOne(db, id: "TestId") }? + .mutedUntilTimestamp + ) + .toNot(beNil()) + + viewModel.settingsData + .first(where: { $0.model == .content })? + .elements + .first(where: { $0.id == .notificationMute })? + .onTap?(nil) + + expect( + mockStorage + .read { db in try SessionThread.fetchOne(db, id: "TestId") }? + .mutedUntilTimestamp + ) + .to(beNil()) + } + } + + context("with a note-to-self conversation") { + beforeEach { + mockStorage.write { db in + try SessionThread.deleteAll(db) + + try SessionThread( + id: "05\(TestConstants.publicKey)", + variant: .contact + ).insert(db) + } + + viewModel = ThreadSettingsViewModel( + dependencies: dependencies, + threadId: "05\(TestConstants.publicKey)", + threadVariant: .contact, + didTriggerSearch: { + didTriggerSearchCallbackTriggered = true + } + ) + cancellables.append( + viewModel.observableSettingsData + .receiveOnMain(immediately: true) + .sink( + receiveCompletion: { _ in }, + receiveValue: { viewModel.updateSettings($0) } + ) + ) + } + + it("has the correct title") { + expect(viewModel.title).to(equal("vc_settings_title".localized())) + } + + it("starts in the standard nav state") { + expect(viewModel.navState.firstValue()) + .to(equal(.standard)) + + expect(viewModel.leftNavItems.firstValue()).to(equal([])) + expect(viewModel.rightNavItems.firstValue()) + .to(equal([ + ParentType.NavItem( + id: .edit, + systemItem: .edit, + accessibilityIdentifier: "Edit button" + ) + ])) + } + + it("has no mute button") { + expect( + viewModel.settingsData + .first(where: { $0.model == .content })? + .elements + .first(where: { $0.id == .notificationMute }) + ).to(beNil()) + } + + context("when entering edit mode") { + beforeEach { + viewModel.rightNavItems.firstValue()??.first?.action?() + + let leftAccessory: SessionCell.Accessory? = viewModel.settingsData.first? + .elements.first? + .leftAccessory + + switch leftAccessory { + case .threadInfo(_, _, _, _, let titleChanged): titleChanged?("TestNew") + default: break + } + } + + it("enters the editing state") { + expect(viewModel.navState.firstValue()) + .to(equal(.editing)) + + expect(viewModel.leftNavItems.firstValue()) + .to(equal([ + ParentType.NavItem( + id: .cancel, + systemItem: .cancel, + accessibilityIdentifier: "Cancel button" + ) + ])) + expect(viewModel.rightNavItems.firstValue()) + .to(equal([ + ParentType.NavItem( + id: .done, + systemItem: .done, + accessibilityIdentifier: "Done button" + ) + ])) + } + + context("when cancelling edit mode") { + beforeEach { + viewModel.leftNavItems.firstValue()??.first?.action?() + } + + it("exits editing mode") { + expect(viewModel.navState.firstValue()) + .to(equal(.standard)) + + expect(viewModel.leftNavItems.firstValue()).to(equal([])) + expect(viewModel.rightNavItems.firstValue()) + .to(equal([ + ParentType.NavItem( + id: .edit, + systemItem: .edit, + accessibilityIdentifier: "Edit button" + ) + ])) + } + + it("does not update the nickname for the current user") { + expect( + mockStorage + .read { db in + try Profile.fetchOne(db, id: "05\(TestConstants.publicKey)") + }? + .nickname + ) + .to(beNil()) + } + } + + context("when saving edit mode") { + beforeEach { + viewModel.rightNavItems.firstValue()??.first?.action?() + } + + it("exits editing mode") { + expect(viewModel.navState.firstValue()) + .to(equal(.standard)) + + expect(viewModel.leftNavItems.firstValue()).to(equal([])) + expect(viewModel.rightNavItems.firstValue()) + .to(equal([ + ParentType.NavItem( + id: .edit, + systemItem: .edit, + accessibilityIdentifier: "Edit button" + ) + ])) + } + + it("updates the nickname for the current user") { + expect( + mockStorage + .read { db in + try Profile.fetchOne(db, id: "05\(TestConstants.publicKey)") + }? + .nickname + ) + .to(equal("TestNew")) + } + } + } + } + + context("with a one-to-one conversation") { + beforeEach { + mockStorage.write { db in + try SessionThread.deleteAll(db) + + try SessionThread( + id: "TestId", + variant: .contact + ).insert(db) + } + } + + it("has the correct title") { + expect(viewModel.title).to(equal("vc_settings_title".localized())) + } + + it("starts in the standard nav state") { + expect(viewModel.navState.firstValue()) + .to(equal(.standard)) + + expect(viewModel.leftNavItems.firstValue()).to(equal([])) + expect(viewModel.rightNavItems.firstValue()) + .to(equal([ + ParentType.NavItem( + id: .edit, + systemItem: .edit, + accessibilityIdentifier: "Edit button" + ) + ])) + } + + context("when entering edit mode") { + beforeEach { + viewModel.rightNavItems.firstValue()??.first?.action?() + + let leftAccessory: SessionCell.Accessory? = viewModel.settingsData.first? + .elements.first? + .leftAccessory + + switch leftAccessory { + case .threadInfo(_, _, _, _, let titleChanged): titleChanged?("TestUserNew") + default: break + } + } + + it("enters the editing state") { + expect(viewModel.navState.firstValue()) + .to(equal(.editing)) + + expect(viewModel.leftNavItems.firstValue()) + .to(equal([ + ParentType.NavItem( + id: .cancel, + systemItem: .cancel, + accessibilityIdentifier: "Cancel button" + ) + ])) + expect(viewModel.rightNavItems.firstValue()) + .to(equal([ + ParentType.NavItem( + id: .done, + systemItem: .done, + accessibilityIdentifier: "Done button" + ) + ])) + } + + context("when cancelling edit mode") { + beforeEach { + viewModel.leftNavItems.firstValue()??.first?.action?() + } + + it("exits editing mode") { + expect(viewModel.navState.firstValue()) + .to(equal(.standard)) + + expect(viewModel.leftNavItems.firstValue()).to(equal([])) + expect(viewModel.rightNavItems.firstValue()) + .to(equal([ + ParentType.NavItem( + id: .edit, + systemItem: .edit, + accessibilityIdentifier: "Edit button" + ) + ])) + } + + it("does not update the nickname for the current user") { + expect( + mockStorage + .read { db in try Profile.fetchOne(db, id: "TestId") }? + .nickname + ) + .to(beNil()) + } + } + + context("when saving edit mode") { + beforeEach { + viewModel.rightNavItems.firstValue()??.first?.action?() + } + + it("exits editing mode") { + expect(viewModel.navState.firstValue()) + .to(equal(.standard)) + + expect(viewModel.leftNavItems.firstValue()).to(equal([])) + expect(viewModel.rightNavItems.firstValue()) + .to(equal([ + ParentType.NavItem( + id: .edit, + systemItem: .edit, + accessibilityIdentifier: "Edit button" + ) + ])) + } + + it("updates the nickname for the current user") { + expect( + mockStorage + .read { db in try Profile.fetchOne(db, id: "TestId") }? + .nickname + ) + .to(equal("TestUserNew")) + } + } + } + } + + context("with a group conversation") { + beforeEach { + mockStorage.write { db in + try SessionThread.deleteAll(db) + + try SessionThread( + id: "TestId", + variant: .closedGroup + ).insert(db) + } + + viewModel = ThreadSettingsViewModel( + dependencies: dependencies, + threadId: "TestId", + threadVariant: .closedGroup, + didTriggerSearch: { + didTriggerSearchCallbackTriggered = true + } + ) + cancellables.append( + viewModel.observableSettingsData + .receiveOnMain(immediately: true) + .sink( + receiveCompletion: { _ in }, + receiveValue: { viewModel.updateSettings($0) } + ) + ) + } + + it("has the correct title") { + expect(viewModel.title).to(equal("vc_group_settings_title".localized())) + } + + it("starts in the standard nav state") { + expect(viewModel.navState.firstValue()) + .to(equal(.standard)) + + expect(viewModel.leftNavItems.firstValue()).to(equal([])) + expect(viewModel.rightNavItems.firstValue()).to(equal([])) + } + } + + context("with a community conversation") { + beforeEach { + mockStorage.write { db in + try SessionThread.deleteAll(db) + + try SessionThread( + id: "TestId", + variant: .openGroup + ).insert(db) + } + + viewModel = ThreadSettingsViewModel( + dependencies: dependencies, + threadId: "TestId", + threadVariant: .openGroup, + didTriggerSearch: { + didTriggerSearchCallbackTriggered = true + } + ) + cancellables.append( + viewModel.observableSettingsData + .receiveOnMain(immediately: true) + .sink( + receiveCompletion: { _ in }, + receiveValue: { viewModel.updateSettings($0) } + ) + ) + } + + it("has the correct title") { + expect(viewModel.title).to(equal("vc_group_settings_title".localized())) + } + + it("starts in the standard nav state") { + expect(viewModel.navState.firstValue()) + .to(equal(.standard)) + + expect(viewModel.leftNavItems.firstValue()).to(equal([])) + expect(viewModel.rightNavItems.firstValue()).to(equal([])) + } + } + } + } +} diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 280b33a4b..f6c64bce6 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -6,7 +6,7 @@ import GRDB import PromiseKit import SignalCoreKit -public final class Storage { +open class Storage { private static let dbFileName: String = "Session.sqlite" private static let keychainService: String = "TSKeyChainService" private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec" @@ -305,17 +305,17 @@ public final class Storage { // MARK: - Functions - @discardableResult public func write(updates: (Database) throws -> T?) -> T? { + @discardableResult public final func write(updates: (Database) throws -> T?) -> T? { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } return try? dbWriter.write(updates) } - public func writeAsync(updates: @escaping (Database) throws -> T) { + open func writeAsync(updates: @escaping (Database) throws -> T) { writeAsync(updates: updates, completion: { _, _ in }) } - public func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result) throws -> Void) { + open func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result) throws -> Void) { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } dbWriter.asyncWrite( @@ -326,7 +326,7 @@ public final class Storage { ) } - @discardableResult public func read(_ value: (Database) throws -> T?) -> T? { + @discardableResult public final func read(_ value: (Database) throws -> T?) -> T? { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } return try? dbWriter.read(value) @@ -401,6 +401,7 @@ public extension Storage { } } + // FIXME: Can't overrwrite this in `SynchronousStorage` since it's in an extension @discardableResult func writeAsync(updates: @escaping (Database) throws -> Promise) -> Promise { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return Promise(error: StorageError.databaseInvalid) diff --git a/SessionUtilitiesKit/General/Dependencies.swift b/SessionUtilitiesKit/General/Dependencies.swift index a0c3f132c..e9c576639 100644 --- a/SessionUtilitiesKit/General/Dependencies.swift +++ b/SessionUtilitiesKit/General/Dependencies.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB open class Dependencies { public var _generalCache: Atomic?> @@ -15,6 +16,12 @@ open class Dependencies { set { _storage.mutate { $0 = newValue } } } + public var _scheduler: Atomic + public var scheduler: ValueObservationScheduler { + get { Dependencies.getValueSettingIfNull(&_scheduler) { Storage.defaultPublisherScheduler } } + set { _scheduler.mutate { $0 = newValue } } + } + public var _standardUserDefaults: Atomic public var standardUserDefaults: UserDefaultsType { get { Dependencies.getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } } @@ -32,11 +39,13 @@ open class Dependencies { public init( generalCache: Atomic? = nil, storage: Storage? = nil, + scheduler: ValueObservationScheduler? = nil, standardUserDefaults: UserDefaultsType? = nil, date: Date? = nil ) { _generalCache = Atomic(generalCache) _storage = Atomic(storage) + _scheduler = Atomic(scheduler) _standardUserDefaults = Atomic(standardUserDefaults) _date = Atomic(date) } @@ -52,12 +61,4 @@ open class Dependencies { return value } - -// 0 libswiftCore.dylib 0x00000001999fd40c _swift_release_dealloc + 32 (HeapObject.cpp:703) -// 1 SessionMessagingKit 0x0000000106aa958c 0x106860000 + 2397580 -// 2 libswiftCore.dylib 0x00000001999fd424 _swift_release_dealloc + 56 (HeapObject.cpp:703) -// 3 SessionUtilitiesKit 0x0000000106cbd980 static Dependencies.getValueSettingIfNull(_:_:) + 264 (Dependencies.swift:49) -// 4 SessionMessagingKit 0x0000000106aa90f4 closure #1 in SMKDependencies.sign.getter + 112 (SMKDependencies.swift:17) -// 5 SessionUtilitiesKit 0x0000000106cbd974 static Dependencies.getValueSettingIfNull(_:_:) + 252 (Dependencies.swift:48) -// 6 SessionMessagingKit 0x000000010697aef8 specialized static OpenGroupAPI.sign(_:messageBytes:for:fallbackSigningType:using:) + 1158904 (OpenGroupAPI.swift:1190) } diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift index 1524549c1..39bbbb913 100644 --- a/SessionUtilitiesKit/General/String+Utilities.swift +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -44,20 +44,6 @@ public extension String { return localizedString } - func dataFromHex() -> Data? { - guard self.count > 0 && (self.count % 2) == 0 else { return nil } - - let chars = self.map { $0 } - let bytes: [UInt8] = stride(from: 0, to: chars.count, by: 2) - .map { index -> String in String(chars[index]) + String(chars[index + 1]) } - .compactMap { (str: String) -> UInt8? in UInt8(str, radix: 16) } - - guard bytes.count > 0 else { return nil } - guard (self.count / bytes.count) == 2 else { return nil } - - return Data(bytes) - } - func ranges(of substring: String, options: CompareOptions = [], locale: Locale? = nil) -> [Range] { var ranges: [Range] = [] diff --git a/SignalUtilitiesKit/Shared Views/Toast.swift b/SignalUtilitiesKit/Shared Views/Toast.swift index 11c86e8b9..231dd800b 100644 --- a/SignalUtilitiesKit/Shared Views/Toast.swift +++ b/SignalUtilitiesKit/Shared Views/Toast.swift @@ -22,7 +22,11 @@ public class ToastController: ToastViewDelegate { // MARK: Public - public func presentToastView(fromBottomOfView view: UIView, inset: CGFloat) { + public func presentToastView( + fromBottomOfView view: UIView, + inset: CGFloat, + duration: DispatchTimeInterval = .milliseconds(1500) + ) { Logger.debug("") toastView.alpha = 0 view.addSubview(toastView) @@ -46,7 +50,7 @@ public class ToastController: ToastViewDelegate { self.toastView.alpha = 1 } - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { // intentional strong reference to self. // As with an AlertController, the caller likely expects toast to // be presented and dismissed without maintaining a strong reference to ToastController diff --git a/_SharedTestUtilities/CombineExtensions.swift b/_SharedTestUtilities/CombineExtensions.swift new file mode 100644 index 000000000..4a914cff8 --- /dev/null +++ b/_SharedTestUtilities/CombineExtensions.swift @@ -0,0 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine + +public extension AnyPublisher { + func firstValue() -> Output? { + var value: Output? + + _ = self + .receiveOnMain(immediately: true) + .sink( + receiveCompletion: { _ in }, + receiveValue: { result in value = result } + ) + + return value + } +} diff --git a/SharedTest/CommonMockedExtensions.swift b/_SharedTestUtilities/CommonMockedExtensions.swift similarity index 52% rename from SharedTest/CommonMockedExtensions.swift rename to _SharedTestUtilities/CommonMockedExtensions.swift index bbc01747b..052931525 100644 --- a/SharedTest/CommonMockedExtensions.swift +++ b/_SharedTestUtilities/CommonMockedExtensions.swift @@ -6,16 +6,16 @@ import Curve25519Kit extension Box.KeyPair: Mocked { static var mockValue: Box.KeyPair = Box.KeyPair( - publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, - secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + publicKey: Data(hex: TestConstants.publicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes ) } extension ECKeyPair: Mocked { static var mockValue: Self { try! Self.init( - publicKeyData: Data.data(fromHex: TestConstants.publicKey)!, - privateKeyData: Data.data(fromHex: TestConstants.privateKey)! + publicKeyData: Data(hex: TestConstants.publicKey), + privateKeyData: Data(hex: TestConstants.privateKey) ) } } diff --git a/SharedTest/Mock.swift b/_SharedTestUtilities/Mock.swift similarity index 100% rename from SharedTest/Mock.swift rename to _SharedTestUtilities/Mock.swift diff --git a/SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swift b/_SharedTestUtilities/MockGeneralCache.swift similarity index 100% rename from SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swift rename to _SharedTestUtilities/MockGeneralCache.swift diff --git a/SharedTest/NimbleExtensions.swift b/_SharedTestUtilities/NimbleExtensions.swift similarity index 100% rename from SharedTest/NimbleExtensions.swift rename to _SharedTestUtilities/NimbleExtensions.swift diff --git a/_SharedTestUtilities/SynchronousStorage.swift b/_SharedTestUtilities/SynchronousStorage.swift new file mode 100644 index 000000000..fdda5a983 --- /dev/null +++ b/_SharedTestUtilities/SynchronousStorage.swift @@ -0,0 +1,28 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +import SessionUtilitiesKit + +class SynchronousStorage: Storage { + override func writeAsync(updates: @escaping (Database) throws -> T) { + super.write(updates: updates) + } + + override func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result) throws -> Void) { + super.write { db in + do { + var result: T? + try db.inTransaction { + result = try updates(db) + return .commit + } + try? completion(db, .success(result!)) + } + catch { + try? completion(db, .failure(error)) + } + } + } +} diff --git a/SharedTest/TestConstants.swift b/_SharedTestUtilities/TestConstants.swift similarity index 100% rename from SharedTest/TestConstants.swift rename to _SharedTestUtilities/TestConstants.swift