From 27e098191308b4f0f6ef5f33646f821ddbeb6e48 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 5 Oct 2022 18:44:25 +1100 Subject: [PATCH] Added toast and info message deletion, fixed layout issues & unit tests Added a toast when hitting the emoji reacts rate limit Added the ability to delete info messages Fixed some odd layout behaviours with the VisibleMessageCell Fixed some layout issues with reactions Removed some unneeded custom code --- Session.xcodeproj/project.pbxproj | 36 +- .../xcshareddata/xcschemes/Session.xcscheme | 24 +- Session/Calls/CallVC.swift | 31 +- Session/Calls/VideoPreviewVC.swift | 31 +- .../Context Menu/ContextMenuVC+Action.swift | 15 +- .../Context Menu/ContextMenuVC.swift | 3 +- .../ConversationVC+Interaction.swift | 47 +- Session/Conversations/ConversationVC.swift | 21 +- .../Message Cells/CallMessageCell.swift | 59 +- .../Content Views/DocumentView.swift | 12 +- .../Content Views/ReactionContainerView.swift | 256 ++-- .../Content Views/ReactionView.swift | 66 +- .../Message Cells/InfoMessageCell.swift | 22 + .../Message Cells/MessageCell.swift | 3 +- .../Message Cells/VisibleMessageCell.swift | 167 ++- .../ThreadDisappearingMessagesViewModel.swift | 13 +- .../Settings/ThreadSettingsViewModel.swift | 121 +- .../Translations/de.lproj/Localizable.strings | 1 + .../Translations/en.lproj/Localizable.strings | 1 + .../Translations/es.lproj/Localizable.strings | 1 + .../Translations/fa.lproj/Localizable.strings | 1 + .../Translations/fi.lproj/Localizable.strings | 1 + .../Translations/fr.lproj/Localizable.strings | 1 + .../Translations/hi.lproj/Localizable.strings | 1 + .../Translations/hr.lproj/Localizable.strings | 1 + .../id-ID.lproj/Localizable.strings | 1 + .../Translations/it.lproj/Localizable.strings | 1 + .../Translations/ja.lproj/Localizable.strings | 1 + .../Translations/nl.lproj/Localizable.strings | 1 + .../Translations/pl.lproj/Localizable.strings | 1 + .../pt_BR.lproj/Localizable.strings | 1 + .../Translations/ru.lproj/Localizable.strings | 1 + .../Translations/si.lproj/Localizable.strings | 1 + .../Translations/sk.lproj/Localizable.strings | 1 + .../Translations/sv.lproj/Localizable.strings | 1 + .../Translations/th.lproj/Localizable.strings | 1 + .../vi-VN.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../zh_CN.lproj/Localizable.strings | 1 + Session/Settings/SettingsViewModel.swift | 109 +- .../Open Groups/OpenGroupAPI.swift | 7 +- .../Open Groups/OpenGroupManager.swift | 2 + .../Shared Models/MessageViewModel.swift | 10 +- .../Utilities/SMKDependencies.swift | 3 + .../Utilities/Sodium+Utilities.swift | 5 +- .../_TestUtilities/DependencyExtensions.swift | 3 + .../OGMDependencyExtensions.swift | 3 + ...eadDisappearingMessagesViewModelSpec.swift | 55 +- .../ThreadSettingsViewModelSpec.swift | 1291 +++++++---------- SessionUtilitiesKit/Database/Storage.swift | 11 +- .../General/Dependencies.swift | 17 +- .../General/String+Utilities.swift | 14 - SignalUtilitiesKit/Shared Views/Toast.swift | 8 +- _SharedTestUtilities/CombineExtensions.swift | 19 + .../CommonMockedExtensions.swift | 8 +- .../Mock.swift | 0 .../MockGeneralCache.swift | 0 .../NimbleExtensions.swift | 0 _SharedTestUtilities/SynchronousStorage.swift | 28 + .../TestConstants.swift | 0 60 files changed, 1280 insertions(+), 1262 deletions(-) create mode 100644 _SharedTestUtilities/CombineExtensions.swift rename {SharedTest => _SharedTestUtilities}/CommonMockedExtensions.swift (52%) rename {SharedTest => _SharedTestUtilities}/Mock.swift (100%) rename {SessionMessagingKitTests/_TestUtilities => _SharedTestUtilities}/MockGeneralCache.swift (100%) rename {SharedTest => _SharedTestUtilities}/NimbleExtensions.swift (100%) create mode 100644 _SharedTestUtilities/SynchronousStorage.swift rename {SharedTest => _SharedTestUtilities}/TestConstants.swift (100%) 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