// Copyright © 2020 Metabolist. All rights reserved. // swiftlint:disable file_length import Combine import Mastodon import SDWebImage import UIKit import ViewModels final class StatusView: UIView { let avatarImageView = SDAnimatedImageView() let rebloggerAvatarImageView = SDAnimatedImageView() let avatarButton = UIButton() let infoIcon = UIImageView() let infoLabel = AnimatedAttachmentLabel() let rebloggerButton = UIButton() let displayNameLabel = AnimatedAttachmentLabel() let accountLabel = UILabel() let nameButton = UIButton() let timeLabel = UILabel() let bodyView = StatusBodyView() let showThreadIndicator = UIButton(type: .system) let contextParentTimeLabel = UILabel() let visibilityImageView = UIImageView() let applicationButton = UIButton(type: .system) let rebloggedByButton = UIButton() let favoritedByButton = UIButton() let replyButton = UIButton() let reblogButton = UIButton() let favoriteButton = UIButton() let shareButton = UIButton() let menuButton = UIButton() let buttonsStackView = UIStackView() let reportSelectionSwitch = UISwitch() private let containerStackView = UIStackView() private let sideStackView = UIStackView() private let mainStackView = UIStackView() private let avatarContainerView = UIView() private let nameAccountContainerStackView = UIStackView() private let nameAccountTimeStackView = UIStackView() private let contextParentTimeApplicationStackView = UIStackView() private let timeVisibilityDividerLabel = UILabel() private let visibilityApplicationDividerLabel = UILabel() private let contextParentTopNameAccountSpacingView = UIView() private let contextParentBottomNameAccountSpacingView = UIView() private let interactionsDividerView = UIView() private let interactionsStackView = UIStackView() private let buttonsDividerView = UIView() private let inReplyToView = UIView() private let hasReplyFollowingView = UIView() private var statusConfiguration: StatusContentConfiguration private let avatarWidthConstraint: NSLayoutConstraint private let avatarHeightConstraint: NSLayoutConstraint private var cancellables = Set() init(configuration: StatusContentConfiguration) { self.statusConfiguration = configuration avatarWidthConstraint = avatarImageView.widthAnchor.constraint(equalToConstant: .avatarDimension) avatarHeightConstraint = avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension) avatarHeightConstraint.priority = .justBelowMax super.init(frame: .zero) initialSetup() applyStatusConfiguration() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func accessibilityActivate() -> Bool { if reportSelectionSwitch.isHidden, !statusConfiguration.viewModel.shouldShowContent { statusConfiguration.viewModel.toggleShowContent() accessibilityAttributedLabel = accessibilityAttributedLabel(forceShowContent: true) return true } else { return super.accessibilityActivate() } } } extension StatusView { static func estimatedHeight(width: CGFloat, identityContext: IdentityContext, status: Status, configuration: CollectionItem.StatusConfiguration) -> CGFloat { var height = CGFloat.defaultSpacing * 2 let bodyWidth = width - .defaultSpacing - .avatarDimension if status.reblog != nil || configuration.isPinned { height += UIFont.preferredFont(forTextStyle: .caption1).lineHeight + .compactSpacing } if configuration.isContextParent { height += .avatarDimension + .minimumButtonDimension * 2.5 + .hairline * 2 + .compactSpacing * 4 } else { height += UIFont.preferredFont(forTextStyle: .headline).lineHeight + .compactSpacing + .minimumButtonDimension / 2 } height += StatusBodyView.estimatedHeight( width: bodyWidth, identityContext: identityContext, status: status, configuration: configuration) + .compactSpacing if configuration.isReplyOutOfContext { height += UIFont.preferredFont(forTextStyle: .callout).lineHeight + .compactSpacing } return height } func refreshAccessibilityLabel() { accessibilityAttributedLabel = accessibilityAttributedLabel(forceShowContent: false) } } extension StatusView: UIContentView { var configuration: UIContentConfiguration { get { statusConfiguration } set { guard let statusConfiguration = newValue as? StatusContentConfiguration else { return } self.statusConfiguration = statusConfiguration applyStatusConfiguration() } } } extension StatusView: UITextViewDelegate { func textView( _ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { switch interaction { case .invokeDefaultAction: statusConfiguration.viewModel.urlSelected(URL) return false case .preview: return false case .presentActions: return false @unknown default: return false } } } private extension StatusView { static let actionButtonTitleEdgeInsets = UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 0) static let reblogAvatarDimension: CGFloat = .avatarDimension * 7 / 8 var actionButtons: [UIButton] { [replyButton, reblogButton, favoriteButton, shareButton, menuButton] } // swiftlint:disable function_body_length func initialSetup() { addSubview(containerStackView) containerStackView.translatesAutoresizingMaskIntoConstraints = false containerStackView.spacing = .defaultSpacing infoIcon.tintColor = .secondaryLabel infoIcon.contentMode = .scaleAspectFit infoIcon.setContentCompressionResistancePriority(.required, for: .vertical) sideStackView.axis = .vertical sideStackView.alignment = .trailing sideStackView.spacing = .compactSpacing sideStackView.addArrangedSubview(infoIcon) sideStackView.addArrangedSubview(UIView()) containerStackView.addArrangedSubview(sideStackView) mainStackView.axis = .vertical mainStackView.spacing = .compactSpacing containerStackView.addArrangedSubview(mainStackView) infoLabel.font = .preferredFont(forTextStyle: .caption1) infoLabel.textColor = .secondaryLabel infoLabel.adjustsFontForContentSizeCategory = true infoLabel.isUserInteractionEnabled = true infoLabel.setContentHuggingPriority(.required, for: .vertical) mainStackView.addArrangedSubview(infoLabel) infoLabel.addSubview(rebloggerButton) rebloggerButton.translatesAutoresizingMaskIntoConstraints = false rebloggerButton.addAction( UIAction { [weak self] _ in self?.statusConfiguration.viewModel.rebloggerAccountSelected() }, for: .touchUpInside) let rebloggerTouchStartAction = UIAction { [weak self] _ in self?.infoLabel.alpha = 0.75 } rebloggerButton.addAction(rebloggerTouchStartAction, for: .touchDown) rebloggerButton.addAction(rebloggerTouchStartAction, for: .touchDragEnter) let rebloggerTouchEnd = UIAction { [weak self] _ in self?.infoLabel.alpha = 1 } rebloggerButton.addAction(rebloggerTouchEnd, for: .touchDragExit) rebloggerButton.addAction(rebloggerTouchEnd, for: .touchUpInside) rebloggerButton.addAction(rebloggerTouchEnd, for: .touchUpOutside) rebloggerButton.addAction(rebloggerTouchEnd, for: .touchCancel) displayNameLabel.font = .preferredFont(forTextStyle: .headline) displayNameLabel.adjustsFontForContentSizeCategory = true displayNameLabel.setContentHuggingPriority(.required, for: .horizontal) displayNameLabel.setContentHuggingPriority(.required, for: .vertical) displayNameLabel.setContentCompressionResistancePriority(.required, for: .horizontal) nameAccountTimeStackView.addArrangedSubview(displayNameLabel) accountLabel.font = .preferredFont(forTextStyle: .subheadline) accountLabel.adjustsFontForContentSizeCategory = true accountLabel.textColor = .secondaryLabel accountLabel.setContentHuggingPriority(.required, for: .horizontal) accountLabel.setContentHuggingPriority(.required, for: .vertical) nameAccountTimeStackView.addArrangedSubview(accountLabel) timeLabel.font = .preferredFont(forTextStyle: .subheadline) timeLabel.adjustsFontForContentSizeCategory = true timeLabel.textColor = .secondaryLabel timeLabel.textAlignment = .right timeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) timeLabel.setContentHuggingPriority(.required, for: .vertical) nameAccountTimeStackView.addArrangedSubview(timeLabel) nameAccountContainerStackView.spacing = .defaultSpacing nameAccountContainerStackView.addArrangedSubview(nameAccountTimeStackView) mainStackView.addArrangedSubview(nameAccountContainerStackView) nameButton.translatesAutoresizingMaskIntoConstraints = false nameButton.addAction( UIAction { [weak self] _ in self?.displayNameLabel.alpha = 1 self?.accountLabel.alpha = 1 self?.statusConfiguration.viewModel.accountSelected() }, for: .touchUpInside) nameButton.addAction( UIAction { [weak self] _ in self?.displayNameLabel.alpha = 0.5 self?.accountLabel.alpha = 0.5 }, for: .touchDown) let unhighlightAction = UIAction { [weak self] _ in self?.displayNameLabel.alpha = 1 self?.accountLabel.alpha = 1 } nameButton.addAction(unhighlightAction, for: .touchUpOutside) nameButton.addAction(unhighlightAction, for: .touchCancel) nameButton.addAction(unhighlightAction, for: .touchDragOutside) nameAccountContainerStackView.addSubview(nameButton) mainStackView.addArrangedSubview(bodyView) mainStackView.addArrangedSubview(showThreadIndicator) showThreadIndicator.isHidden = true showThreadIndicator.setTitle(NSLocalizedString("status.show-thread", comment: ""), for: .normal) showThreadIndicator.titleLabel?.adjustsFontForContentSizeCategory = true showThreadIndicator.titleLabel?.font = .preferredFont(forTextStyle: .callout) showThreadIndicator.isUserInteractionEnabled = false contextParentTimeLabel.font = .preferredFont(forTextStyle: .footnote) contextParentTimeLabel.adjustsFontForContentSizeCategory = true contextParentTimeLabel.textColor = .secondaryLabel contextParentTimeLabel.setContentHuggingPriority(.required, for: .horizontal) contextParentTimeApplicationStackView.addArrangedSubview(contextParentTimeLabel) for label in [timeVisibilityDividerLabel, visibilityApplicationDividerLabel] { label.font = .preferredFont(forTextStyle: .footnote) label.adjustsFontForContentSizeCategory = true label.textColor = .secondaryLabel label.text = "•" label.setContentHuggingPriority(.required, for: .horizontal) label.isAccessibilityElement = false } contextParentTimeApplicationStackView.addArrangedSubview(timeVisibilityDividerLabel) contextParentTimeApplicationStackView.addArrangedSubview(visibilityImageView) visibilityImageView.contentMode = .scaleAspectFit visibilityImageView.tintColor = .secondaryLabel visibilityImageView.isAccessibilityElement = true contextParentTimeApplicationStackView.addArrangedSubview(visibilityApplicationDividerLabel) applicationButton.titleLabel?.font = .preferredFont(forTextStyle: .footnote) applicationButton.titleLabel?.adjustsFontForContentSizeCategory = true applicationButton.setTitleColor(.secondaryLabel, for: .disabled) applicationButton.setContentHuggingPriority(.required, for: .horizontal) applicationButton.addAction( UIAction { [weak self] _ in guard let viewModel = self?.statusConfiguration.viewModel, let url = viewModel.applicationURL else { return } viewModel.urlSelected(url) }, for: .touchUpInside) contextParentTimeApplicationStackView.addArrangedSubview(applicationButton) contextParentTimeApplicationStackView.addArrangedSubview(UIView()) contextParentTimeApplicationStackView.spacing = .compactSpacing mainStackView.addArrangedSubview(contextParentTimeApplicationStackView) for view in [interactionsDividerView, buttonsDividerView] { view.backgroundColor = .opaqueSeparator view.heightAnchor.constraint(equalToConstant: .hairline).isActive = true } mainStackView.addArrangedSubview(interactionsDividerView) mainStackView.addArrangedSubview(interactionsStackView) mainStackView.addArrangedSubview(buttonsDividerView) rebloggedByButton.contentHorizontalAlignment = .leading rebloggedByButton.addAction( UIAction { [weak self] _ in self?.statusConfiguration.viewModel.rebloggedBySelected() }, for: .touchUpInside) interactionsStackView.addArrangedSubview(rebloggedByButton) favoritedByButton.contentHorizontalAlignment = .leading favoritedByButton.addAction( UIAction { [weak self] _ in self?.statusConfiguration.viewModel.favoritedBySelected() }, for: .touchUpInside) interactionsStackView.addArrangedSubview(favoritedByButton) interactionsStackView.distribution = .fillEqually replyButton.addAction( UIAction { [weak self] _ in self?.statusConfiguration.viewModel.reply() }, for: .touchUpInside) replyButton.accessibilityLabel = NSLocalizedString("status.reply-button.accessibility-label", comment: "") reblogButton.addAction( UIAction { [weak self] _ in guard let self = self, !self.statusConfiguration.viewModel.identityContext.appPreferences.requireDoubleTapToReblog else { return } self.reblog() }, for: .touchUpInside) reblogButton.addTarget(self, action: #selector(reblogButtonDoubleTap(sender:event:)), for: .touchDownRepeat) favoriteButton.addAction( UIAction { [weak self] _ in guard let self = self, !self.statusConfiguration.viewModel.identityContext.appPreferences.requireDoubleTapToFavorite else { return } self.favorite() }, for: .touchUpInside) favoriteButton.accessibilityLabel = NSLocalizedString("status.favorite-button.accessibility-label", comment: "") favoriteButton.addTarget(self, action: #selector(favoriteButtonDoubleTap(sender:event:)), for: .touchDownRepeat) shareButton.addAction( UIAction { [weak self] _ in self?.statusConfiguration.viewModel.shareStatus() }, for: .touchUpInside) menuButton.showsMenuAsPrimaryAction = true for button in actionButtons { button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) button.titleLabel?.adjustsFontSizeToFitWidth = true button.tintColor = .secondaryLabel button.setTitleColor(.secondaryLabel, for: .normal) button.titleEdgeInsets = Self.actionButtonTitleEdgeInsets buttonsStackView.addArrangedSubview(button) button.widthAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension).isActive = true } buttonsStackView.distribution = .equalSpacing mainStackView.addArrangedSubview(buttonsStackView) avatarContainerView.addSubview(avatarImageView) avatarImageView.translatesAutoresizingMaskIntoConstraints = false avatarImageView.layer.cornerRadius = .avatarDimension / 2 avatarImageView.clipsToBounds = true avatarButton.translatesAutoresizingMaskIntoConstraints = false avatarImageView.addSubview(avatarButton) avatarImageView.isUserInteractionEnabled = true avatarButton.setBackgroundImage(.highlightedButtonBackground, for: .highlighted) avatarButton.addAction( UIAction { [weak self] _ in self?.statusConfiguration.viewModel.accountSelected() }, for: .touchUpInside) avatarContainerView.addSubview(rebloggerAvatarImageView) rebloggerAvatarImageView.translatesAutoresizingMaskIntoConstraints = false rebloggerAvatarImageView.layer.cornerRadius = .avatarDimension / 4 rebloggerAvatarImageView.clipsToBounds = true rebloggerAvatarImageView.isHidden = true for view in [inReplyToView, hasReplyFollowingView] { addSubview(view) view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = .opaqueSeparator view.widthAnchor.constraint(equalToConstant: .hairline).isActive = true } containerStackView.addArrangedSubview(reportSelectionSwitch) reportSelectionSwitch.setContentCompressionResistancePriority(.required, for: .horizontal) reportSelectionSwitch.setContentHuggingPriority(.required, for: .horizontal) reportSelectionSwitch.setContentHuggingPriority(.defaultLow, for: .vertical) reportSelectionSwitch.isHidden = true NSLayoutConstraint.activate([ containerStackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), containerStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), containerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), containerStackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor), avatarContainerView.widthAnchor.constraint(equalToConstant: .avatarDimension), avatarContainerView.heightAnchor.constraint(equalToConstant: .avatarDimension), avatarWidthConstraint, avatarHeightConstraint, avatarImageView.topAnchor.constraint(equalTo: avatarContainerView.topAnchor), avatarImageView.leadingAnchor.constraint(equalTo: avatarContainerView.leadingAnchor), rebloggerAvatarImageView.widthAnchor.constraint(equalToConstant: .avatarDimension / 2), rebloggerAvatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension / 2), rebloggerAvatarImageView.trailingAnchor.constraint(equalTo: avatarContainerView.trailingAnchor), rebloggerAvatarImageView.bottomAnchor.constraint(equalTo: avatarContainerView.bottomAnchor), sideStackView.widthAnchor.constraint(equalToConstant: .avatarDimension), avatarButton.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor), avatarButton.topAnchor.constraint(equalTo: avatarImageView.topAnchor), avatarButton.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor), avatarButton.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor), infoIcon.centerYAnchor.constraint(equalTo: infoLabel.centerYAnchor), nameButton.leadingAnchor.constraint(equalTo: displayNameLabel.leadingAnchor), nameButton.topAnchor.constraint(equalTo: displayNameLabel.topAnchor), nameButton.trailingAnchor.constraint(equalTo: accountLabel.trailingAnchor), nameButton.bottomAnchor.constraint(equalTo: accountLabel.bottomAnchor), contextParentTimeApplicationStackView.heightAnchor.constraint( greaterThanOrEqualToConstant: .minimumButtonDimension / 2), interactionsStackView.heightAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension), rebloggerButton.leadingAnchor.constraint(equalTo: infoLabel.leadingAnchor), rebloggerButton.topAnchor.constraint(equalTo: infoLabel.topAnchor), rebloggerButton.trailingAnchor.constraint(equalTo: infoLabel.trailingAnchor), rebloggerButton.bottomAnchor.constraint(equalTo: infoLabel.bottomAnchor) ]) NotificationCenter.default.publisher(for: UIAccessibility.voiceOverStatusDidChangeNotification) .sink { [weak self] _ in self?.configureUserInteractionEnabledForAccessibility() } .store(in: &cancellables) } func applyStatusConfiguration() { let viewModel = statusConfiguration.viewModel let isContextParent = viewModel.configuration.isContextParent let mutableDisplayName = NSMutableAttributedString(string: viewModel.accountViewModel.displayName) let isAuthenticated = viewModel.identityContext.identity.authenticated && !viewModel.identityContext.identity.pending menuButton.menu = menu(viewModel: viewModel) avatarImageView.sd_setImage(with: viewModel.avatarURL) avatarButton.accessibilityLabel = String.localizedStringWithFormat( NSLocalizedString("account.avatar.accessibility-label-%@", comment: ""), viewModel.accountViewModel.displayName) sideStackView.isHidden = isContextParent let avatarDimension = viewModel.isReblog ? Self.reblogAvatarDimension : .avatarDimension avatarWidthConstraint.constant = avatarDimension avatarHeightConstraint.constant = avatarDimension avatarImageView.layer.cornerRadius = avatarDimension / 2 rebloggerAvatarImageView.isHidden = !viewModel.isReblog rebloggerAvatarImageView.sd_setImage(with: viewModel.isReblog ? viewModel.rebloggerAvatarURL : nil) if isContextParent, avatarContainerView.superview !== nameAccountContainerStackView { nameAccountContainerStackView.insertArrangedSubview(avatarContainerView, at: 0) } else if avatarContainerView.superview !== sideStackView { sideStackView.insertArrangedSubview(avatarContainerView, at: 1) } NSLayoutConstraint.activate([ inReplyToView.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor), inReplyToView.topAnchor.constraint(equalTo: topAnchor), inReplyToView.bottomAnchor.constraint(equalTo: avatarImageView.topAnchor), hasReplyFollowingView.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor), hasReplyFollowingView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor), hasReplyFollowingView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) inReplyToView.isHidden = !viewModel.configuration.isReplyInContext hasReplyFollowingView.isHidden = !viewModel.configuration.hasReplyFollowing if viewModel.isReblog { let attributedTitle = "status.reblogged-by-%@".localizedBolding( displayName: viewModel.rebloggedByDisplayName, emojis: viewModel.rebloggedByDisplayNameEmojis, label: infoLabel, identityContext: viewModel.identityContext) let highlightedAttributedTitle = NSMutableAttributedString(attributedString: attributedTitle) highlightedAttributedTitle.addAttribute( .foregroundColor, value: UIColor.tertiaryLabel, range: .init(location: 0, length: highlightedAttributedTitle.length)) infoLabel.attributedText = attributedTitle infoIcon.image = UIImage( systemName: "arrow.2.squarepath", withConfiguration: UIImage.SymbolConfiguration(scale: .small)) infoLabel.isHidden = false infoIcon.isHidden = false rebloggerButton.isHidden = false } else if viewModel.configuration.isPinned { let pinnedText: String switch viewModel.identityContext.appPreferences.statusWord { case .toot: pinnedText = NSLocalizedString("status.pinned.toot", comment: "") case .post: pinnedText = NSLocalizedString("status.pinned.post", comment: "") } infoLabel.text = pinnedText infoIcon.centerYAnchor.constraint(equalTo: infoLabel.centerYAnchor).isActive = true infoIcon.image = UIImage( systemName: "pin", withConfiguration: UIImage.SymbolConfiguration(scale: .small)) infoLabel.isHidden = false infoIcon.isHidden = false rebloggerButton.isHidden = true } else { infoLabel.text = nil infoIcon.image = nil infoLabel.isHidden = true infoIcon.isHidden = true rebloggerButton.setTitle(nil, for: .normal) rebloggerButton.setImage(nil, for: .normal) rebloggerButton.isHidden = true } mutableDisplayName.insert(emojis: viewModel.accountViewModel.emojis, view: displayNameLabel, identityContext: viewModel.identityContext) mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight) displayNameLabel.attributedText = mutableDisplayName accountLabel.text = viewModel.accountName let nameButtonAccessibilityAttributedLabel = NSMutableAttributedString(attributedString: mutableDisplayName) nameButtonAccessibilityAttributedLabel.appendWithSeparator(viewModel.accountName) nameButton.accessibilityAttributedLabel = nameButtonAccessibilityAttributedLabel nameAccountTimeStackView.axis = isContextParent ? .vertical : .horizontal nameAccountTimeStackView.alignment = isContextParent ? .leading : .fill nameAccountTimeStackView.spacing = isContextParent ? 0 : .compactSpacing contextParentTopNameAccountSpacingView.removeFromSuperview() contextParentBottomNameAccountSpacingView.removeFromSuperview() if isContextParent { nameAccountTimeStackView.insertArrangedSubview(contextParentTopNameAccountSpacingView, at: 0) nameAccountTimeStackView.addArrangedSubview(contextParentBottomNameAccountSpacingView) contextParentTopNameAccountSpacingView.heightAnchor .constraint(equalTo: contextParentBottomNameAccountSpacingView.heightAnchor).isActive = true } timeLabel.text = viewModel.time timeLabel.accessibilityLabel = viewModel.accessibilityTime timeLabel.isHidden = isContextParent bodyView.viewModel = viewModel showThreadIndicator.isHidden = !viewModel.configuration.isReplyOutOfContext contextParentTimeLabel.text = viewModel.contextParentTime contextParentTimeLabel.accessibilityLabel = viewModel.accessibilityContextParentTime visibilityImageView.image = UIImage(systemName: viewModel.visibility.systemImageName) visibilityImageView.accessibilityLabel = viewModel.visibility.title visibilityApplicationDividerLabel.isHidden = viewModel.applicationName == nil applicationButton.isHidden = viewModel.applicationName == nil applicationButton.setTitle(viewModel.applicationName, for: .normal) applicationButton.isEnabled = viewModel.applicationURL != nil contextParentTimeApplicationStackView.isHidden = !isContextParent let noReblogs = viewModel.reblogsCount == 0 let noFavorites = viewModel.favoritesCount == 0 let noInteractions = !isContextParent || (noReblogs && noFavorites) rebloggedByButton.setAttributedLocalizedTitle( localizationKey: "status.reblogs-count-%ld", count: viewModel.reblogsCount) rebloggedByButton.isHidden = noReblogs favoritedByButton.setAttributedLocalizedTitle( localizationKey: "status.favorites-count-%ld", count: viewModel.favoritesCount) favoritedByButton.isHidden = noFavorites interactionsDividerView.isHidden = noInteractions interactionsStackView.isHidden = noInteractions buttonsDividerView.isHidden = !isContextParent for button in actionButtons { button.contentHorizontalAlignment = isContextParent ? .center : .leading if isContextParent { button.heightAnchor.constraint(equalToConstant: .minimumButtonDimension).isActive = true } else { button.heightAnchor.constraint( greaterThanOrEqualToConstant: .minimumButtonDimension * 2 / 3).isActive = true } } setButtonImages(font: isContextParent ? .preferredFont(forTextStyle: .title3) : .preferredFont(forTextStyle: .subheadline)) replyButton.setCountTitle(count: viewModel.repliesCount, isContextParent: isContextParent) replyButton.isEnabled = isAuthenticated replyButton.menu = authenticatedIdentitiesMenu { viewModel.reply(identity: $0) } if viewModel.identityContext.appPreferences.showReblogAndFavoriteCounts || isContextParent { reblogButton.setCountTitle(count: viewModel.reblogsCount, isContextParent: isContextParent) favoriteButton.setCountTitle(count: viewModel.favoritesCount, isContextParent: isContextParent) } else { reblogButton.setTitle(nil, for: .normal) favoriteButton.setTitle(nil, for: .normal) } setReblogButtonColor(reblogged: viewModel.reblogged) reblogButton.isEnabled = viewModel.canBeReblogged && isAuthenticated reblogButton.menu = authenticatedIdentitiesMenu { viewModel.toggleReblogged(identityId: $0.id) } setFavoriteButtonColor(favorited: viewModel.favorited) favoriteButton.isEnabled = isAuthenticated favoriteButton.menu = authenticatedIdentitiesMenu { viewModel.toggleFavorited(identityId: $0.id) } shareButton.tag = viewModel.sharingURL?.hashValue ?? 0 menuButton.isEnabled = isAuthenticated reportSelectionSwitch.isOn = viewModel.selectedForReport isAccessibilityElement = !viewModel.configuration.isContextParent accessibilityAttributedLabel = accessibilityAttributedLabel(forceShowContent: false) configureUserInteractionEnabledForAccessibility() accessibilityCustomActions = accessibilityCustomActions(viewModel: viewModel) } func menu(viewModel: StatusViewModel) -> UIMenu { var sections = [UIMenu]() var firstSectionItems = [ UIAction( title: viewModel.bookmarked ? NSLocalizedString("status.unbookmark", comment: "") : NSLocalizedString("status.bookmark", comment: ""), image: UIImage(systemName: "bookmark")) { _ in viewModel.toggleBookmarked() } ] if let pinned = viewModel.pinned { firstSectionItems.append(UIAction( title: pinned ? NSLocalizedString("status.unpin", comment: "") : NSLocalizedString("status.pin", comment: ""), image: UIImage(systemName: "pin")) { _ in viewModel.togglePinned() }) } sections.append(UIMenu(options: .displayInline, children: firstSectionItems)) var secondSectionItems = [UIAction]() if viewModel.isMine { secondSectionItems += [ UIAction( title: viewModel.muted ? NSLocalizedString("status.unmute", comment: "") : NSLocalizedString("status.mute", comment: ""), image: UIImage(systemName: viewModel.muted ? "speaker" : "speaker.slash")) { _ in viewModel.toggleMuted() }, UIAction( title: NSLocalizedString("status.delete", comment: ""), image: UIImage(systemName: "trash"), attributes: .destructive) { _ in viewModel.confirmDelete(redraft: false) }, UIAction( title: NSLocalizedString("status.delete-and-redraft", comment: ""), image: UIImage(systemName: "trash.circle"), attributes: .destructive) { _ in viewModel.confirmDelete(redraft: true) } ] sections.append(UIMenu(options: .displayInline, children: secondSectionItems)) } else { if let relationship = viewModel.accountViewModel.relationship { if relationship.muting { secondSectionItems.append(UIAction( title: NSLocalizedString("account.unmute", comment: ""), image: UIImage(systemName: "speaker")) { _ in viewModel.accountViewModel.confirmUnmute() }) } else { secondSectionItems.append(UIAction( title: NSLocalizedString("account.mute", comment: ""), image: UIImage(systemName: "speaker.slash")) { _ in viewModel.accountViewModel.confirmMute() }) } if relationship.blocking { secondSectionItems.append(UIAction( title: NSLocalizedString("account.unblock", comment: ""), image: UIImage(systemName: "slash.circle"), attributes: .destructive) { _ in viewModel.accountViewModel.confirmUnblock() }) } else { secondSectionItems.append(UIAction( title: NSLocalizedString("account.block", comment: ""), image: UIImage(systemName: "slash.circle"), attributes: .destructive) { _ in viewModel.accountViewModel.confirmBlock() }) } } secondSectionItems.append(UIAction( title: NSLocalizedString("report", comment: ""), image: UIImage(systemName: "flag"), attributes: .destructive) { _ in viewModel.reportStatus() }) sections.append(UIMenu(options: .displayInline, children: secondSectionItems)) if !viewModel.accountViewModel.isLocal, let domain = viewModel.accountViewModel.domain, let relationship = viewModel.accountViewModel.relationship { let domainBlockAction: UIAction if relationship.domainBlocking { domainBlockAction = UIAction( title: String.localizedStringWithFormat( NSLocalizedString("account.domain-unblock-%@", comment: ""), domain), image: UIImage(systemName: "slash.circle"), attributes: .destructive) { _ in viewModel.accountViewModel.confirmDomainUnblock(domain: domain) } } else { domainBlockAction = UIAction( title: String.localizedStringWithFormat( NSLocalizedString("account.domain-block-%@", comment: ""), domain), image: UIImage(systemName: "slash.circle"), attributes: .destructive) { _ in viewModel.accountViewModel.confirmDomainBlock(domain: domain) } } sections.append(UIMenu(options: .displayInline, children: [domainBlockAction])) } } return UIMenu(children: sections) } // swiftlint:enable function_body_length func accessibilityAttributedLabel(forceShowContent: Bool) -> NSAttributedString { let accessibilityAttributedLabel = NSMutableAttributedString(string: "") if !reportSelectionSwitch.isHidden, reportSelectionSwitch.isOn { accessibilityAttributedLabel.appendWithSeparator(NSLocalizedString("selected", comment: "")) } if !infoLabel.isHidden, let infoText = infoLabel.attributedText { accessibilityAttributedLabel.appendWithSeparator(infoText) } if let displayName = displayNameLabel.attributedText { if accessibilityAttributedLabel.string.isEmpty { accessibilityAttributedLabel.append(displayName) } else { accessibilityAttributedLabel.appendWithSeparator(displayName) } } accessibilityAttributedLabel.appendWithSeparator( bodyView.accessibilityAttributedLabel(forceShowContent: forceShowContent)) if let accessibilityTime = statusConfiguration.viewModel.accessibilityTime { accessibilityAttributedLabel.appendWithSeparator(accessibilityTime) } if statusConfiguration.viewModel.repliesCount > 0 { accessibilityAttributedLabel.appendWithSeparator( String.localizedStringWithFormat( NSLocalizedString("status.replies-count-%ld", comment: ""), statusConfiguration.viewModel.repliesCount)) } if statusConfiguration.viewModel.identityContext.appPreferences.showReblogAndFavoriteCounts { if statusConfiguration.viewModel.reblogsCount > 0 { accessibilityAttributedLabel.appendWithSeparator( String.localizedStringWithFormat( NSLocalizedString("status.reblogs-count-%ld", comment: ""), statusConfiguration.viewModel.reblogsCount)) } if statusConfiguration.viewModel.favoritesCount > 0 { accessibilityAttributedLabel.appendWithSeparator( String.localizedStringWithFormat( NSLocalizedString("status.favorites-count-%ld", comment: ""), statusConfiguration.viewModel.favoritesCount)) } } if statusConfiguration.viewModel.configuration.isReplyOutOfContext { accessibilityAttributedLabel.appendWithSeparator( NSLocalizedString("status.accessibility.part-of-a-thread", comment: "")) } return accessibilityAttributedLabel } func setButtonImages(font: UIFont) { let visibility = statusConfiguration.viewModel.visibility let reblogSystemImageName: String if statusConfiguration.viewModel.configuration.isContextParent { reblogSystemImageName = "arrow.2.squarepath" } else { switch visibility { case .public, .unlisted: reblogSystemImageName = "arrow.2.squarepath" default: reblogSystemImageName = visibility.systemImageName } } replyButton.setImage(UIImage(systemName: "bubble.right", withConfiguration: UIImage.SymbolConfiguration(pointSize: font.pointSize)), for: .normal) reblogButton.setImage(UIImage(systemName: reblogSystemImageName, withConfiguration: UIImage.SymbolConfiguration( pointSize: font.pointSize, weight: statusConfiguration.viewModel.reblogged ? .bold : .regular)), for: .normal) favoriteButton.setImage(UIImage(systemName: statusConfiguration.viewModel.favorited ? "star.fill" : "star", withConfiguration: UIImage.SymbolConfiguration(pointSize: font.pointSize)), for: .normal) shareButton.setImage(UIImage(systemName: "square.and.arrow.up", withConfiguration: UIImage.SymbolConfiguration(pointSize: font.pointSize)), for: .normal) menuButton.setImage(UIImage(systemName: "ellipsis", withConfiguration: UIImage.SymbolConfiguration(pointSize: font.pointSize)), for: .normal) } @objc func reblogButtonDoubleTap(sender: UIButton, event: UIEvent) { guard statusConfiguration.viewModel.identityContext.appPreferences.requireDoubleTapToReblog, event.allTouches?.first?.tapCount == 2 else { return } reblog() } @objc func favoriteButtonDoubleTap(sender: UIButton, event: UIEvent) { guard statusConfiguration.viewModel.identityContext.appPreferences.requireDoubleTapToFavorite, event.allTouches?.first?.tapCount == 2 else { return } favorite() } func setReblogButtonColor(reblogged: Bool) { let reblogColor: UIColor = reblogged ? .systemGreen : .secondaryLabel reblogButton.tintColor = reblogColor reblogButton.setTitleColor(reblogColor, for: .normal) if reblogged { reblogButton.accessibilityLabel = NSLocalizedString("status.reblog-button.undo.accessibility-label", comment: "") } else { reblogButton.accessibilityLabel = NSLocalizedString("status.reblog-button.accessibility-label", comment: "") } } func setFavoriteButtonColor(favorited: Bool) { let favoriteColor: UIColor = favorited ? .systemYellow : .secondaryLabel favoriteButton.tintColor = favoriteColor favoriteButton.setTitleColor(favoriteColor, for: .normal) if favorited { favoriteButton.accessibilityLabel = NSLocalizedString("status.favorite-button.undo.accessibility-label", comment: "") } else { favoriteButton.accessibilityLabel = NSLocalizedString("status.favorite-button.accessibility-label", comment: "") } } func reblog() { UIImpactFeedbackGenerator(style: .medium).impactOccurred() if !UIAccessibility.isReduceMotionEnabled { UIViewPropertyAnimator.runningPropertyAnimator( withDuration: .defaultAnimationDuration, delay: 0, options: .curveLinear) { self.setReblogButtonColor(reblogged: !self.statusConfiguration.viewModel.reblogged) self.reblogButton.imageView?.transform = self.reblogButton.imageView?.transform.rotated(by: .pi) ?? .identity } completion: { _ in self.reblogButton.imageView?.transform = .identity } } statusConfiguration.viewModel.toggleReblogged() } func favorite() { UIImpactFeedbackGenerator(style: .medium).impactOccurred() if !UIAccessibility.isReduceMotionEnabled { UIViewPropertyAnimator.runningPropertyAnimator( withDuration: .defaultAnimationDuration, delay: 0, options: .curveLinear) { self.setFavoriteButtonColor(favorited: !self.statusConfiguration.viewModel.favorited) self.favoriteButton.imageView?.transform = self.favoriteButton.imageView?.transform.rotated(by: .pi) ?? .identity } completion: { _ in self.favoriteButton.imageView?.transform = .identity } } statusConfiguration.viewModel.toggleFavorited() } func configureUserInteractionEnabledForAccessibility() { isUserInteractionEnabled = !UIAccessibility.isVoiceOverRunning || statusConfiguration.viewModel.configuration.isContextParent } // swiftlint:disable:next function_body_length cyclomatic_complexity func accessibilityCustomActions(viewModel: StatusViewModel) -> [UIAccessibilityCustomAction] { guard !viewModel.configuration.isContextParent, reportSelectionSwitch.isHidden else { return [] } var actions = bodyView.accessibilityCustomActions ?? [] if replyButton.isEnabled { actions.append(UIAccessibilityCustomAction( name: replyButton.accessibilityLabel ?? "") { _ in viewModel.reply() return true }) } if viewModel.canBeReblogged, reblogButton.isEnabled { actions.append(UIAccessibilityCustomAction( name: reblogButton.accessibilityLabel ?? "") { [weak self] _ in self?.reblog() return true }) } if favoriteButton.isEnabled { actions.append(UIAccessibilityCustomAction( name: favoriteButton.accessibilityLabel ?? "") { [weak self] _ in self?.favorite() return true }) } if shareButton.isEnabled { actions.append(UIAccessibilityCustomAction( name: shareButton.accessibilityLabel ?? "") { _ in viewModel.shareStatus() return true }) } actions.append( UIAccessibilityCustomAction( name: NSLocalizedString("status.accessibility.view-author-profile", comment: "")) { [weak self] _ in self?.statusConfiguration.viewModel.accountSelected() return true }) if viewModel.isReblog { actions.append( UIAccessibilityCustomAction( name: NSLocalizedString("status.accessibility.view-reblogger-profile", comment: "")) { [weak self] _ in self?.statusConfiguration.viewModel.rebloggerAccountSelected() return true }) } actions.append( UIAccessibilityCustomAction( name: NSLocalizedString("accessibility.copy-text", comment: "")) { [weak self] _ in UIPasteboard.general.string = self?.bodyView.contentTextView.text return true }) if menuButton.isEnabled { actions.append(UIAccessibilityCustomAction( name: viewModel.bookmarked ? NSLocalizedString("status.unbookmark", comment: "") : NSLocalizedString("status.bookmark", comment: "")) { _ in viewModel.toggleBookmarked() return true }) if let pinned = viewModel.pinned { actions.append(UIAccessibilityCustomAction( name: pinned ? NSLocalizedString("status.unpin", comment: "") : NSLocalizedString("status.pin", comment: "")) { _ in viewModel.togglePinned() return true }) } if viewModel.isMine { actions += [ UIAccessibilityCustomAction( name: viewModel.muted ? NSLocalizedString("status.unmute", comment: "") : NSLocalizedString("status.mute", comment: "")) { _ in viewModel.toggleMuted() return true }, UIAccessibilityCustomAction( name: NSLocalizedString("status.delete", comment: "")) { _ in viewModel.confirmDelete(redraft: false) return true }, UIAccessibilityCustomAction( name: NSLocalizedString("status.delete-and-redraft", comment: "")) { _ in viewModel.confirmDelete(redraft: true) return true } ] } else { if let relationship = viewModel.accountViewModel.relationship { if relationship.muting { actions.append(UIAccessibilityCustomAction( name: NSLocalizedString("account.unmute", comment: "")) { _ in viewModel.accountViewModel.confirmUnmute() return true }) } else { actions.append(UIAccessibilityCustomAction( name: NSLocalizedString("account.mute", comment: "")) { _ in viewModel.accountViewModel.confirmMute() return true }) } if relationship.blocking { actions.append(UIAccessibilityCustomAction( name: NSLocalizedString("account.unblock", comment: "")) { _ in viewModel.accountViewModel.confirmUnblock() return true }) } else { actions.append(UIAccessibilityCustomAction( name: NSLocalizedString("account.block", comment: "")) { _ in viewModel.accountViewModel.confirmBlock() return true }) } } actions.append(UIAccessibilityCustomAction( name: NSLocalizedString("report", comment: "")) { _ in viewModel.reportStatus() return true }) if !viewModel.accountViewModel.isLocal, let domain = viewModel.accountViewModel.domain, let relationship = viewModel.accountViewModel.relationship { if relationship.domainBlocking { actions.append(UIAccessibilityCustomAction( name: String.localizedStringWithFormat( NSLocalizedString("account.domain-unblock-%@", comment: ""), domain)) { _ in viewModel.accountViewModel.confirmDomainUnblock(domain: domain) return true }) } else { actions.append(UIAccessibilityCustomAction( name: String.localizedStringWithFormat( NSLocalizedString("account.domain-block-%@", comment: ""), domain)) { _ in viewModel.accountViewModel.confirmDomainBlock(domain: domain) return true }) } } } } return actions } func authenticatedIdentitiesMenu(action: @escaping (Identity) -> Void) -> UIMenu { let imageTransformer = SDImageRoundCornerTransformer( radius: .greatestFiniteMagnitude, corners: .allCorners, borderWidth: 0, borderColor: nil) return UIMenu(children: statusConfiguration.viewModel .identityContext .authenticatedOtherIdentities.map { identity in UIDeferredMenuElement { completion in let menuItemAction = UIAction(title: identity.handle) { _ in action(identity) } if let image = identity.image { SDWebImageManager.shared.loadImage( with: image, options: [.transformAnimatedImage], context: [.imageTransformer: imageTransformer], progress: nil) { (image, _, _, _, _, _) in menuItemAction.image = image completion([menuItemAction]) } } else { completion([menuItemAction]) } } }) } } private extension UIButton { func setCountTitle(count: Int, isContextParent: Bool) { setTitle((isContextParent || count == 0) ? nil : String(count), for: .normal) } } // swiftlint:enable file_length