From d9a4c6e83707c783227e34a9c1063531f76cb4b5 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Thu, 1 Nov 2018 14:08:49 -0600 Subject: [PATCH 01/12] typing indicator upgrade screen --- Signal.xcodeproj/project.pbxproj | 4 + ...ExperienceUpgradesPageViewController.swift | 121 ++++++++++++++++++ .../ExperienceUpgradeFinder.swift | 13 +- .../translations/en.lproj/Localizable.strings | 11 +- 4 files changed, 146 insertions(+), 3 deletions(-) diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index de3b60800..39439a41d 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -425,6 +425,7 @@ 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */; }; 4C11AA5020FD59C700351FBD /* MessageStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C11AA4F20FD59C700351FBD /* MessageStatusView.swift */; }; 4C13C9F620E57BA30089A98B /* ColorPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C13C9F520E57BA30089A98B /* ColorPickerViewController.swift */; }; + 4C1D233D218B96A000A0598F /* typing-animation.gif in Resources */ = {isa = PBXBuildFile; fileRef = 4C1D233C218B96A000A0598F /* typing-animation.gif */; }; 4C20B2B720CA0034001BAC90 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */; }; 4C20B2B920CA10DE001BAC90 /* ConversationSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */; }; 4C23A5F2215C4ADE00534937 /* SheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C23A5F1215C4ADE00534937 /* SheetViewController.swift */; }; @@ -1115,6 +1116,7 @@ 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HapticFeedback.swift; path = UserInterface/HapticFeedback.swift; sourceTree = ""; }; 4C11AA4F20FD59C700351FBD /* MessageStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageStatusView.swift; sourceTree = ""; }; 4C13C9F520E57BA30089A98B /* ColorPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerViewController.swift; sourceTree = ""; }; + 4C1D233C218B96A000A0598F /* typing-animation.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; name = "typing-animation.gif"; path = "../../../../../Downloads/typing-animation.gif"; sourceTree = ""; }; 4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSearchViewController.swift; sourceTree = ""; }; 4C23A5F1215C4ADE00534937 /* SheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetViewController.swift; sourceTree = ""; }; 4C2F454E214C00E1004871FF /* AvatarTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarTableViewCell.swift; sourceTree = ""; }; @@ -2261,6 +2263,7 @@ B633C4FD1A1D190B0059AC12 /* Images */ = { isa = PBXGroup; children = ( + 4C1D233C218B96A000A0598F /* typing-animation.gif */, AD83FF461A73428300B5C81A /* audio_play_button_blue.png */, AD83FF381A73426500B5C81A /* audio_pause_button_blue.png */, AD83FF391A73426500B5C81A /* audio_pause_button_blue@2x.png */, @@ -2856,6 +2859,7 @@ 45B74A742044AAB600CD42F8 /* aurora-quiet.aifc in Resources */, 45B74A852044AAB600CD42F8 /* bamboo.aifc in Resources */, 45B74A782044AAB600CD42F8 /* bamboo-quiet.aifc in Resources */, + 4C1D233D218B96A000A0598F /* typing-animation.gif in Resources */, 45B74A7B2044AAB600CD42F8 /* chord.aifc in Resources */, 45B74A812044AAB600CD42F8 /* chord-quiet.aifc in Resources */, 45B74A832044AAB600CD42F8 /* circles.aifc in Resources */, diff --git a/Signal/src/ViewControllers/ExperienceUpgradesPageViewController.swift b/Signal/src/ViewControllers/ExperienceUpgradesPageViewController.swift index f7c104b69..731da061f 100644 --- a/Signal/src/ViewControllers/ExperienceUpgradesPageViewController.swift +++ b/Signal/src/ViewControllers/ExperienceUpgradesPageViewController.swift @@ -225,6 +225,125 @@ private class IntroductingReadReceiptsExperienceUpgradeViewController: Experienc } } +private class IntroductingTypingIndicatorsExperienceUpgradeViewController: ExperienceUpgradeViewController { + + var buttonAction: ((UIButton) -> Void)? + + override func loadView() { + self.view = UIView.container() + + /// Create Views + + // Title label + let titleLabel = UILabel() + view.addSubview(titleLabel) + titleLabel.text = header + titleLabel.textAlignment = .center + titleLabel.font = UIFont.ows_regularFont(withSize: ScaleFromIPhone5(24)) + titleLabel.textColor = UIColor.white + titleLabel.minimumScaleFactor = 0.5 + titleLabel.adjustsFontSizeToFitWidth = true + + // Body label + let bodyLabel = UILabel() + self.bodyLabel = bodyLabel + view.addSubview(bodyLabel) + bodyLabel.text = body + bodyLabel.font = UIFont.ows_lightFont(withSize: ScaleFromIPhone5To7Plus(17, 22)) + bodyLabel.textColor = Theme.primaryColor + bodyLabel.numberOfLines = 0 + bodyLabel.lineBreakMode = .byWordWrapping + bodyLabel.textAlignment = .center + + // Image + + let imageView: UIView + if let gifPath = Bundle.main.path(forResource: "typing-animation", ofType: "gif") { + let animatedImage = YYImage(contentsOfFile: gifPath) + imageView = YYAnimatedImageView(image: animatedImage) + } else { + owsFailDebug("gifPath was unexpectedly nil") + imageView = UIImageView(image: image) + } + + view.addSubview(imageView) + imageView.contentMode = .scaleAspectFit + + let buttonTitle = NSLocalizedString("UPGRADE_EXPERIENCE_INTRODUCING_TYPING_INDICATOR_PRIVACY_SETTINGS", comment: "button label shown one time, after upgrade") + let button = addButton(title: buttonTitle) { _ in + // dismiss the modally presented view controller, then proceed. + self.experienceUpgradesPageViewController.dismiss(animated: true) { + guard let fromViewController = UIApplication.shared.frontmostViewController as? HomeViewController else { + owsFailDebug("unexpected frontmostViewController: \(String(describing: UIApplication.shared.frontmostViewController))") + return + } + + // Construct the "settings" view & push the "privacy settings" view. + let navigationController = AppSettingsViewController.inModalNavigationController() + navigationController.pushViewController(PrivacySettingsTableViewController(), animated: false) + + fromViewController.present(navigationController, animated: true) + } + } + + let bottomSpacer = UIView() + view.addSubview(bottomSpacer) + + /// Layout Views + + // Image layout + imageView.autoAlignAxis(toSuperviewAxis: .vertical) + imageView.autoPinToSquareAspectRatio() + imageView.autoPinEdge(.top, to: .bottom, of: titleLabel, withOffset: ScaleFromIPhone5To7Plus(36, 40)) + imageView.autoSetDimension(.width, toSize: ScaleFromIPhone5(180)) + + // Title label layout + titleLabel.autoSetDimension(.height, toSize: ScaleFromIPhone5(40)) + titleLabel.autoPinWidthToSuperview(withMargin: ScaleFromIPhone5To7Plus(16, 24)) + titleLabel.autoPinTopToSuperviewMargin() + + // Body label layout + bodyLabel.autoPinEdge(.top, to: .bottom, of: imageView, withOffset: ScaleFromIPhone5To7Plus(18, 28)) + bodyLabel.autoPinWidthToSuperview(withMargin: bodyMargin) + bodyLabel.setContentHuggingVerticalHigh() + + // Button layout + button.autoPinEdge(.top, to: .bottom, of: bodyLabel, withOffset: ScaleFromIPhone5(16)) + button.autoPinWidthToSuperview(withMargin: ScaleFromIPhone5(32)) + + bottomSpacer.autoPinEdge(.top, to: .bottom, of: button, withOffset: ScaleFromIPhone5(16)) + bottomSpacer.autoPinEdge(toSuperviewEdge: .bottom) + bottomSpacer.autoPinWidthToSuperview() + } + + // MARK: - Actions + + func addButton(title: String, action: @escaping (UIButton) -> Void) -> UIButton { + self.buttonAction = action + let button = MultiLineButton() + view.addSubview(button) + button.setTitle(title, for: .normal) + button.setTitleColor(UIColor.ows_signalBrandBlue, for: .normal) + button.isUserInteractionEnabled = true + button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside) + button.contentEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) + button.titleLabel?.textAlignment = .center + button.titleLabel?.font = UIFont.ows_mediumFont(withSize: ScaleFromIPhone5(18)) + return button + } + + @objc func didTapButton(sender: UIButton) { + Logger.debug("") + + guard let buttonAction = self.buttonAction else { + owsFailDebug("button action was nil") + return + } + + buttonAction(sender) + } +} + /** * Allows multiple lines of button text, and ensures the buttons intrinsic content size reflects that of it's label. */ @@ -667,6 +786,8 @@ public class ExperienceUpgradesPageViewController: OWSViewController, UIPageView return IntroductingReadReceiptsExperienceUpgradeViewController(experienceUpgrade: experienceUpgrade, experienceUpgradesPageViewController: self) case .introducingCustomNotificationAudio: return IntroducingCustomNotificationAudioExperienceUpgradeViewController(experienceUpgrade: experienceUpgrade, experienceUpgradesPageViewController: self) + case .introducingTypingIndicators: + return IntroductingTypingIndicatorsExperienceUpgradeViewController(experienceUpgrade: experienceUpgrade, experienceUpgradesPageViewController: self) default: return ExperienceUpgradeViewController(experienceUpgrade: experienceUpgrade, experienceUpgradesPageViewController: self) } diff --git a/Signal/src/environment/ExperienceUpgrades/ExperienceUpgradeFinder.swift b/Signal/src/environment/ExperienceUpgrades/ExperienceUpgradeFinder.swift index 859809d8b..03ccb1cef 100644 --- a/Signal/src/environment/ExperienceUpgrades/ExperienceUpgradeFinder.swift +++ b/Signal/src/environment/ExperienceUpgrades/ExperienceUpgradeFinder.swift @@ -10,7 +10,8 @@ enum ExperienceUpgradeId: String { callKit = "002", introducingProfiles = "003", introducingReadReceipts = "004", - introducingCustomNotificationAudio = "005" + introducingCustomNotificationAudio = "005", + introducingTypingIndicators = "006" } @objc public class ExperienceUpgradeFinder: NSObject { @@ -61,6 +62,13 @@ enum ExperienceUpgradeId: String { image: #imageLiteral(resourceName: "introductory_splash_custom_audio")) } + var typingIndicators: ExperienceUpgrade { + return ExperienceUpgrade(uniqueId: ExperienceUpgradeId.introducingTypingIndicators.rawValue, + title: NSLocalizedString("UPGRADE_EXPERIENCE_INTRODUCING_TYPING_INDICATORS_TITLE", comment: "Header for upgrading users"), + body: NSLocalizedString("UPGRADE_EXPERIENCE_INTRODUCING_TYPING_INDICATORS_DESCRIPTION", comment: "Body text for upgrading users"), + image: #imageLiteral(resourceName: "introductory_splash_custom_audio")) + } + // Keep these ordered by increasing uniqueId. @objc public var allExperienceUpgrades: [ExperienceUpgrade] { @@ -73,7 +81,8 @@ enum ExperienceUpgradeId: String { // (UIDevice.current.supportsCallKit ? callKit : nil), // introducingProfiles, // introducingReadReceipts, - configurableNotificationAudio + // configurableNotificationAudio + typingIndicators ].compactMap { $0 } } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index e83999b99..11ce3a134 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1161,7 +1161,7 @@ /* Confirmation button text to delete selected media message from the gallery */ "MEDIA_GALLERY_DELETE_SINGLE_MESSAGE" = "Delete Message"; -/* embeds {{sender name}} and {{sent date}}, e.g. 'Sarah on 10/30/18, 3:29' */ +/* embeds {{sender name}} and {{sent datetime}}, e.g. 'Sarah on 10/30/18, 3:29' */ "MEDIA_GALLERY_LANDSCAPE_TITLE_FORMAT" = "%@ on %@"; /* Short sender label for media sent by you */ @@ -2303,6 +2303,15 @@ /* Header for upgrade experience */ "UPGRADE_EXPERIENCE_INTRODUCING_READ_RECEIPTS_TITLE" = "Introducing Read Receipts"; +/* button label shown one time, after upgrade */ +"UPGRADE_EXPERIENCE_INTRODUCING_TYPING_INDICATOR_PRIVACY_SETTINGS" = "Enable typing indicators in your privacy settings."; + +/* Body text for upgrading users */ +"UPGRADE_EXPERIENCE_INTRODUCING_TYPING_INDICATORS_DESCRIPTION" = "Now you can optionally see and share when messages are being typed."; + +/* Header for upgrading users */ +"UPGRADE_EXPERIENCE_INTRODUCING_TYPING_INDICATORS_TITLE" = "Introducing Typing Indicators"; + /* Description of video calling to upgrading (existing) users */ "UPGRADE_EXPERIENCE_VIDEO_DESCRIPTION" = "Signal now supports secure video calling. Just start a call like normal, tap the camera button, and wave hello."; From b063a49d561905b62cc43e46c8052d5e2c5de55b Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Wed, 31 Oct 2018 13:24:27 -0400 Subject: [PATCH 02/12] Rework typing indicators API. --- .../src/Util/TypingIndicators.swift | 112 +++++++++++------- 1 file changed, 70 insertions(+), 42 deletions(-) diff --git a/SignalServiceKit/src/Util/TypingIndicators.swift b/SignalServiceKit/src/Util/TypingIndicators.swift index b10ec2d7d..5967a76c2 100644 --- a/SignalServiceKit/src/Util/TypingIndicators.swift +++ b/SignalServiceKit/src/Util/TypingIndicators.swift @@ -24,9 +24,14 @@ public protocol TypingIndicators: class { @objc func didReceiveIncomingMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) + // Returns the recipient id of the user who should currently be shown typing for a given thread. + // + // If no one is typing in that thread, returns nil. + // If multiple users are typing in that thread, returns the user to show. + // // TODO: Use this method. @objc - func areTypingIndicatorsVisible(inThread thread: TSThread, recipientId: String) -> Bool + func typingIndicators(forThread thread: TSThread, recipientId: String) -> String? @objc func setTypingIndicatorsEnabled(value: Bool) @@ -145,19 +150,35 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators { } @objc - public func areTypingIndicatorsVisible(inThread thread: TSThread, recipientId: String) -> Bool { + public func typingIndicators(forThread thread: TSThread, recipientId: String) -> String? { AssertIsOnMainThread() - let key = incomingIndicatorsKey(forThread: thread, recipientId: recipientId) - guard let deviceMap = incomingIndicatorsMap[key] else { - return false + var firstRecipientId: String? + var firstTimestamp: UInt64? + + let threadKey = incomingIndicatorsKey(forThread: thread) + guard let deviceMap = incomingIndicatorsMap[threadKey] else { + // No devices are typing in this thread. + return nil } for incomingIndicators in deviceMap.values { - if incomingIndicators.isTyping { - return true + guard incomingIndicators.isTyping else { + continue } + guard let startedTypingTimestamp = incomingIndicators.startedTypingTimestamp else { + owsFailDebug("Typing device is missing start timestamp.") + continue + } + if let firstTimestamp = firstTimestamp, + firstTimestamp < startedTypingTimestamp { + // More than one recipient/device is typing in this conversation; + // prefer the one that started typing first. + continue + } + firstRecipientId = incomingIndicators.recipientId + firstTimestamp = startedTypingTimestamp } - return false + return firstRecipientId } // MARK: - @@ -316,27 +337,32 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators { // MARK: - - // Map of (thread id and recipient id)-to-(device id)-to-IncomingIndicators. - private var incomingIndicatorsMap = [String: [UInt: IncomingIndicators]]() + // Map of (thread id)-to-(recipient id and device id)-to-IncomingIndicators. + private var incomingIndicatorsMap = [String: [String: IncomingIndicators]]() + + private func incomingIndicatorsKey(forThread thread: TSThread) -> String { + return String(describing: thread.uniqueId) + } - private func incomingIndicatorsKey(forThread thread: TSThread, recipientId: String) -> String { - return "\(String(describing: thread.uniqueId)) \(recipientId)" + private func incomingIndicatorsKey(recipientId: String, deviceId: UInt) -> String { + return "\(recipientId) \(deviceId)" } private func ensureIncomingIndicators(forThread thread: TSThread, recipientId: String, deviceId: UInt) -> IncomingIndicators { AssertIsOnMainThread() - let key = incomingIndicatorsKey(forThread: thread, recipientId: recipientId) - guard let deviceMap = incomingIndicatorsMap[key] else { - let incomingIndicators = IncomingIndicators(delegate: self, recipientId: recipientId, deviceId: deviceId) - incomingIndicatorsMap[key] = [deviceId: incomingIndicators] + let threadKey = incomingIndicatorsKey(forThread: thread) + let deviceKey = incomingIndicatorsKey(recipientId: recipientId, deviceId: deviceId) + guard let deviceMap = incomingIndicatorsMap[threadKey] else { + let incomingIndicators = IncomingIndicators(delegate: self, thread: thread, recipientId: recipientId, deviceId: deviceId) + incomingIndicatorsMap[threadKey] = [deviceKey: incomingIndicators] return incomingIndicators } - guard let incomingIndicators = deviceMap[deviceId] else { - let incomingIndicators = IncomingIndicators(delegate: self, recipientId: recipientId, deviceId: deviceId) + guard let incomingIndicators = deviceMap[deviceKey] else { + let incomingIndicators = IncomingIndicators(delegate: self, thread: thread, recipientId: recipientId, deviceId: deviceId) var deviceMapCopy = deviceMap - deviceMapCopy[deviceId] = incomingIndicators - incomingIndicatorsMap[key] = deviceMapCopy + deviceMapCopy[deviceKey] = incomingIndicators + incomingIndicatorsMap[threadKey] = deviceMapCopy return incomingIndicators } return incomingIndicators @@ -345,9 +371,12 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators { // The receiver maintains one timer for each (sender, device) in a chat: private class IncomingIndicators { private weak var delegate: TypingIndicators? - private let recipientId: String + private let thread: TSThread + fileprivate let recipientId: String private let deviceId: UInt private var displayTypingTimer: Timer? + fileprivate var startedTypingTimestamp: UInt64? + var isTyping = false { didSet { AssertIsOnMainThread() @@ -361,8 +390,10 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators { } } - init(delegate: TypingIndicators, recipientId: String, deviceId: UInt) { + init(delegate: TypingIndicators, thread: TSThread, + recipientId: String, deviceId: UInt) { self.delegate = delegate + self.thread = thread self.recipientId = recipientId self.deviceId = deviceId } @@ -381,43 +412,37 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators { selector: #selector(IncomingIndicators.displayTypingTimerDidFire), userInfo: nil, repeats: false) + if !isTyping { + startedTypingTimestamp = NSDate.ows_millisecondTimeStamp() + } isTyping = true } func didReceiveTypingStoppedMessage() { AssertIsOnMainThread() - // If the client receives a ACTION=STOPPED message: - // - // Cancel the displayTyping timer for that (sender, device) - // Hide the typing indicator for that (sender, device) - displayTypingTimer?.invalidate() - displayTypingTimer = nil - isTyping = false + clearTyping() } @objc func displayTypingTimerDidFire() { AssertIsOnMainThread() - // If the displayTyping indicator fires: - // - // Cancel the displayTyping timer for that (sender, device) - // Hide the typing indicator for that (sender, device) - displayTypingTimer?.invalidate() - displayTypingTimer = nil - isTyping = false + clearTyping() } func didReceiveIncomingMessage() { AssertIsOnMainThread() - // If the client receives a message: - // - // Cancel the displayTyping timer for that (sender, device) - // Hide the typing indicator for that (sender, device) + clearTyping() + } + + private func clearTyping() { + AssertIsOnMainThread() + displayTypingTimer?.invalidate() displayTypingTimer = nil + startedTypingTimestamp = nil isTyping = false } @@ -434,8 +459,11 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators { guard delegate.areTypingIndicatorsEnabled() else { return } - - NotificationCenter.default.postNotificationNameAsync(TypingIndicatorsImpl.typingIndicatorStateDidChange, object: recipientId) + guard let threadId = thread.uniqueId else { + owsFailDebug("Thread is missing id.") + return + } + NotificationCenter.default.postNotificationNameAsync(TypingIndicatorsImpl.typingIndicatorStateDidChange, object: threadId) } } } From 50381cc94cfb503e5715ba882aa28ae437db98dc Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 1 Nov 2018 10:43:13 -0400 Subject: [PATCH 03/12] Add typing indicators in home view. --- Signal.xcodeproj/project.pbxproj | 8 +- .../ViewControllers/HomeView/HomeViewCell.m | 59 +++++++++- Signal/src/views/TypingIndicatorView.swift | 101 ++++++++++++++++++ .../src/Util/TypingIndicators.swift | 7 +- 4 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 Signal/src/views/TypingIndicatorView.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 39439a41d..5ecf813a3 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -216,6 +216,7 @@ 34B3F8821E8DF1700035BE1A /* NewContactThreadViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8501E8DF1700035BE1A /* NewContactThreadViewController.m */; }; 34B3F8851E8DF1700035BE1A /* NewGroupViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8551E8DF1700035BE1A /* NewGroupViewController.m */; }; 34B3F8931E8DF1710035BE1A /* SignalsNavigationController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F86E1E8DF1700035BE1A /* SignalsNavigationController.m */; }; + 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */; }; 34B6D27420F664C900765BE2 /* OWSUnreadIndicator.h in Headers */ = {isa = PBXBuildFile; fileRef = 34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */; settings = {ATTRIBUTES = (Public, ); }; }; 34B6D27520F664C900765BE2 /* OWSUnreadIndicator.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */; }; 34BECE2B1F74C12700D7438D /* DebugUIStress.m in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2A1F74C12700D7438D /* DebugUIStress.m */; }; @@ -866,6 +867,7 @@ 34B3F8551E8DF1700035BE1A /* NewGroupViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NewGroupViewController.m; sourceTree = ""; }; 34B3F86D1E8DF1700035BE1A /* SignalsNavigationController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalsNavigationController.h; sourceTree = ""; }; 34B3F86E1E8DF1700035BE1A /* SignalsNavigationController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SignalsNavigationController.m; sourceTree = ""; }; + 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; 34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSUnreadIndicator.h; sourceTree = ""; }; 34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSUnreadIndicator.m; sourceTree = ""; }; 34BECE291F74C12700D7438D /* DebugUIStress.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUIStress.h; sourceTree = ""; }; @@ -2215,10 +2217,11 @@ isa = PBXGroup; children = ( 452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */, - 4C2F454E214C00E1004871FF /* AvatarTableViewCell.swift */, 34E3E5671EC4B19400495BAC /* AudioProgressView.swift */, + 4C2F454E214C00E1004871FF /* AvatarTableViewCell.swift */, 451764291DE939FD00EDB8B9 /* ContactCell.swift */, 4523149F1F7E9E18003A428C /* DirectionalPanGestureRecognizer.swift */, + 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */, 45A663C41F92EC760027B59E /* GroupTableViewCell.swift */, 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */, 34386A53207D271C009F5D9C /* NeverClearView.swift */, @@ -2234,8 +2237,8 @@ 45A6DAD51EBBF85500893231 /* ReminderView.swift */, 450D19111F85236600970622 /* RemoteVideoView.h */, 450D19121F85236600970622 /* RemoteVideoView.m */, - 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */, 4CA5F792211E1F06008C2708 /* Toast.swift */, + 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */, ); name = Views; path = views; @@ -3382,6 +3385,7 @@ 457C87B82032645C008D52D6 /* DebugUINotifications.swift in Sources */, 4C13C9F620E57BA30089A98B /* ColorPickerViewController.swift in Sources */, 4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */, + 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */, 340FC8D0205BF2FA007AEB0F /* OWSBackupIO.m in Sources */, 458E38371D668EBF0094BD24 /* OWSDeviceProvisioningURLParser.m in Sources */, 4517642B1DE939FD00EDB8B9 /* ContactCell.swift in Sources */, diff --git a/Signal/src/ViewControllers/HomeView/HomeViewCell.m b/Signal/src/ViewControllers/HomeView/HomeViewCell.m index 224575dc6..45691640c 100644 --- a/Signal/src/ViewControllers/HomeView/HomeViewCell.m +++ b/Signal/src/ViewControllers/HomeView/HomeViewCell.m @@ -22,6 +22,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) UILabel *snippetLabel; @property (nonatomic) UILabel *dateTimeLabel; @property (nonatomic) MessageStatusView *messageStatusView; +@property (nonatomic) TypingIndicatorView *typingIndicatorView; +@property (nonatomic) UIStackView *previewStackView; @property (nonatomic) UIView *unreadBadge; @property (nonatomic) UILabel *unreadLabel; @@ -45,6 +47,11 @@ NS_ASSUME_NONNULL_BEGIN return Environment.shared.contactsManager; } +- (id)typingIndicators +{ + return SSKEnvironment.shared.typingIndicators; +} + #pragma mark - - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(nullable NSString *)reuseIdentifier @@ -111,15 +118,25 @@ NS_ASSUME_NONNULL_BEGIN self.snippetLabel.font = [self snippetFont]; self.snippetLabel.numberOfLines = 1; self.snippetLabel.lineBreakMode = NSLineBreakByTruncatingTail; - [self.snippetLabel setContentHuggingHorizontalLow]; - [self.snippetLabel setCompressionResistanceHorizontalLow]; - UIStackView *bottomRowView = [[UIStackView alloc] initWithArrangedSubviews:@[ + self.typingIndicatorView = [TypingIndicatorView new]; + + self.previewStackView = [[UIStackView alloc] initWithArrangedSubviews:@[ self.snippetLabel, + self.typingIndicatorView, + ]]; + self.previewStackView.axis = UILayoutConstraintAxisVertical; + self.previewStackView.alignment = UIStackViewAlignmentLeading; + [self.previewStackView setContentHuggingHorizontalLow]; + [self.previewStackView setCompressionResistanceHorizontalLow]; + + UIStackView *bottomRowView = [[UIStackView alloc] initWithArrangedSubviews:@[ + self.previewStackView, self.messageStatusView, ]]; + bottomRowView.axis = UILayoutConstraintAxisHorizontal; - bottomRowView.alignment = UIStackViewAlignmentLastBaseline; + bottomRowView.alignment = UIStackViewAlignmentCenter; bottomRowView.spacing = 6.f; UIStackView *vStackView = [[UIStackView alloc] initWithArrangedSubviews:@[ @@ -203,6 +220,10 @@ NS_ASSUME_NONNULL_BEGIN selector:@selector(otherUsersProfileDidChange:) name:kNSNotificationName_OtherUsersProfileDidChange object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(typingIndicatorStateDidChange:) + name:[OWSTypingIndicatorsImpl typingIndicatorStateDidChange] + object:nil]; [self updateNameLabel]; [self updateAvatarView]; @@ -215,7 +236,13 @@ NS_ASSUME_NONNULL_BEGIN } else { self.snippetLabel.attributedText = [self attributedSnippetForThread:thread isBlocked:isBlocked]; } - + [self updatePreview]; + CGFloat previewHeight = MAX(self.snippetLabel.font.lineHeight, + TypingIndicatorView.kMaxRadiusPt); + [self.viewConstraints addObjectsFromArray:@[ + [self.previewStackView autoSetDimension:ALDimensionHeight + toSize:previewHeight], + ]]; self.dateTimeLabel.text = (overrideDate ? [self stringForDate:overrideDate] : [self stringForDate:thread.lastMessageDate]); @@ -500,6 +527,28 @@ NS_ASSUME_NONNULL_BEGIN self.nameLabel.attributedText = name; } +#pragma mark - Typing Indicators + +- (void)updatePreview +{ + if ([self.typingIndicators typingIndicatorsForThread:self.thread.threadRecord] != nil) { + self.snippetLabel.hidden = YES; + self.typingIndicatorView.hidden = NO; + [self.typingIndicatorView startAnimation]; + } else { + self.snippetLabel.hidden = NO; + self.typingIndicatorView.hidden = YES; + [self.typingIndicatorView stopAnimation]; + } +} + +- (void)typingIndicatorStateDidChange:(NSNotification *)notification +{ + OWSAssertIsOnMainThread(); + + [self updatePreview]; +} + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/views/TypingIndicatorView.swift b/Signal/src/views/TypingIndicatorView.swift new file mode 100644 index 000000000..446c2fb82 --- /dev/null +++ b/Signal/src/views/TypingIndicatorView.swift @@ -0,0 +1,101 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +@objc class TypingIndicatorView: UIStackView { + private let kDotMaxHSpacing: CGFloat = 8 + + @objc + public static let kMinRadiusPt: CGFloat = 6 + @objc + public static let kMaxRadiusPt: CGFloat = 8 + + private let dot1 = DotView(dotType: .dotType1) + private let dot2 = DotView(dotType: .dotType2) + private let dot3 = DotView(dotType: .dotType3) + + override public var isHidden: Bool { + didSet { + Logger.verbose("\(oldValue) -> \(isHidden)") + } + } + + @available(*, unavailable, message:"use other constructor instead.") + required init(coder aDecoder: NSCoder) { + notImplemented() + } + + @available(*, unavailable, message:"use other constructor instead.") + override init(frame: CGRect) { + notImplemented() + } + + @objc + public init() { + super.init(frame: .zero) + + // init(arrangedSubviews:...) is not a designated initializer. + addArrangedSubview(dot1) + addArrangedSubview(dot2) + addArrangedSubview(dot3) + + self.axis = .horizontal + self.spacing = kDotMaxHSpacing + self.alignment = .center + } + + @objc + public func startAnimation() { + } + + @objc + public func stopAnimation() { + } + + private enum DotType { + case dotType1 + case dotType2 + case dotType3 + } + + private class DotView: UIView { + private let dotType: DotType + + private let shapeLayer = CAShapeLayer() + + @available(*, unavailable, message:"use other constructor instead.") + required init?(coder aDecoder: NSCoder) { + notImplemented() + } + + @available(*, unavailable, message:"use other constructor instead.") + override init(frame: CGRect) { + notImplemented() + } + + init(dotType: DotType) { + self.dotType = dotType + + super.init(frame: .zero) + + autoSetDimension(.width, toSize: kMaxRadiusPt) + autoSetDimension(.height, toSize: kMaxRadiusPt) + + self.layer.addSublayer(shapeLayer) + + updateLayer() +// self.text = text +// +// setupSubviews() + } + + private func updateLayer() { + shapeLayer.fillColor = UIColor.ows_signalBlue.cgColor + + let margin = (TypingIndicatorView.kMaxRadiusPt - TypingIndicatorView.kMinRadiusPt) * 0.5 + let bezierPath = UIBezierPath(ovalIn: CGRect(x: margin, y: margin, width: TypingIndicatorView.kMinRadiusPt, height: TypingIndicatorView.kMinRadiusPt)) + shapeLayer.path = bezierPath.cgPath + + } + } +} diff --git a/SignalServiceKit/src/Util/TypingIndicators.swift b/SignalServiceKit/src/Util/TypingIndicators.swift index 5967a76c2..043a524e6 100644 --- a/SignalServiceKit/src/Util/TypingIndicators.swift +++ b/SignalServiceKit/src/Util/TypingIndicators.swift @@ -31,7 +31,7 @@ public protocol TypingIndicators: class { // // TODO: Use this method. @objc - func typingIndicators(forThread thread: TSThread, recipientId: String) -> String? + func typingIndicators(forThread thread: TSThread) -> String? @objc func setTypingIndicatorsEnabled(value: Bool) @@ -45,7 +45,8 @@ public protocol TypingIndicators: class { @objc(OWSTypingIndicatorsImpl) public class TypingIndicatorsImpl: NSObject, TypingIndicators { - @objc public static let typingIndicatorStateDidChange = Notification.Name("typingIndicatorStateDidChange") + @objc + public static let typingIndicatorStateDidChange = Notification.Name("typingIndicatorStateDidChange") private let kDatabaseCollection = "TypingIndicators" private let kDatabaseKey_TypingIndicatorsEnabled = "kDatabaseKey_TypingIndicatorsEnabled" @@ -150,7 +151,7 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators { } @objc - public func typingIndicators(forThread thread: TSThread, recipientId: String) -> String? { + public func typingIndicators(forThread thread: TSThread) -> String? { AssertIsOnMainThread() var firstRecipientId: String? From eedc9f9a2647c22c8ab6e1ef78224e2c6965aebc Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 1 Nov 2018 11:39:04 -0400 Subject: [PATCH 04/12] Sketch out "typing indicators" interaction and cell. --- Signal.xcodeproj/project.pbxproj | 8 ++ .../Cells/TypingIndicatorCell.swift | 37 +++++++++ .../ConversationViewController.m | 2 + .../ConversationView/ConversationViewItem.m | 10 +++ .../ConversationView/ConversationViewModel.m | 83 +++++++++++++++++++ .../TypingIndicatorInteraction.swift | 44 ++++++++++ Signal/src/views/TypingIndicatorView.swift | 4 +- .../src/Messages/Interactions/TSInteraction.h | 4 + .../src/Messages/Interactions/TSInteraction.m | 20 +++++ 9 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift create mode 100644 Signal/src/ViewControllers/ConversationView/TypingIndicatorInteraction.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 5ecf813a3..d7732c32a 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -217,6 +217,8 @@ 34B3F8851E8DF1700035BE1A /* NewGroupViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8551E8DF1700035BE1A /* NewGroupViewController.m */; }; 34B3F8931E8DF1710035BE1A /* SignalsNavigationController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F86E1E8DF1700035BE1A /* SignalsNavigationController.m */; }; 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */; }; + 34B6A905218B4C91007C4606 /* TypingIndicatorInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */; }; + 34B6A907218B5241007C4606 /* TypingIndicatorCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A906218B5240007C4606 /* TypingIndicatorCell.swift */; }; 34B6D27420F664C900765BE2 /* OWSUnreadIndicator.h in Headers */ = {isa = PBXBuildFile; fileRef = 34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */; settings = {ATTRIBUTES = (Public, ); }; }; 34B6D27520F664C900765BE2 /* OWSUnreadIndicator.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */; }; 34BECE2B1F74C12700D7438D /* DebugUIStress.m in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2A1F74C12700D7438D /* DebugUIStress.m */; }; @@ -868,6 +870,8 @@ 34B3F86D1E8DF1700035BE1A /* SignalsNavigationController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalsNavigationController.h; sourceTree = ""; }; 34B3F86E1E8DF1700035BE1A /* SignalsNavigationController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SignalsNavigationController.m; sourceTree = ""; }; 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; + 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorInteraction.swift; sourceTree = ""; }; + 34B6A906218B5240007C4606 /* TypingIndicatorCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorCell.swift; sourceTree = ""; }; 34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSUnreadIndicator.h; sourceTree = ""; }; 34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSUnreadIndicator.m; sourceTree = ""; }; 34BECE291F74C12700D7438D /* DebugUIStress.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUIStress.h; sourceTree = ""; }; @@ -1575,6 +1579,7 @@ 34D1F0721F8678AA0066283D /* ConversationViewLayout.m */, 341341ED2187467900192D59 /* ConversationViewModel.h */, 341341EE2187467900192D59 /* ConversationViewModel.m */, + 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */, ); path = ConversationView; sourceTree = ""; @@ -1844,6 +1849,7 @@ 34277A5C20751BDC006049F2 /* OWSQuotedMessageView.m */, 34D1F0A51F867BFC0066283D /* OWSSystemMessageCell.h */, 34D1F0A61F867BFC0066283D /* OWSSystemMessageCell.m */, + 34B6A906218B5240007C4606 /* TypingIndicatorCell.swift */, ); path = Cells; sourceTree = ""; @@ -3310,6 +3316,7 @@ 34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */, 348570A820F67575004FF32B /* OWSMessageHeaderView.m in Sources */, 450DF2091E0DD2C6003D14BE /* UserNotificationsAdaptee.swift in Sources */, + 34B6A907218B5241007C4606 /* TypingIndicatorCell.swift in Sources */, 34D1F0AB1F867BFC0066283D /* OWSContactOffersCell.m in Sources */, 340FC8C7204DE64D007AEB0F /* OWSBackupAPI.swift in Sources */, 343A65981FC4CFE7000477A1 /* ConversationScrollButton.m in Sources */, @@ -3388,6 +3395,7 @@ 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */, 340FC8D0205BF2FA007AEB0F /* OWSBackupIO.m in Sources */, 458E38371D668EBF0094BD24 /* OWSDeviceProvisioningURLParser.m in Sources */, + 34B6A905218B4C91007C4606 /* TypingIndicatorInteraction.swift in Sources */, 4517642B1DE939FD00EDB8B9 /* ContactCell.swift in Sources */, 340FC8AB204DAC8D007AEB0F /* DomainFrontingCountryViewController.m in Sources */, 3496744D2076768700080B5F /* OWSMessageBubbleView.m in Sources */, diff --git a/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift b/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift new file mode 100644 index 000000000..5def3dceb --- /dev/null +++ b/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift @@ -0,0 +1,37 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc(OWSTypingIndicatorCell) +public class TypingIndicatorCell: ConversationViewCell { + + @objc + public static let cellReuseIdentifier = "TypingIndicatorCell" + + @available(*, unavailable, message:"use other constructor instead.") + @objc + public required init(coder aDecoder: NSCoder) { + notImplemented() + } + + override init(frame: CGRect) { + super.init(frame: frame) + } + + @objc + public override func loadForDisplay() { + + } + + @objc + public override func cellSize() -> CGSize { + return .zero + } + + @objc + public override func prepareForReuse() { + super.prepareForReuse() + } +} diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index d313c83a8..571ae69ef 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -596,6 +596,8 @@ typedef enum : NSUInteger { { [self.collectionView registerClass:[OWSSystemMessageCell class] forCellWithReuseIdentifier:[OWSSystemMessageCell cellReuseIdentifier]]; + [self.collectionView registerClass:[OWSTypingIndicatorCell class] + forCellWithReuseIdentifier:[OWSTypingIndicatorCell cellReuseIdentifier]]; [self.collectionView registerClass:[OWSContactOffersCell class] forCellWithReuseIdentifier:[OWSContactOffersCell cellReuseIdentifier]]; [self.collectionView registerClass:[OWSMessageCell class] diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m index e100027ac..6d533ece8 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m @@ -302,6 +302,9 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) case OWSInteractionType_Offer: measurementCell = [OWSContactOffersCell new]; break; + case OWSInteractionType_TypingIndicator: + measurementCell = [OWSTypingIndicatorCell new]; + break; } OWSAssertDebug(measurementCell); @@ -319,6 +322,8 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) return OWSMessageHeaderViewDateHeaderVMargin; } + // TODO: + // "Bubble Collapse". Adjacent messages with the same author should be close together. if (self.interaction.interactionType == OWSInteractionType_IncomingMessage && previousLayoutItem.interaction.interactionType == OWSInteractionType_IncomingMessage) { @@ -359,6 +364,10 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) case OWSInteractionType_Offer: return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSContactOffersCell cellReuseIdentifier] forIndexPath:indexPath]; + + case OWSInteractionType_TypingIndicator: + return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSTypingIndicatorCell cellReuseIdentifier] + forIndexPath:indexPath]; } } @@ -480,6 +489,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) switch (self.interaction.interactionType) { case OWSInteractionType_Unknown: case OWSInteractionType_Offer: + case OWSInteractionType_TypingIndicator: return; case OWSInteractionType_Error: case OWSInteractionType_Info: diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m index d84020efa..042cebc88 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m @@ -143,6 +143,7 @@ static const int kYapDatabaseRangeMinLength = 0; @property (nonatomic, nullable) ThreadDynamicInteractions *dynamicInteractions; @property (nonatomic) BOOL hasClearedUnreadMessagesIndicator; @property (nonatomic, nullable) NSDate *collapseCutoffDate; +@property (nonatomic, nullable) NSString *typingIndicatorsRecipient; @end @@ -200,6 +201,11 @@ static const int kYapDatabaseRangeMinLength = 0; return OWSBlockingManager.sharedManager; } +- (id)typingIndicators +{ + return SSKEnvironment.shared.typingIndicators; +} + #pragma mark - (void)addNotificationListeners @@ -224,6 +230,10 @@ static const int kYapDatabaseRangeMinLength = 0; selector:@selector(signalAccountsDidChange:) name:OWSContactsManagerSignalAccountsDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(typingIndicatorStateDidChange:) + name:[OWSTypingIndicatorsImpl typingIndicatorStateDidChange] + object:nil]; } - (void)signalAccountsDidChange:(NSNotification *)notification @@ -240,6 +250,7 @@ static const int kYapDatabaseRangeMinLength = 0; // We need to update the "unread indicator" _before_ we determine the initial range // size, since it depends on where the unread indicator is placed. self.lastRangeLength = 0; + self.typingIndicatorsRecipient = [self.typingIndicators typingIndicatorsForThread:self.thread]; [self ensureDynamicInteractions]; [self.primaryStorage updateUIDatabaseConnectionToLatest]; @@ -574,6 +585,36 @@ static const int kYapDatabaseRangeMinLength = 0; updatedNeighborItemSet:updatedNeighborItemSet]; } +// A simpler version of the update logic we call when +// only transient items have changed. +- (void)updateForTransientItems +{ + OWSAssertIsOnMainThread(); + + OWSLogVerbose(@""); + + NSMutableArray *oldItemIdList = [NSMutableArray new]; + for (id viewItem in self.viewItems) { + [oldItemIdList addObject:viewItem.itemId]; + } + + NSUInteger oldViewItemCount = self.viewItems.count; + if (![self reloadViewItems]) { + // These errors are rare. + OWSFailDebug(@"could not reload view items; hard resetting message mappings."); + // resetMappings will call delegate.conversationViewModelDidUpdate. + [self resetMappings]; + return; + } + + OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldViewItemCount, self.viewItems.count); + + [self updateViewWitholdItemIdList:oldItemIdList + updatedItemSet:[NSSet set] + oldViewItemCount:oldViewItemCount + updatedNeighborItemSet:nil]; +} + - (void)updateViewWithOldItemIdList:(NSArray *)oldItemIdList updatedItemSet:(NSSet *)updatedItemSet updatedNeighborItemSet:(nullable NSMutableSet *)updatedNeighborItemSet @@ -863,6 +904,25 @@ static const int kYapDatabaseRangeMinLength = 0; OWSAssertDebug(!viewItemCache[interaction.uniqueId]); viewItemCache[interaction.uniqueId] = viewItem; } + + if (self.typingIndicatorsRecipient) { + id _Nullable lastViewItem = viewItems.lastObject; + uint64_t typingIndicatorTimestamp = (lastViewItem ? lastViewItem.interaction.timestamp + 1 : 1); + TSInteraction *interaction = + [[OWSTypingIndicatorInteraction alloc] initWithThread:self.thread + timestamp:typingIndicatorTimestamp + recipientId:self.typingIndicatorsRecipient]; + id _Nullable viewItem = self.viewItemCache[interaction.uniqueId]; + if (!viewItem) { + viewItem = [[ConversationInteractionViewItem alloc] initWithInteraction:interaction + isGroupThread:isGroupThread + transaction:transaction + conversationStyle:conversationStyle]; + } + [viewItems addObject:viewItem]; + OWSAssertDebug(!viewItemCache[interaction.uniqueId]); + viewItemCache[interaction.uniqueId] = viewItem; + } }]; // Flag to ensure that we only increment once per launch. @@ -883,6 +943,7 @@ static const int kYapDatabaseRangeMinLength = 0; switch (viewItem.interaction.interactionType) { case OWSInteractionType_Unknown: case OWSInteractionType_Offer: + case OWSInteractionType_TypingIndicator: canShowDate = NO; break; case OWSInteractionType_IncomingMessage: @@ -1276,6 +1337,28 @@ static const int kYapDatabaseRangeMinLength = 0; return @(groupIndex); } +- (void)typingIndicatorStateDidChange:(NSNotification *)notification +{ + OWSAssertIsOnMainThread(); + + self.typingIndicatorsRecipient = [self.typingIndicators typingIndicatorsForThread:self.thread]; +} + +- (void)setTypingIndicatorsRecipient:(nullable NSString *)typingIndicatorsRecipient +{ + OWSAssertIsOnMainThread(); + + BOOL didChange = ![NSObject isNullableObject:typingIndicatorsRecipient equalTo:_typingIndicatorsRecipient]; + + _typingIndicatorsRecipient = typingIndicatorsRecipient; + + // Update the view items if necessary. + // We don't have to do this if they haven't been configured yet. + if (didChange && self.viewItems != nil) { + [self updateForTransientItems]; + } +} + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/TypingIndicatorInteraction.swift b/Signal/src/ViewControllers/ConversationView/TypingIndicatorInteraction.swift new file mode 100644 index 000000000..2716f9cc5 --- /dev/null +++ b/Signal/src/ViewControllers/ConversationView/TypingIndicatorInteraction.swift @@ -0,0 +1,44 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc(OWSTypingIndicatorInteraction) +public class TypingIndicatorInteraction: TSInteraction { + @objc + public static let TypingIndicatorId = "TypingIndicator" + + @objc + public override func isDynamicInteraction() -> Bool { + return true + } + + @objc + public override func interactionType() -> OWSInteractionType { + return .typingIndicator + } + + @available(*, unavailable, message:"use other constructor instead.") + @objc + public required init(coder aDecoder: NSCoder) { + notImplemented() + } + + @available(*, unavailable, message:"use other constructor instead.") + @objc + public required init(dictionary dictionaryValue: [AnyHashable: Any]!) throws { + notImplemented() + } + + @objc + public let recipientId: String + + @objc + public init(thread: TSThread, timestamp: UInt64, recipientId: String) { + self.recipientId = recipientId + + super.init(interactionWithUniqueId: TypingIndicatorInteraction.TypingIndicatorId, + timestamp: timestamp, in: thread) + } +} diff --git a/Signal/src/views/TypingIndicatorView.swift b/Signal/src/views/TypingIndicatorView.swift index 446c2fb82..3fb4632eb 100644 --- a/Signal/src/views/TypingIndicatorView.swift +++ b/Signal/src/views/TypingIndicatorView.swift @@ -3,7 +3,9 @@ // @objc class TypingIndicatorView: UIStackView { - private let kDotMaxHSpacing: CGFloat = 8 + // This represents the spacing between the dots + // _at their max size_. + private let kDotMaxHSpacing: CGFloat = 3 @objc public static let kMinRadiusPt: CGFloat = 6 diff --git a/SignalServiceKit/src/Messages/Interactions/TSInteraction.h b/SignalServiceKit/src/Messages/Interactions/TSInteraction.h index a5b29b147..58a109bc6 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInteraction.h +++ b/SignalServiceKit/src/Messages/Interactions/TSInteraction.h @@ -16,6 +16,7 @@ typedef NS_ENUM(NSInteger, OWSInteractionType) { OWSInteractionType_Call, OWSInteractionType_Info, OWSInteractionType_Offer, + OWSInteractionType_TypingIndicator, }; NSString *NSStringFromOWSInteractionType(OWSInteractionType value); @@ -28,6 +29,9 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value); @interface TSInteraction : TSYapDatabaseObject +- (instancetype)initInteractionWithUniqueId:(NSString *)uniqueId + timestamp:(uint64_t)timestamp + inThread:(TSThread *)thread; - (instancetype)initInteractionWithTimestamp:(uint64_t)timestamp inThread:(TSThread *)thread; @property (nonatomic, readonly) NSString *uniqueThreadId; diff --git a/SignalServiceKit/src/Messages/Interactions/TSInteraction.m b/SignalServiceKit/src/Messages/Interactions/TSInteraction.m index 10e4a27bd..ff234c97e 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInteraction.m +++ b/SignalServiceKit/src/Messages/Interactions/TSInteraction.m @@ -27,6 +27,8 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value) return @"OWSInteractionType_Info"; case OWSInteractionType_Offer: return @"OWSInteractionType_Offer"; + case OWSInteractionType_TypingIndicator: + return @"OWSInteractionType_TypingIndicator"; } } @@ -74,6 +76,24 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value) return @"TSInteraction"; } +- (instancetype)initInteractionWithUniqueId:(NSString *)uniqueId + timestamp:(uint64_t)timestamp + inThread:(TSThread *)thread +{ + OWSAssertDebug(timestamp > 0); + + self = [super initWithUniqueId:uniqueId]; + + if (!self) { + return self; + } + + _timestamp = timestamp; + _uniqueThreadId = thread.uniqueId; + + return self; +} + - (instancetype)initInteractionWithTimestamp:(uint64_t)timestamp inThread:(TSThread *)thread { OWSAssertDebug(timestamp > 0); From 63d88ef5cbf6bc69e8571f7e02c14acf982bb9cf Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 1 Nov 2018 12:47:08 -0400 Subject: [PATCH 05/12] Sketch out TypingIndicatorCell. --- .../ConversationView/Cells/OWSBubbleView.h | 2 + .../ConversationView/Cells/OWSBubbleView.m | 5 + .../Cells/TypingIndicatorCell.swift | 122 +++++++++++++++++- .../ConversationView/ConversationViewItem.m | 24 +++- Signal/src/views/TypingIndicatorView.swift | 14 +- SignalMessaging/categories/UIView+OWS.h | 5 + SignalServiceKit/src/Contacts/TSThread.h | 3 + SignalServiceKit/src/Contacts/TSThread.m | 5 + 8 files changed, 162 insertions(+), 18 deletions(-) diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSBubbleView.h b/Signal/src/ViewControllers/ConversationView/Cells/OWSBubbleView.h index 9936e6c14..09f5a49a7 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSBubbleView.h +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSBubbleView.h @@ -53,6 +53,8 @@ typedef NS_OPTIONS(NSUInteger, OWSDirectionalRectCorner) { - (CGFloat)minWidth; +- (CGFloat)minHeight; + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSBubbleView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSBubbleView.m index ce012b29d..14ee8e436 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSBubbleView.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSBubbleView.m @@ -273,6 +273,11 @@ const CGFloat kOWSMessageCellCornerRadius_Small = 4; return (kOWSMessageCellCornerRadius_Large * 2); } +- (CGFloat)minHeight +{ + return (kOWSMessageCellCornerRadius_Large * 2); +} + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift b/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift index 5def3dceb..670d10261 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift +++ b/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift @@ -10,28 +10,146 @@ public class TypingIndicatorCell: ConversationViewCell { @objc public static let cellReuseIdentifier = "TypingIndicatorCell" - @available(*, unavailable, message:"use other constructor instead.") + @available(*, unavailable, message:"use other constructor instead.") @objc public required init(coder aDecoder: NSCoder) { notImplemented() } + private let kAvatarSize: CGFloat = 36 + private let kAvatarHSpacing: CGFloat = 8 + + private let avatarView = AvatarImageView() + private let bubbleView = OWSBubbleView() + private let typingIndicatorView = TypingIndicatorView() + private var viewConstraints = [NSLayoutConstraint]() + override init(frame: CGRect) { super.init(frame: frame) + + commonInit() + } + + private func commonInit() { + self.layoutMargins = .zero + self.contentView.layoutMargins = .zero + + bubbleView.layoutMargins = .zero + + bubbleView.addSubview(typingIndicatorView) + contentView.addSubview(bubbleView) + + avatarView.autoSetDimension(.width, toSize: kAvatarSize) + avatarView.autoSetDimension(.height, toSize: kAvatarSize) + } + + deinit { + NotificationCenter.default.removeObserver(self) } @objc public override func loadForDisplay() { + guard let conversationStyle = self.conversationStyle else { + owsFailDebug("Missing conversationStyle") + return + } + + bubbleView.bubbleColor = conversationStyle.bubbleColor(isIncoming: true) + typingIndicatorView.startAnimation() + typingIndicatorView.addBackgroundView(withBackgroundColor: UIColor.red) + + viewConstraints.append(contentsOf: [ + bubbleView.autoPinEdge(toSuperviewEdge: .leading, withInset: conversationStyle.gutterLeading), + bubbleView.autoPinEdge(toSuperviewEdge: .trailing, withInset: conversationStyle.gutterTrailing, relation: .greaterThanOrEqual), + bubbleView.autoPinTopToSuperviewMargin(withInset: 0), + bubbleView.autoPinBottomToSuperviewMargin(withInset: 0), + typingIndicatorView.autoPinEdge(toSuperviewEdge: .leading, withInset: conversationStyle.textInsetHorizontal), + typingIndicatorView.autoPinEdge(toSuperviewEdge: .trailing, withInset: conversationStyle.textInsetHorizontal), + typingIndicatorView.autoPinTopToSuperviewMargin(withInset: conversationStyle.textInsetTop), + typingIndicatorView.autoPinBottomToSuperviewMargin(withInset: conversationStyle.textInsetBottom) + ]) + + if let avatarView = configureAvatarView() { + contentView.addSubview(avatarView) + viewConstraints.append(contentsOf: [ + bubbleView.autoPinLeading(toTrailingEdgeOf: avatarView, offset: kAvatarHSpacing), + bubbleView.autoAlignAxis(.horizontal, toSameAxisOf: avatarView) + ]) + + } else { + avatarView.removeFromSuperview() + } + } + + private func configureAvatarView() -> UIView? { + guard let viewItem = self.viewItem else { + owsFailDebug("Missing viewItem") + return nil + } + guard let typingIndicators = viewItem.interaction as? TypingIndicatorInteraction else { + owsFailDebug("Missing typingIndicators") + return nil + } + guard shouldShowAvatar() else { + return nil + } + guard let colorName = viewItem.authorConversationColorName else { + owsFailDebug("Missing authorConversationColorName") + return nil + } + guard let authorAvatarImage = + OWSContactAvatarBuilder(signalId: typingIndicators.recipientId, + colorName: ConversationColorNameForString(colorName), + diameter: UInt(kAvatarSize)).build() else { + owsFailDebug("Could build avatar image") + return nil + } + avatarView.image = authorAvatarImage + return avatarView + } + + private func shouldShowAvatar() -> Bool { + guard let viewItem = self.viewItem else { + owsFailDebug("Missing viewItem") + return false + } + return viewItem.isGroupThread } @objc public override func cellSize() -> CGSize { - return .zero + guard let conversationStyle = self.conversationStyle else { + owsFailDebug("Missing conversationStyle") + return .zero + } + + let insetsSize = CGSize(width: conversationStyle.textInsetHorizontal * 2, + height: conversationStyle.textInsetTop + conversationStyle.textInsetBottom) + let typingIndicatorSize = typingIndicatorView.sizeThatFits(.zero) + let bubbleSize = CGSizeAdd(insetsSize, typingIndicatorSize) + + if shouldShowAvatar() { + let avatarSize = CGSize(width: kAvatarSize, height: kAvatarSize) + return CGSizeCeil(CGSize(width: avatarSize.width + kAvatarHSpacing + bubbleSize.width, + height: max(avatarSize.height, bubbleSize.height))) + } else { + return bubbleSize + } } @objc public override func prepareForReuse() { super.prepareForReuse() + + NSLayoutConstraint.deactivate(viewConstraints) + viewConstraints = [NSLayoutConstraint]() + + avatarView.image = nil + avatarView.removeFromSuperview() + + typingIndicatorView.stopAnimation() + + NotificationCenter.default.removeObserver(self) } } diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m index 6d533ece8..be6772231 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m @@ -143,14 +143,24 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) { OWSAssertDebug(transaction); - if (self.interaction.interactionType != OWSInteractionType_IncomingMessage) { - _authorConversationColorName = nil; - return; + switch (self.interaction.interactionType) { + case OWSInteractionType_TypingIndicator: { + OWSTypingIndicatorInteraction *typingIndicator = (OWSTypingIndicatorInteraction *)self.interaction; + _authorConversationColorName = + [TSContactThread conversationColorNameForRecipientId:typingIndicator.recipientId + transaction:transaction]; + break; + } + case OWSInteractionType_IncomingMessage: { + TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.interaction; + _authorConversationColorName = + [TSContactThread conversationColorNameForRecipientId:incomingMessage.authorId transaction:transaction]; + break; + } + default: + _authorConversationColorName = nil; + break; } - - TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.interaction; - _authorConversationColorName = - [TSContactThread conversationColorNameForRecipientId:incomingMessage.authorId transaction:transaction]; } - (NSString *)itemId diff --git a/Signal/src/views/TypingIndicatorView.swift b/Signal/src/views/TypingIndicatorView.swift index 3fb4632eb..f78cd65df 100644 --- a/Signal/src/views/TypingIndicatorView.swift +++ b/Signal/src/views/TypingIndicatorView.swift @@ -16,12 +16,6 @@ private let dot2 = DotView(dotType: .dotType2) private let dot3 = DotView(dotType: .dotType3) - override public var isHidden: Bool { - didSet { - Logger.verbose("\(oldValue) -> \(isHidden)") - } - } - @available(*, unavailable, message:"use other constructor instead.") required init(coder aDecoder: NSCoder) { notImplemented() @@ -46,6 +40,11 @@ self.alignment = .center } + @objc + public override func sizeThatFits(_ size: CGSize) -> CGSize { + return CGSize(width: TypingIndicatorView.kMaxRadiusPt * 3 + kDotMaxHSpacing * 2, height: TypingIndicatorView.kMaxRadiusPt) + } + @objc public func startAnimation() { } @@ -86,9 +85,6 @@ self.layer.addSublayer(shapeLayer) updateLayer() -// self.text = text -// -// setupSubviews() } private func updateLayer() { diff --git a/SignalMessaging/categories/UIView+OWS.h b/SignalMessaging/categories/UIView+OWS.h index 3e1de19c2..81bdde795 100644 --- a/SignalMessaging/categories/UIView+OWS.h +++ b/SignalMessaging/categories/UIView+OWS.h @@ -193,6 +193,11 @@ CG_INLINE CGSize CGSizeScale(CGSize size, CGFloat factor) return CGSizeMake(size.width * factor, size.height * factor); } +CG_INLINE CGSize CGSizeAdd(CGSize left, CGSize right) +{ + return CGSizeMake(left.width + right.width, left.height + right.height); +} + CGFloat CGHairlineWidth(void); NS_ASSUME_NONNULL_END diff --git a/SignalServiceKit/src/Contacts/TSThread.h b/SignalServiceKit/src/Contacts/TSThread.h index 098787a38..15cd2b20a 100644 --- a/SignalServiceKit/src/Contacts/TSThread.h +++ b/SignalServiceKit/src/Contacts/TSThread.h @@ -11,6 +11,9 @@ NS_ASSUME_NONNULL_BEGIN @class TSInvalidIdentityKeyReceivingErrorMessage; typedef NSString *ConversationColorName NS_STRING_ENUM; + +ConversationColorName ConversationColorNameForString(NSString *value); + extern ConversationColorName const ConversationColorNameCrimson; extern ConversationColorName const ConversationColorNameVermilion; extern ConversationColorName const ConversationColorNameBurlap; diff --git a/SignalServiceKit/src/Contacts/TSThread.m b/SignalServiceKit/src/Contacts/TSThread.m index 4d046f132..aa7831e1c 100644 --- a/SignalServiceKit/src/Contacts/TSThread.m +++ b/SignalServiceKit/src/Contacts/TSThread.m @@ -19,6 +19,11 @@ NS_ASSUME_NONNULL_BEGIN +ConversationColorName ConversationColorNameForString(NSString *value) +{ + return value; +} + ConversationColorName const ConversationColorNameCrimson = @"red"; ConversationColorName const ConversationColorNameVermilion = @"orange"; ConversationColorName const ConversationColorNameBurlap = @"brown"; From 37ae4ef3604c660647681ee3f73428debe4ad04f Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 1 Nov 2018 13:53:58 -0400 Subject: [PATCH 06/12] Add typing indicator animation. --- .../Cells/TypingIndicatorCell.swift | 1 - Signal/src/views/TypingIndicatorView.swift | 102 +++++++++++++++--- 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift b/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift index 670d10261..27bac0dae 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift +++ b/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift @@ -56,7 +56,6 @@ public class TypingIndicatorCell: ConversationViewCell { bubbleView.bubbleColor = conversationStyle.bubbleColor(isIncoming: true) typingIndicatorView.startAnimation() - typingIndicatorView.addBackgroundView(withBackgroundColor: UIColor.red) viewConstraints.append(contentsOf: [ bubbleView.autoPinEdge(toSuperviewEdge: .leading, withInset: conversationStyle.gutterLeading), diff --git a/Signal/src/views/TypingIndicatorView.swift b/Signal/src/views/TypingIndicatorView.swift index f78cd65df..2f14ac43d 100644 --- a/Signal/src/views/TypingIndicatorView.swift +++ b/Signal/src/views/TypingIndicatorView.swift @@ -31,9 +31,9 @@ super.init(frame: .zero) // init(arrangedSubviews:...) is not a designated initializer. - addArrangedSubview(dot1) - addArrangedSubview(dot2) - addArrangedSubview(dot3) + for dot in dots() { + addArrangedSubview(dot) + } self.axis = .horizontal self.spacing = kDotMaxHSpacing @@ -45,12 +45,22 @@ return CGSize(width: TypingIndicatorView.kMaxRadiusPt * 3 + kDotMaxHSpacing * 2, height: TypingIndicatorView.kMaxRadiusPt) } + private func dots() -> [DotView] { + return [dot1, dot2, dot3] + } + @objc public func startAnimation() { + for dot in dots() { + dot.startAnimation() + } } @objc public func stopAnimation() { + for dot in dots() { + dot.stopAnimation() + } } private enum DotType { @@ -82,18 +92,86 @@ autoSetDimension(.width, toSize: kMaxRadiusPt) autoSetDimension(.height, toSize: kMaxRadiusPt) - self.layer.addSublayer(shapeLayer) - - updateLayer() + layer.addSublayer(shapeLayer) } - private func updateLayer() { - shapeLayer.fillColor = UIColor.ows_signalBlue.cgColor - - let margin = (TypingIndicatorView.kMaxRadiusPt - TypingIndicatorView.kMinRadiusPt) * 0.5 - let bezierPath = UIBezierPath(ovalIn: CGRect(x: margin, y: margin, width: TypingIndicatorView.kMinRadiusPt, height: TypingIndicatorView.kMinRadiusPt)) - shapeLayer.path = bezierPath.cgPath + fileprivate func startAnimation() { + stopAnimation() + + let timeIncrement: CFTimeInterval = 0.15 + var colorValues = [CGColor]() + var pathValues = [CGPath]() + var keyTimes = [CFTimeInterval]() + var animationDuration: CFTimeInterval = 0 + + let addDotKeyFrame = { (keyFrameTime: CFTimeInterval, progress: CGFloat) in + let dotColor = UIColor(rgbHex: 0x636467).withAlphaComponent(CGFloatLerp(0.4, 1.0, progress)) + colorValues.append(dotColor.cgColor) + let radius = CGFloatLerp(TypingIndicatorView.kMinRadiusPt, TypingIndicatorView.kMaxRadiusPt, progress) + let margin = (TypingIndicatorView.kMaxRadiusPt - radius) * 0.5 + let bezierPath = UIBezierPath(ovalIn: CGRect(x: margin, y: margin, width: radius, height: radius)) + pathValues.append(bezierPath.cgPath) + + keyTimes.append(keyFrameTime) + animationDuration = max(animationDuration, keyFrameTime) + } + + // All animations in the group apparently need to have the same number + // of keyframes, and use the same timing. + switch dotType { + case .dotType1: + addDotKeyFrame(0 * timeIncrement, 0.0) + addDotKeyFrame(1 * timeIncrement, 0.5) + addDotKeyFrame(2 * timeIncrement, 1.0) + addDotKeyFrame(3 * timeIncrement, 0.5) + addDotKeyFrame(4 * timeIncrement, 0.0) + addDotKeyFrame(5 * timeIncrement, 0.0) + addDotKeyFrame(6 * timeIncrement, 0.0) + addDotKeyFrame(10 * timeIncrement, 0.0) + break + case .dotType2: + addDotKeyFrame(0 * timeIncrement, 0.0) + addDotKeyFrame(1 * timeIncrement, 0.0) + addDotKeyFrame(2 * timeIncrement, 0.5) + addDotKeyFrame(3 * timeIncrement, 1.0) + addDotKeyFrame(4 * timeIncrement, 0.5) + addDotKeyFrame(5 * timeIncrement, 0.0) + addDotKeyFrame(6 * timeIncrement, 0.0) + addDotKeyFrame(10 * timeIncrement, 0.0) + break + case .dotType3: + addDotKeyFrame(0 * timeIncrement, 0.0) + addDotKeyFrame(1 * timeIncrement, 0.0) + addDotKeyFrame(2 * timeIncrement, 0.0) + addDotKeyFrame(3 * timeIncrement, 0.5) + addDotKeyFrame(4 * timeIncrement, 1.0) + addDotKeyFrame(5 * timeIncrement, 0.5) + addDotKeyFrame(6 * timeIncrement, 0.0) + addDotKeyFrame(10 * timeIncrement, 0.0) + break + } + + let makeAnimation: (String, [Any]) -> CAKeyframeAnimation = { (keyPath, values) in + let animation = CAKeyframeAnimation() + animation.keyPath = keyPath + animation.values = values + animation.duration = animationDuration + return animation + } + + let groupAnimation = CAAnimationGroup() + groupAnimation.animations = [ + makeAnimation("fillColor", colorValues), + makeAnimation("path", pathValues) + ] + groupAnimation.duration = animationDuration + groupAnimation.repeatCount = MAXFLOAT + + shapeLayer.add(groupAnimation, forKey: UUID().uuidString) + } + fileprivate func stopAnimation() { + shapeLayer.removeAllAnimations() } } } From f8a5a414151b71189ba340983a51f5beafb52ed5 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 1 Nov 2018 14:02:45 -0400 Subject: [PATCH 07/12] Apply dark theme to typing indicator. --- Signal/src/views/TypingIndicatorView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Signal/src/views/TypingIndicatorView.swift b/Signal/src/views/TypingIndicatorView.swift index 2f14ac43d..58e68683b 100644 --- a/Signal/src/views/TypingIndicatorView.swift +++ b/Signal/src/views/TypingIndicatorView.swift @@ -98,6 +98,9 @@ fileprivate func startAnimation() { stopAnimation() + let baseColor = (Theme.isDarkThemeEnabled + ? UIColor(rgbHex: 0xBBBDBE) + : UIColor(rgbHex: 0x636467)) let timeIncrement: CFTimeInterval = 0.15 var colorValues = [CGColor]() var pathValues = [CGPath]() @@ -105,7 +108,7 @@ var animationDuration: CFTimeInterval = 0 let addDotKeyFrame = { (keyFrameTime: CFTimeInterval, progress: CGFloat) in - let dotColor = UIColor(rgbHex: 0x636467).withAlphaComponent(CGFloatLerp(0.4, 1.0, progress)) + let dotColor = baseColor.withAlphaComponent(CGFloatLerp(0.4, 1.0, progress)) colorValues.append(dotColor.cgColor) let radius = CGFloatLerp(TypingIndicatorView.kMinRadiusPt, TypingIndicatorView.kMaxRadiusPt, progress) let margin = (TypingIndicatorView.kMaxRadiusPt - radius) * 0.5 From 94eaed002461f774feba49c0072719544d1054c5 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 1 Nov 2018 15:00:01 -0400 Subject: [PATCH 08/12] Fix rebase breakage. --- .../ConversationView/ConversationViewModel.m | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m index 042cebc88..3910d2daa 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m @@ -585,7 +585,7 @@ static const int kYapDatabaseRangeMinLength = 0; updatedNeighborItemSet:updatedNeighborItemSet]; } -// A simpler version of the update logic we call when +// A simpler version of the update logic we use when // only transient items have changed. - (void)updateForTransientItems { @@ -598,7 +598,6 @@ static const int kYapDatabaseRangeMinLength = 0; [oldItemIdList addObject:viewItem.itemId]; } - NSUInteger oldViewItemCount = self.viewItems.count; if (![self reloadViewItems]) { // These errors are rare. OWSFailDebug(@"could not reload view items; hard resetting message mappings."); @@ -607,12 +606,9 @@ static const int kYapDatabaseRangeMinLength = 0; return; } - OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldViewItemCount, self.viewItems.count); + OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldItemIdList.count, self.viewItems.count); - [self updateViewWitholdItemIdList:oldItemIdList - updatedItemSet:[NSSet set] - oldViewItemCount:oldViewItemCount - updatedNeighborItemSet:nil]; + [self updateViewWithOldItemIdList:oldItemIdList updatedItemSet:[NSSet set] updatedNeighborItemSet:nil]; } - (void)updateViewWithOldItemIdList:(NSArray *)oldItemIdList From 4088bebe05ee276c2a58a6795d49e8786e8fe76b Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 1 Nov 2018 15:07:17 -0400 Subject: [PATCH 09/12] Clean up ahead of PR. --- .../src/ViewControllers/ConversationView/ConversationViewItem.m | 2 -- 1 file changed, 2 deletions(-) diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m index be6772231..ddfa18d34 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m @@ -332,8 +332,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) return OWSMessageHeaderViewDateHeaderVMargin; } - // TODO: - // "Bubble Collapse". Adjacent messages with the same author should be close together. if (self.interaction.interactionType == OWSInteractionType_IncomingMessage && previousLayoutItem.interaction.interactionType == OWSInteractionType_IncomingMessage) { From 650469c6a7c23ab0041dad6c04e12ee10d053d02 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 1 Nov 2018 16:19:03 -0400 Subject: [PATCH 10/12] Respond to CR. --- .../Cells/TypingIndicatorCell.swift | 8 +------- .../ConversationView/ConversationViewModel.m | 16 ++++++++-------- .../TypingIndicatorInteraction.swift | 5 +++++ .../src/ViewControllers/HomeView/HomeViewCell.m | 2 +- SignalServiceKit/src/Contacts/TSThread.h | 2 -- SignalServiceKit/src/Contacts/TSThread.m | 5 ----- SignalServiceKit/src/Util/TypingIndicators.swift | 6 +++--- 7 files changed, 18 insertions(+), 26 deletions(-) diff --git a/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift b/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift index 27bac0dae..0713cdcea 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift +++ b/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift @@ -43,10 +43,6 @@ public class TypingIndicatorCell: ConversationViewCell { avatarView.autoSetDimension(.height, toSize: kAvatarSize) } - deinit { - NotificationCenter.default.removeObserver(self) - } - @objc public override func loadForDisplay() { guard let conversationStyle = self.conversationStyle else { @@ -99,7 +95,7 @@ public class TypingIndicatorCell: ConversationViewCell { } guard let authorAvatarImage = OWSContactAvatarBuilder(signalId: typingIndicators.recipientId, - colorName: ConversationColorNameForString(colorName), + colorName: ConversationColorName(rawValue: colorName), diameter: UInt(kAvatarSize)).build() else { owsFailDebug("Could build avatar image") return nil @@ -148,7 +144,5 @@ public class TypingIndicatorCell: ConversationViewCell { avatarView.removeFromSuperview() typingIndicatorView.stopAnimation() - - NotificationCenter.default.removeObserver(self) } } diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m index 3910d2daa..f216e8bbe 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m @@ -143,7 +143,7 @@ static const int kYapDatabaseRangeMinLength = 0; @property (nonatomic, nullable) ThreadDynamicInteractions *dynamicInteractions; @property (nonatomic) BOOL hasClearedUnreadMessagesIndicator; @property (nonatomic, nullable) NSDate *collapseCutoffDate; -@property (nonatomic, nullable) NSString *typingIndicatorsRecipient; +@property (nonatomic, nullable) NSString *typingIndicatorsSender; @end @@ -250,7 +250,7 @@ static const int kYapDatabaseRangeMinLength = 0; // We need to update the "unread indicator" _before_ we determine the initial range // size, since it depends on where the unread indicator is placed. self.lastRangeLength = 0; - self.typingIndicatorsRecipient = [self.typingIndicators typingIndicatorsForThread:self.thread]; + self.typingIndicatorsSender = [self.typingIndicators typingRecipientIdForThread:self.thread]; [self ensureDynamicInteractions]; [self.primaryStorage updateUIDatabaseConnectionToLatest]; @@ -901,13 +901,13 @@ static const int kYapDatabaseRangeMinLength = 0; viewItemCache[interaction.uniqueId] = viewItem; } - if (self.typingIndicatorsRecipient) { + if (self.typingIndicatorsSender) { id _Nullable lastViewItem = viewItems.lastObject; uint64_t typingIndicatorTimestamp = (lastViewItem ? lastViewItem.interaction.timestamp + 1 : 1); TSInteraction *interaction = [[OWSTypingIndicatorInteraction alloc] initWithThread:self.thread timestamp:typingIndicatorTimestamp - recipientId:self.typingIndicatorsRecipient]; + recipientId:self.typingIndicatorsSender]; id _Nullable viewItem = self.viewItemCache[interaction.uniqueId]; if (!viewItem) { viewItem = [[ConversationInteractionViewItem alloc] initWithInteraction:interaction @@ -1337,16 +1337,16 @@ static const int kYapDatabaseRangeMinLength = 0; { OWSAssertIsOnMainThread(); - self.typingIndicatorsRecipient = [self.typingIndicators typingIndicatorsForThread:self.thread]; + self.typingIndicatorsSender = [self.typingIndicators typingRecipientIdForThread:self.thread]; } -- (void)setTypingIndicatorsRecipient:(nullable NSString *)typingIndicatorsRecipient +- (void)setTypingIndicatorsSender:(nullable NSString *)typingIndicatorsSender { OWSAssertIsOnMainThread(); - BOOL didChange = ![NSObject isNullableObject:typingIndicatorsRecipient equalTo:_typingIndicatorsRecipient]; + BOOL didChange = ![NSObject isNullableObject:typingIndicatorsSender equalTo:_typingIndicatorsSender]; - _typingIndicatorsRecipient = typingIndicatorsRecipient; + _typingIndicatorsSender = typingIndicatorsSender; // Update the view items if necessary. // We don't have to do this if they haven't been configured yet. diff --git a/Signal/src/ViewControllers/ConversationView/TypingIndicatorInteraction.swift b/Signal/src/ViewControllers/ConversationView/TypingIndicatorInteraction.swift index 2716f9cc5..f2bfc47cf 100644 --- a/Signal/src/ViewControllers/ConversationView/TypingIndicatorInteraction.swift +++ b/Signal/src/ViewControllers/ConversationView/TypingIndicatorInteraction.swift @@ -41,4 +41,9 @@ public class TypingIndicatorInteraction: TSInteraction { super.init(interactionWithUniqueId: TypingIndicatorInteraction.TypingIndicatorId, timestamp: timestamp, in: thread) } + + @objc + public override func save(with transaction: YapDatabaseReadWriteTransaction!) { + owsFailDebug("The transient interaction should not be saved in the database.") + } } diff --git a/Signal/src/ViewControllers/HomeView/HomeViewCell.m b/Signal/src/ViewControllers/HomeView/HomeViewCell.m index 45691640c..50737bb60 100644 --- a/Signal/src/ViewControllers/HomeView/HomeViewCell.m +++ b/Signal/src/ViewControllers/HomeView/HomeViewCell.m @@ -531,7 +531,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)updatePreview { - if ([self.typingIndicators typingIndicatorsForThread:self.thread.threadRecord] != nil) { + if ([self.typingIndicators typingRecipientIdForThread:self.thread.threadRecord] != nil) { self.snippetLabel.hidden = YES; self.typingIndicatorView.hidden = NO; [self.typingIndicatorView startAnimation]; diff --git a/SignalServiceKit/src/Contacts/TSThread.h b/SignalServiceKit/src/Contacts/TSThread.h index 15cd2b20a..0bdc7c106 100644 --- a/SignalServiceKit/src/Contacts/TSThread.h +++ b/SignalServiceKit/src/Contacts/TSThread.h @@ -12,8 +12,6 @@ NS_ASSUME_NONNULL_BEGIN typedef NSString *ConversationColorName NS_STRING_ENUM; -ConversationColorName ConversationColorNameForString(NSString *value); - extern ConversationColorName const ConversationColorNameCrimson; extern ConversationColorName const ConversationColorNameVermilion; extern ConversationColorName const ConversationColorNameBurlap; diff --git a/SignalServiceKit/src/Contacts/TSThread.m b/SignalServiceKit/src/Contacts/TSThread.m index aa7831e1c..4d046f132 100644 --- a/SignalServiceKit/src/Contacts/TSThread.m +++ b/SignalServiceKit/src/Contacts/TSThread.m @@ -19,11 +19,6 @@ NS_ASSUME_NONNULL_BEGIN -ConversationColorName ConversationColorNameForString(NSString *value) -{ - return value; -} - ConversationColorName const ConversationColorNameCrimson = @"red"; ConversationColorName const ConversationColorNameVermilion = @"orange"; ConversationColorName const ConversationColorNameBurlap = @"brown"; diff --git a/SignalServiceKit/src/Util/TypingIndicators.swift b/SignalServiceKit/src/Util/TypingIndicators.swift index 043a524e6..898248d13 100644 --- a/SignalServiceKit/src/Util/TypingIndicators.swift +++ b/SignalServiceKit/src/Util/TypingIndicators.swift @@ -31,7 +31,7 @@ public protocol TypingIndicators: class { // // TODO: Use this method. @objc - func typingIndicators(forThread thread: TSThread) -> String? + func typingRecipientId(forThread thread: TSThread) -> String? @objc func setTypingIndicatorsEnabled(value: Bool) @@ -86,7 +86,7 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators { _areTypingIndicatorsEnabled = value primaryStorage.dbReadWriteConnection.setBool(value, forKey: kDatabaseKey_TypingIndicatorsEnabled, inCollection: kDatabaseCollection) - + syncManager.sendConfigurationSyncMessage() } @@ -151,7 +151,7 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators { } @objc - public func typingIndicators(forThread thread: TSThread) -> String? { + public func typingRecipientId(forThread thread: TSThread) -> String? { AssertIsOnMainThread() var firstRecipientId: String? From 22c922bf5bcf54e48d4367187912f9aa7ccc2b58 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 1 Nov 2018 16:24:45 -0400 Subject: [PATCH 11/12] Respond to CR. --- .../ConversationView/ConversationViewModel.m | 6 ++++++ Signal/src/ViewControllers/HomeView/HomeViewCell.m | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m index f216e8bbe..eab704025 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m @@ -1336,6 +1336,12 @@ static const int kYapDatabaseRangeMinLength = 0; - (void)typingIndicatorStateDidChange:(NSNotification *)notification { OWSAssertIsOnMainThread(); + OWSAssertDebug([notification.object isKindOfClass:[NSString class]]); + OWSAssertDebug(self.thread); + + if (![notification.object isEqual:self.thread.uniqueId]) { + return; + } self.typingIndicatorsSender = [self.typingIndicators typingRecipientIdForThread:self.thread]; } diff --git a/Signal/src/ViewControllers/HomeView/HomeViewCell.m b/Signal/src/ViewControllers/HomeView/HomeViewCell.m index 50737bb60..8fbf77420 100644 --- a/Signal/src/ViewControllers/HomeView/HomeViewCell.m +++ b/Signal/src/ViewControllers/HomeView/HomeViewCell.m @@ -545,6 +545,12 @@ NS_ASSUME_NONNULL_BEGIN - (void)typingIndicatorStateDidChange:(NSNotification *)notification { OWSAssertIsOnMainThread(); + OWSAssertDebug([notification.object isKindOfClass:[NSString class]]); + OWSAssertDebug(self.thread); + + if (![notification.object isEqual:self.thread.threadRecord.uniqueId]) { + return; + } [self updatePreview]; } From b8e9cd6b581ad049e497bd82fd9c930c160e33f4 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 1 Nov 2018 16:42:49 -0400 Subject: [PATCH 12/12] Respond to CR. --- .../ViewControllers/HomeView/HomeViewCell.m | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/Signal/src/ViewControllers/HomeView/HomeViewCell.m b/Signal/src/ViewControllers/HomeView/HomeViewCell.m index 8fbf77420..2658d5d10 100644 --- a/Signal/src/ViewControllers/HomeView/HomeViewCell.m +++ b/Signal/src/ViewControllers/HomeView/HomeViewCell.m @@ -23,12 +23,13 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) UILabel *dateTimeLabel; @property (nonatomic) MessageStatusView *messageStatusView; @property (nonatomic) TypingIndicatorView *typingIndicatorView; -@property (nonatomic) UIStackView *previewStackView; @property (nonatomic) UIView *unreadBadge; @property (nonatomic) UILabel *unreadLabel; @property (nonatomic, nullable) ThreadViewModel *thread; +@property (nonatomic, nullable) NSAttributedString *overrideSnippet; +@property (nonatomic) BOOL isBlocked; @property (nonatomic, readonly) NSMutableArray *viewConstraints; @@ -118,25 +119,19 @@ NS_ASSUME_NONNULL_BEGIN self.snippetLabel.font = [self snippetFont]; self.snippetLabel.numberOfLines = 1; self.snippetLabel.lineBreakMode = NSLineBreakByTruncatingTail; + [self.snippetLabel setContentHuggingHorizontalLow]; + [self.snippetLabel setCompressionResistanceHorizontalLow]; self.typingIndicatorView = [TypingIndicatorView new]; - - self.previewStackView = [[UIStackView alloc] initWithArrangedSubviews:@[ - self.snippetLabel, - self.typingIndicatorView, - ]]; - self.previewStackView.axis = UILayoutConstraintAxisVertical; - self.previewStackView.alignment = UIStackViewAlignmentLeading; - [self.previewStackView setContentHuggingHorizontalLow]; - [self.previewStackView setCompressionResistanceHorizontalLow]; + [self.contentView addSubview:self.typingIndicatorView]; UIStackView *bottomRowView = [[UIStackView alloc] initWithArrangedSubviews:@[ - self.previewStackView, + self.snippetLabel, self.messageStatusView, ]]; - + bottomRowView.axis = UILayoutConstraintAxisHorizontal; - bottomRowView.alignment = UIStackViewAlignmentCenter; + bottomRowView.alignment = UIStackViewAlignmentLastBaseline; bottomRowView.spacing = 6.f; UIStackView *vStackView = [[UIStackView alloc] initWithArrangedSubviews:@[ @@ -171,6 +166,9 @@ NS_ASSUME_NONNULL_BEGIN [self.contentView addSubview:self.unreadBadge]; [self.unreadBadge autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.nameLabel]; + + [self.typingIndicatorView autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:self.snippetLabel]; + [self.typingIndicatorView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.snippetLabel]; } - (void)dealloc @@ -213,6 +211,8 @@ NS_ASSUME_NONNULL_BEGIN [OWSTableItem configureCell:self]; self.thread = thread; + self.overrideSnippet = overrideSnippet; + self.isBlocked = isBlocked; BOOL hasUnreadMessages = thread.hasUnreadMessages; @@ -231,18 +231,8 @@ NS_ASSUME_NONNULL_BEGIN // changes to the dynamic type settings are reflected. self.snippetLabel.font = [self snippetFont]; - if (overrideSnippet) { - self.snippetLabel.attributedText = overrideSnippet; - } else { - self.snippetLabel.attributedText = [self attributedSnippetForThread:thread isBlocked:isBlocked]; - } [self updatePreview]; - CGFloat previewHeight = MAX(self.snippetLabel.font.lineHeight, - TypingIndicatorView.kMaxRadiusPt); - [self.viewConstraints addObjectsFromArray:@[ - [self.previewStackView autoSetDimension:ALDimensionHeight - toSize:previewHeight], - ]]; + self.dateTimeLabel.text = (overrideDate ? [self stringForDate:overrideDate] : [self stringForDate:thread.lastMessageDate]); @@ -469,6 +459,7 @@ NS_ASSUME_NONNULL_BEGIN [self.viewConstraints removeAllObjects]; self.thread = nil; + self.overrideSnippet = nil; self.avatarView.image = nil; [[NSNotificationCenter defaultCenter] removeObserver:self]; @@ -532,11 +523,20 @@ NS_ASSUME_NONNULL_BEGIN - (void)updatePreview { if ([self.typingIndicators typingRecipientIdForThread:self.thread.threadRecord] != nil) { - self.snippetLabel.hidden = YES; + // If we hide snippetLabel, our layout will break since UIStackView will remove + // it from the layout. Wrapping the preview views (the snippet label and the + // typing indicator) in a UIStackView proved non-trivial since we're using + // UIStackViewAlignmentLastBaseline. Therefore we hide the _contents_ of the + // snippet label using an empty string. + self.snippetLabel.text = @" "; self.typingIndicatorView.hidden = NO; [self.typingIndicatorView startAnimation]; } else { - self.snippetLabel.hidden = NO; + if (self.overrideSnippet) { + self.snippetLabel.attributedText = self.overrideSnippet; + } else { + self.snippetLabel.attributedText = [self attributedSnippetForThread:self.thread isBlocked:self.isBlocked]; + } self.typingIndicatorView.hidden = YES; [self.typingIndicatorView stopAnimation]; }