diff --git a/Signal/src/ViewControllers/HomeView/HomeViewCell.swift b/Signal/src/ViewControllers/HomeView/HomeViewCell.swift index d1ed7a4b08..a558dcea11 100644 --- a/Signal/src/ViewControllers/HomeView/HomeViewCell.swift +++ b/Signal/src/ViewControllers/HomeView/HomeViewCell.swift @@ -4,6 +4,7 @@ import UIKit import SignalMessaging +import SignalUI @objc public class HomeViewCell: UITableViewCell { @@ -58,12 +59,19 @@ public class HomeViewCell: UITableViewCell { private struct ReuseToken { let hasMuteIndicator: Bool let hasMessageStatusToken: Bool + let hasUnreadBadge: Bool } private var reuseToken: ReuseToken? // MARK: - Configuration + fileprivate enum UnreadMode { + case none + case unreadWithCount(count: UInt) + case unreadWithoutCount + } + // Compare with HVCellContentToken: // // * Configuration captures _how_ the view wants to render the cell. @@ -83,8 +91,21 @@ public class HomeViewCell: UITableViewCell { fileprivate var hasOverrideSnippet: Bool { overrideSnippet != nil } - fileprivate var hasUnreadStyle: Bool { - thread.hasUnreadMessages && overrideSnippet == nil + fileprivate var unreadMode: UnreadMode { + guard !hasOverrideSnippet else { + // If we're using the conversation list cell to render search results, + // don't show "unread badge" or "message status" indicator. + return .none + } + guard thread.hasUnreadMessages else { + return .none + } + let unreadCount = thread.unreadCount + if unreadCount > 0 { + return .unreadWithCount(count: unreadCount) + } else { + return .unreadWithoutCount + } } init(thread: ThreadViewModel, @@ -107,7 +128,7 @@ public class HomeViewCell: UITableViewCell { // MARK: - View Constants private static var unreadFont: UIFont { - UIFont.ows_dynamicTypeCaption1Clamped.ows_semibold + UIFont.ows_dynamicTypeCaption1Clamped } private static var dateTimeFont: UIFont { @@ -184,7 +205,6 @@ public class HomeViewCell: UITableViewCell { lastReloadDate: configuration.lastReloadDate, isBlocked: configuration.isBlocked, shouldShowMuteIndicator: shouldShowMuteIndicator, - hasUnreadStyle: configuration.hasUnreadStyle, hasOverrideSnippet: configuration.hasOverrideSnippet, messageStatusToken: messageStatusToken, unreadIndicatorLabelConfig: unreadIndicatorLabelConfig, @@ -244,15 +264,27 @@ public class HomeViewCell: UITableViewCell { var bottomRowStackSubviewInfos: [ManualStackSubviewInfo] = [ bottomRowWrapperSize.asManualSubviewInfo() ] + if let messageStatusToken = configs.messageStatusToken { let statusIndicatorSize = messageStatusToken.image.size // The status indicator should vertically align with the // first line of the snippet. - let locationOffset = CGPoint(x: 0, - y: snippetLineHeight * -0.5) + let locationOffset = CGPoint(x: 0, y: snippetLineHeight * -0.5) bottomRowStackSubviewInfos.append(statusIndicatorSize.asManualSubviewInfo(hasFixedSize: true, locationOffset: locationOffset)) } + + var unreadBadgeMeasurements: HVUnreadBadgeMeasurements? + if let unreadMeasurements = measureUnreadBadge(unreadIndicatorLabelConfig: configs.unreadIndicatorLabelConfig) { + unreadBadgeMeasurements = unreadMeasurements + let unreadBadgeSize = unreadMeasurements.badgeSize + // The unread indicator should vertically align with the + // first line of the snippet. + let locationOffset = CGPoint(x: 0, y: snippetLineHeight * -0.5) + bottomRowStackSubviewInfos.append(unreadBadgeSize.asManualSubviewInfo(hasFixedSize: true, + locationOffset: locationOffset)) + } + let bottomRowStackMeasurement = ManualStackView.measure(config: bottomRowStackConfig, subviewInfos: bottomRowStackSubviewInfos) let bottomRowStackSize = bottomRowStackMeasurement.measuredSize @@ -275,7 +307,8 @@ public class HomeViewCell: UITableViewCell { bottomRowStackMeasurement: bottomRowStackMeasurement, vStackMeasurement: vStackMeasurement, outerHStackMeasurement: outerHStackMeasurement, - snippetLineHeight: snippetLineHeight) + snippetLineHeight: snippetLineHeight, + unreadBadgeMeasurements: unreadBadgeMeasurements) } func configure(cellContentToken: HVCellContentToken) { @@ -329,49 +362,6 @@ public class HomeViewCell: UITableViewCell { name: TypingIndicatorsImpl.typingIndicatorStateDidChange, object: nil) - // Avatar - - let avatarSize: CGSize = .square(CGFloat(HomeViewCell.avatarSize)) - - // Unread Indicator - - // If there are unread messages, show the "unread badge." - if let unreadIndicatorLabelConfig = configs.unreadIndicatorLabelConfig { - let unreadLabel = self.unreadLabel - unreadIndicatorLabelConfig.applyForRendering(label: unreadLabel) - unreadLabel.removeFromSuperview() - let unreadLabelSize = unreadLabel.sizeThatFits(.square(.greatestFiniteMagnitude)) - - let unreadBadge = self.unreadBadge - unreadBadge.backgroundColor = .ows_accentBlue - unreadBadge.addSubview(unreadLabel) { view in - // Center within badge. - unreadLabel.frame = CGRect(origin: (view.frame.size - unreadLabelSize).asPoint * 0.5, - size: unreadLabelSize) - } - - let unreadBadgeHeight = ceil(unreadLabel.font.lineHeight * 1.5) - unreadBadge.layer.cornerRadius = unreadBadgeHeight / 2 - unreadBadge.layer.borderColor = Theme.backgroundColor.cgColor - unreadBadge.layer.borderWidth = 2 - // This is a bit arbitrary, but it should scale with the size of dynamic text - let minMargin = CeilEven(unreadBadgeHeight * 0.5) - // Pill should be at least circular; can be wider. - let unreadBadgeSize = CGSize(width: max(unreadBadgeHeight, - unreadLabelSize.width + minMargin), - height: unreadBadgeHeight) - avatarStack.addSubview(unreadBadge) { view in - unreadBadge.frame = CGRect(origin: CGPoint(x: view.width - unreadBadgeSize.width + 6, - y: (view.height - avatarSize.height) * 0.5), - size: unreadBadgeSize) - } - // The unread badge should appear on top of the avatar, but it is added - // to the view hierarchy first (in the "configure" below where we leverage - // existing measurement/arrangements). We solve that by nudging the zPosition - // of the unread badge. - unreadBadge.layer.zPosition = +1 - } - // The top row contains: // // * Name label @@ -430,21 +420,31 @@ public class HomeViewCell: UITableViewCell { width: typingIndicatorSize.width, height: typingIndicatorSize.height) } + updateTypingIndicatorState() var bottomRowStackSubviews: [UIView] = [ bottomRowWrapper ] - if let messageStatusToken = cellContentToken.configs.messageStatusToken { + if let messageStatusToken = configs.messageStatusToken { let statusIndicator = configureStatusIndicatorView(token: messageStatusToken) bottomRowStackSubviews.append(statusIndicator) } - updateTypingIndicatorState() + // If there are unread messages, show the "unread badge." + if let unreadIndicatorLabelConfig = configs.unreadIndicatorLabelConfig, + let unreadBadgeMeasurements = measurements.unreadBadgeMeasurements { + let unreadBadge = configureUnreadBadge(unreadIndicatorLabelConfig: unreadIndicatorLabelConfig, + unreadBadgeMeasurements: unreadBadgeMeasurements) + bottomRowStackSubviews.append(unreadBadge) + } let avatarStackSubviews = [ avatarView ] let vStackSubviews = [ topRowStack, bottomRowStack ] let outerHStackSubviews = [ avatarStack, vStack ] + // It is only safe to reuse the bottom row wrapper if its subview list + // hasn't changed. let newReuseToken = ReuseToken(hasMuteIndicator: shouldShowMuteIndicator, - hasMessageStatusToken: cellContentToken.configs.messageStatusToken != nil) + hasMessageStatusToken: configs.messageStatusToken != nil, + hasUnreadBadge: measurements.unreadBadgeMeasurements != nil) avatarStack.configure(config: avatarStackConfig, measurement: avatarStackMeasurement, @@ -463,10 +463,11 @@ public class HomeViewCell: UITableViewCell { subviews: topRowStackSubviews) } - // bottomRowStack can only be configured for reuse if - // its subview list hasn't changed. + // It is only safe to reuse bottomRowStack if the same subset of subviews + // are in use. if let oldReuseToken = self.reuseToken, - oldReuseToken.hasMessageStatusToken == newReuseToken.hasMessageStatusToken { + oldReuseToken.hasMessageStatusToken == newReuseToken.hasMessageStatusToken, + oldReuseToken.hasUnreadBadge == newReuseToken.hasUnreadBadge { bottomRowStack.configureForReuse(config: bottomRowStackConfig, measurement: bottomRowStackMeasurement) } else { @@ -614,18 +615,20 @@ public class HomeViewCell: UITableViewCell { // MARK: - Unread Indicator private static func buildUnreadIndicatorLabelConfig(configuration: Configuration) -> CVLabelConfig? { - guard !configuration.hasOverrideSnippet else { + let text: String + switch configuration.unreadMode { + case .none: // If we're using the conversation list cell to render search results, // don't show "unread badge" or "message status" indicator. + // + // Or there might simply be no unread messages / the thread is not + // marked as unread. return nil + case .unreadWithoutCount: + text = "" + case .unreadWithCount(let unreadCount): + text = unreadCount > 0 ? OWSFormat.formatUInt(unreadCount) : "" } - guard configuration.hasUnreadStyle else { - return nil - } - - let thread = configuration.thread - let unreadCount = thread.unreadCount - let text = unreadCount > 0 ? OWSFormat.formatUInt(unreadCount) : "" return CVLabelConfig(text: text, font: unreadFont, textColor: .ows_white, @@ -634,6 +637,48 @@ public class HomeViewCell: UITableViewCell { textAlignment: .center) } + private static func measureUnreadBadge(unreadIndicatorLabelConfig: CVLabelConfig?) -> HVUnreadBadgeMeasurements? { + + guard let unreadIndicatorLabelConfig = unreadIndicatorLabelConfig else { + return nil + } + + let unreadLabelSize = CVText.measureLabel(config: unreadIndicatorLabelConfig, + maxWidth: .greatestFiniteMagnitude) + + let unreadBadgeHeight = ceil(unreadIndicatorLabelConfig.font.lineHeight * 1.35) + // This is a bit arbitrary, but it should scale with the size of dynamic text + let minMargin = CeilEven(unreadBadgeHeight * 0.5) + // Pill should be at least circular; can be wider. + let badgeSize = CGSize(width: max(unreadBadgeHeight, + unreadLabelSize.width + minMargin), + height: unreadBadgeHeight) + + return HVUnreadBadgeMeasurements(badgeSize: badgeSize, + unreadLabelSize: unreadLabelSize) + } + + private func configureUnreadBadge(unreadIndicatorLabelConfig: CVLabelConfig, + unreadBadgeMeasurements measurements: HVUnreadBadgeMeasurements) -> UIView { + + let unreadLabel = self.unreadLabel + unreadIndicatorLabelConfig.applyForRendering(label: unreadLabel) + unreadLabel.removeFromSuperview() + let unreadLabelSize = measurements.unreadLabelSize + + let unreadBadge = self.unreadBadge + unreadBadge.backgroundColor = .ows_accentBlue + unreadBadge.addSubview(unreadLabel) { view in + // Center within badge. + unreadLabel.frame = CGRect(origin: (view.frame.size - unreadLabelSize).asPoint * 0.5, + size: unreadLabelSize) + } + + let unreadBadgeHeight = measurements.badgeSize.height + unreadBadge.layer.cornerRadius = unreadBadgeHeight / 2 + return unreadBadge + } + // MARK: - Label Configs private static func attributedSnippet(configuration: Configuration) -> NSAttributedString { @@ -750,19 +795,13 @@ public class HomeViewCell: UITableViewCell { let thread = configuration.thread var text: String = "" if let labelDate = configuration.overrideDate ?? thread.homeViewInfo?.lastMessageDate { - text = DateUtil.formatDateShort(labelDate) - } - if configuration.hasUnreadStyle { - return CVLabelConfig(text: text, - font: dateTimeFont.ows_semibold, - textColor: Theme.primaryTextColor, - textAlignment: .trailing) - } else { - return CVLabelConfig(text: text, - font: dateTimeFont, - textColor: snippetColor, - textAlignment: .trailing) + text = DateUtil.formatMessageTimestampForCVC(labelDate.ows_millisecondsSince1970, + shouldUseLongFormat: false) } + return CVLabelConfig(text: text, + font: dateTimeFont, + textColor: snippetColor, + textAlignment: .trailing) } private static func nameLabelConfig(configuration: Configuration) -> CVLabelConfig { @@ -914,7 +953,6 @@ private struct HVCellConfigs { let lastReloadDate: Date? let isBlocked: Bool let shouldShowMuteIndicator: Bool - let hasUnreadStyle: Bool let hasOverrideSnippet: Bool let messageStatusToken: HVMessageStatusToken? let unreadIndicatorLabelConfig: CVLabelConfig? @@ -932,6 +970,13 @@ private struct HVCellConfigs { // MARK: - +private struct HVUnreadBadgeMeasurements { + let badgeSize: CGSize + let unreadLabelSize: CGSize +} + +// MARK: - + private struct HVCellMeasurements { let avatarStackMeasurement: ManualStackView.Measurement let topRowStackMeasurement: ManualStackView.Measurement @@ -939,6 +984,7 @@ private struct HVCellMeasurements { let vStackMeasurement: ManualStackView.Measurement let outerHStackMeasurement: ManualStackView.Measurement let snippetLineHeight: CGFloat + let unreadBadgeMeasurements: HVUnreadBadgeMeasurements? } // MARK: - @@ -978,7 +1024,6 @@ class HVCellContentToken { fileprivate var thread: TSThread { configs.thread } fileprivate var isBlocked: Bool { configs.isBlocked } fileprivate var shouldShowMuteIndicator: Bool { configs.shouldShowMuteIndicator } - fileprivate var hasUnreadStyle: Bool { configs.hasUnreadStyle } fileprivate var hasOverrideSnippet: Bool { configs.hasOverrideSnippet } fileprivate var shouldLoadAvatarAsync: Bool {