mirror of
https://github.com/mastodon/mastodon-ios.git
synced 2024-12-10 15:47:02 +01:00
848 lines
40 KiB
Swift
848 lines
40 KiB
Swift
//
|
|
// StatusView.swift
|
|
//
|
|
//
|
|
// Created by MainasuK on 2022-1-10.
|
|
//
|
|
|
|
import os.log
|
|
import UIKit
|
|
import Combine
|
|
import MetaTextKit
|
|
import Meta
|
|
import MastodonAsset
|
|
import MastodonLocalization
|
|
|
|
public protocol StatusViewDelegate: AnyObject {
|
|
func statusView(_ statusView: StatusView, headerDidPressed header: UIView)
|
|
func statusView(_ statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton)
|
|
func statusView(_ statusView: StatusView, contentSensitiveeToggleButtonDidPressed button: UIButton)
|
|
func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta)
|
|
func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int)
|
|
func statusView(_ statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath)
|
|
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
|
|
func statusView(_ statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action)
|
|
func statusView(_ statusView: StatusView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action)
|
|
func statusView(_ statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView)
|
|
func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton)
|
|
func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton)
|
|
func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton)
|
|
|
|
// a11y
|
|
func statusView(_ statusView: StatusView, accessibilityActivate: Void)
|
|
}
|
|
|
|
public final class StatusView: UIView {
|
|
|
|
public static let containerLayoutMargin: CGFloat = 16
|
|
|
|
let logger = Logger(subsystem: "StatusView", category: "View")
|
|
|
|
private var _disposeBag = Set<AnyCancellable>() // which lifetime same to view scope
|
|
public var disposeBag = Set<AnyCancellable>()
|
|
|
|
public weak var delegate: StatusViewDelegate?
|
|
|
|
public private(set) var style: Style?
|
|
|
|
public private(set) lazy var viewModel: ViewModel = {
|
|
let viewModel = ViewModel()
|
|
viewModel.bind(statusView: self)
|
|
return viewModel
|
|
}()
|
|
|
|
let containerStackView: UIStackView = {
|
|
let stackView = UIStackView()
|
|
stackView.axis = .vertical
|
|
stackView.spacing = 10
|
|
return stackView
|
|
}()
|
|
|
|
// header
|
|
let headerAdaptiveMarginContainerView = AdaptiveMarginContainerView()
|
|
public let headerContainerView = UIView()
|
|
|
|
// header icon
|
|
let headerIconImageView: UIImageView = {
|
|
let imageView = UIImageView()
|
|
imageView.tintColor = Asset.Colors.Label.secondary.color
|
|
imageView.contentMode = .scaleAspectFill
|
|
return imageView
|
|
}()
|
|
|
|
// header info
|
|
let headerInfoLabel = MetaLabel(style: .statusHeader)
|
|
|
|
// author
|
|
let authorAdaptiveMarginContainerView = AdaptiveMarginContainerView()
|
|
let authorContainerView: UIStackView = {
|
|
let stackView = UIStackView()
|
|
stackView.axis = .horizontal
|
|
stackView.spacing = 12
|
|
return stackView
|
|
}()
|
|
|
|
// avatar
|
|
public let avatarButton = AvatarButton()
|
|
|
|
// author name
|
|
public let authorNameLabel = MetaLabel(style: .statusName)
|
|
|
|
// author username
|
|
public let authorUsernameLabel = MetaLabel(style: .statusUsername)
|
|
|
|
public let usernameTrialingDotLabel: MetaLabel = {
|
|
let label = MetaLabel(style: .statusUsername)
|
|
label.configure(content: PlaintextMetaContent(string: "·"))
|
|
return label
|
|
}()
|
|
|
|
// timestamp
|
|
public let dateLabel = MetaLabel(style: .statusUsername)
|
|
|
|
public let menuButton: UIButton = {
|
|
let button = HitTestExpandedButton(type: .system)
|
|
button.expandEdgeInsets = UIEdgeInsets(top: -20, left: -10, bottom: -5, right: -10)
|
|
button.tintColor = Asset.Colors.Label.secondary.color
|
|
let image = UIImage(systemName: "ellipsis", withConfiguration: UIImage.SymbolConfiguration(font: .systemFont(ofSize: 15)))
|
|
button.setImage(image, for: .normal)
|
|
button.accessibilityLabel = L10n.Common.Controls.Status.Actions.menu
|
|
return button
|
|
}()
|
|
|
|
public let contentSensitiveeToggleButton: UIButton = {
|
|
let button = HitTestExpandedButton(type: .system)
|
|
button.expandEdgeInsets = UIEdgeInsets(top: -5, left: -10, bottom: -20, right: -10)
|
|
button.tintColor = Asset.Colors.Label.secondary.color
|
|
button.imageView?.contentMode = .scaleAspectFill
|
|
button.imageView?.clipsToBounds = false
|
|
let image = UIImage(systemName: "eye.slash.fill", withConfiguration: UIImage.SymbolConfiguration(font: .systemFont(ofSize: 15)))
|
|
button.setImage(image, for: .normal)
|
|
return button
|
|
}()
|
|
|
|
// content
|
|
let contentAdaptiveMarginContainerView = AdaptiveMarginContainerView()
|
|
let contentContainer = UIStackView()
|
|
public let contentMetaText: MetaText = {
|
|
let metaText = MetaText()
|
|
metaText.textView.backgroundColor = .clear
|
|
metaText.textView.isEditable = false
|
|
metaText.textView.isSelectable = false
|
|
metaText.textView.isScrollEnabled = false
|
|
metaText.textView.textContainer.lineFragmentPadding = 0
|
|
metaText.textView.textContainerInset = .zero
|
|
metaText.textView.layer.masksToBounds = false
|
|
metaText.textView.textDragInteraction?.isEnabled = false // disable drag for link and attachment
|
|
|
|
metaText.paragraphStyle = {
|
|
let style = NSMutableParagraphStyle()
|
|
style.lineSpacing = 5
|
|
style.paragraphSpacing = 8
|
|
style.alignment = .natural
|
|
return style
|
|
}()
|
|
metaText.textAttributes = [
|
|
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)),
|
|
.foregroundColor: Asset.Colors.Label.primary.color,
|
|
]
|
|
metaText.linkAttributes = [
|
|
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)),
|
|
.foregroundColor: Asset.Colors.brandBlue.color,
|
|
]
|
|
return metaText
|
|
}()
|
|
|
|
// content warning
|
|
public let spoilerOverlayView = SpoilerOverlayView()
|
|
|
|
// media
|
|
public let mediaContainerView = UIView()
|
|
public let mediaGridContainerView = MediaGridContainerView()
|
|
|
|
// poll
|
|
let pollAdaptiveMarginContainerView = AdaptiveMarginContainerView()
|
|
let pollContainerView = UIStackView()
|
|
public let pollTableView: UITableView = {
|
|
let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
|
|
tableView.register(PollOptionTableViewCell.self, forCellReuseIdentifier: String(describing: PollOptionTableViewCell.self))
|
|
tableView.isScrollEnabled = false
|
|
tableView.estimatedRowHeight = 36
|
|
tableView.tableFooterView = UIView()
|
|
tableView.backgroundColor = .clear
|
|
tableView.separatorStyle = .none
|
|
return tableView
|
|
}()
|
|
public var pollTableViewHeightLayoutConstraint: NSLayoutConstraint!
|
|
public var pollTableViewDiffableDataSource: UITableViewDiffableDataSource<PollSection, PollItem>?
|
|
|
|
public let pollStatusStackView = UIStackView()
|
|
let pollVoteCountLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular))
|
|
label.textColor = Asset.Colors.Label.secondary.color
|
|
label.text = L10n.Plural.Count.vote(0)
|
|
return label
|
|
}()
|
|
let pollStatusDotLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular))
|
|
label.textColor = Asset.Colors.Label.secondary.color
|
|
label.text = " · "
|
|
label.isAccessibilityElement = false
|
|
return label
|
|
}()
|
|
let pollCountdownLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular))
|
|
label.textColor = Asset.Colors.Label.secondary.color
|
|
label.text = "1 day left"
|
|
return label
|
|
}()
|
|
let pollVoteButton: UIButton = {
|
|
let button = HitTestExpandedButton()
|
|
button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold))
|
|
button.setTitle(L10n.Common.Controls.Status.Poll.vote, for: .normal)
|
|
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
|
|
button.setTitleColor(Asset.Colors.brandBlue.color.withAlphaComponent(0.8), for: .highlighted)
|
|
button.setTitleColor(Asset.Colors.Button.disabled.color, for: .disabled)
|
|
button.isEnabled = false
|
|
return button
|
|
}()
|
|
let pollVoteActivityIndicatorView: UIActivityIndicatorView = {
|
|
let indicatorView = UIActivityIndicatorView(style: .medium)
|
|
indicatorView.hidesWhenStopped = true
|
|
indicatorView.stopAnimating()
|
|
return indicatorView
|
|
}()
|
|
|
|
// toolbar
|
|
let actionToolbarAdaptiveMarginContainerView = AdaptiveMarginContainerView()
|
|
public let actionToolbarContainer = ActionToolbarContainer()
|
|
|
|
// metric
|
|
let statusMetricViewAdaptiveMarginContainerView = AdaptiveMarginContainerView()
|
|
public let statusMetricView = StatusMetricView()
|
|
|
|
// filter hint
|
|
public let filterHintLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.textColor = Asset.Colors.Label.secondary.color
|
|
label.text = L10n.Common.Controls.Timeline.filtered
|
|
label.font = .systemFont(ofSize: 17, weight: .regular)
|
|
return label
|
|
}()
|
|
|
|
public func prepareForReuse() {
|
|
disposeBag.removeAll()
|
|
|
|
viewModel.objects.removeAll()
|
|
viewModel.prepareForReuse()
|
|
|
|
avatarButton.avatarImageView.cancelTask()
|
|
if var snapshot = pollTableViewDiffableDataSource?.snapshot() {
|
|
snapshot.deleteAllItems()
|
|
if #available(iOS 15.0, *) {
|
|
pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(snapshot)
|
|
} else {
|
|
// Fallback on earlier versions
|
|
pollTableViewDiffableDataSource?.apply(snapshot, animatingDifferences: false)
|
|
}
|
|
}
|
|
|
|
setHeaderDisplay(isDisplay: false)
|
|
setContentSensitiveeToggleButtonDisplay(isDisplay: false)
|
|
setSpoilerOverlayViewHidden(isHidden: true)
|
|
setMediaDisplay(isDisplay: false)
|
|
setPollDisplay(isDisplay: false)
|
|
setFilterHintLabelDisplay(isDisplay: false)
|
|
}
|
|
|
|
public override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
_init()
|
|
}
|
|
|
|
public required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
_init()
|
|
}
|
|
|
|
}
|
|
|
|
extension StatusView {
|
|
private func _init() {
|
|
// container
|
|
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(containerStackView)
|
|
NSLayoutConstraint.activate([
|
|
containerStackView.topAnchor.constraint(equalTo: topAnchor),
|
|
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
])
|
|
|
|
// header
|
|
headerIconImageView.isUserInteractionEnabled = false
|
|
headerInfoLabel.isUserInteractionEnabled = false
|
|
let headerTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
|
headerTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerDidPressed(_:)))
|
|
headerContainerView.addGestureRecognizer(headerTapGestureRecognizer)
|
|
|
|
// avatar button
|
|
avatarButton.addTarget(self, action: #selector(StatusView.authorAvatarButtonDidPressed(_:)), for: .touchUpInside)
|
|
authorNameLabel.isUserInteractionEnabled = false
|
|
authorUsernameLabel.isUserInteractionEnabled = false
|
|
|
|
// contentSensitiveeToggleButton
|
|
contentSensitiveeToggleButton.addTarget(self, action: #selector(StatusView.contentSensitiveeToggleButtonDidPressed(_:)), for: .touchUpInside)
|
|
|
|
// dateLabel
|
|
dateLabel.isUserInteractionEnabled = false
|
|
|
|
// content warning
|
|
let spoilerOverlayViewTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
|
spoilerOverlayView.addGestureRecognizer(spoilerOverlayViewTapGestureRecognizer)
|
|
spoilerOverlayViewTapGestureRecognizer.addTarget(self, action: #selector(StatusView.spoilerOverlayViewTapGestureRecognizerHandler(_:)))
|
|
|
|
// content
|
|
contentMetaText.textView.delegate = self
|
|
contentMetaText.textView.linkDelegate = self
|
|
|
|
// media
|
|
mediaGridContainerView.delegate = self
|
|
|
|
// poll
|
|
pollTableView.translatesAutoresizingMaskIntoConstraints = false
|
|
pollTableViewHeightLayoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1)
|
|
NSLayoutConstraint.activate([
|
|
pollTableViewHeightLayoutConstraint,
|
|
])
|
|
pollTableView.delegate = self
|
|
pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonDidPressed(_:)), for: .touchUpInside)
|
|
|
|
// toolbar
|
|
actionToolbarContainer.delegate = self
|
|
|
|
// statusMetricView
|
|
statusMetricView.delegate = self
|
|
}
|
|
}
|
|
|
|
extension StatusView {
|
|
|
|
@objc private func headerDidPressed(_ sender: UITapGestureRecognizer) {
|
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
|
assert(sender.view === headerContainerView)
|
|
delegate?.statusView(self, headerDidPressed: headerContainerView)
|
|
}
|
|
|
|
@objc private func authorAvatarButtonDidPressed(_ sender: UIButton) {
|
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
|
delegate?.statusView(self, authorAvatarButtonDidPressed: avatarButton)
|
|
}
|
|
|
|
@objc private func contentSensitiveeToggleButtonDidPressed(_ sender: UIButton) {
|
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
|
delegate?.statusView(self, contentSensitiveeToggleButtonDidPressed: sender)
|
|
}
|
|
|
|
@objc private func pollVoteButtonDidPressed(_ sender: UIButton) {
|
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
|
delegate?.statusView(self, pollVoteButtonPressed: pollVoteButton)
|
|
}
|
|
|
|
@objc private func spoilerOverlayViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
|
delegate?.statusView(self, spoilerOverlayViewDidPressed: spoilerOverlayView)
|
|
}
|
|
|
|
}
|
|
|
|
extension StatusView {
|
|
|
|
public func setup(style: Style) {
|
|
guard self.style == nil else {
|
|
assertionFailure("Should only setup once")
|
|
return
|
|
}
|
|
self.style = style
|
|
style.layout(statusView: self)
|
|
prepareForReuse()
|
|
}
|
|
|
|
public enum Style {
|
|
case inline
|
|
case plain
|
|
case report
|
|
case notification
|
|
case notificationQuote
|
|
case composeStatusReplica
|
|
case composeStatusAuthor
|
|
}
|
|
}
|
|
|
|
extension StatusView.Style {
|
|
|
|
func layout(statusView: StatusView) {
|
|
switch self {
|
|
case .inline: inline(statusView: statusView)
|
|
case .plain: plain(statusView: statusView)
|
|
case .report: report(statusView: statusView)
|
|
case .notification: notification(statusView: statusView)
|
|
case .notificationQuote: notificationQuote(statusView: statusView)
|
|
case .composeStatusReplica: composeStatusReplica(statusView: statusView)
|
|
case .composeStatusAuthor: composeStatusAuthor(statusView: statusView)
|
|
}
|
|
}
|
|
|
|
private func base(statusView: StatusView) {
|
|
// container: V - [ header container | author container | content container | media container | pollTableView | actionToolbarContainer ]
|
|
|
|
// header container: H - [ icon | label ]
|
|
statusView.headerAdaptiveMarginContainerView.contentView = statusView.headerContainerView
|
|
statusView.headerAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin
|
|
statusView.containerStackView.addArrangedSubview(statusView.headerAdaptiveMarginContainerView)
|
|
|
|
statusView.headerIconImageView.translatesAutoresizingMaskIntoConstraints = false
|
|
statusView.headerInfoLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
statusView.headerContainerView.addSubview(statusView.headerIconImageView)
|
|
statusView.headerContainerView.addSubview(statusView.headerInfoLabel)
|
|
NSLayoutConstraint.activate([
|
|
statusView.headerIconImageView.leadingAnchor.constraint(equalTo: statusView.headerContainerView.leadingAnchor),
|
|
statusView.headerIconImageView.heightAnchor.constraint(equalTo: statusView.headerInfoLabel.heightAnchor, multiplier: 1.0).priority(.required - 1),
|
|
statusView.headerIconImageView.widthAnchor.constraint(equalTo: statusView.headerInfoLabel.heightAnchor, multiplier: 1.0).priority(.required - 1),
|
|
statusView.headerInfoLabel.topAnchor.constraint(equalTo: statusView.headerContainerView.topAnchor),
|
|
statusView.headerInfoLabel.leadingAnchor.constraint(equalTo: statusView.headerIconImageView.trailingAnchor, constant: 6),
|
|
statusView.headerInfoLabel.trailingAnchor.constraint(equalTo: statusView.headerContainerView.trailingAnchor),
|
|
statusView.headerInfoLabel.bottomAnchor.constraint(equalTo: statusView.headerContainerView.bottomAnchor),
|
|
statusView.headerInfoLabel.centerYAnchor.constraint(equalTo: statusView.headerIconImageView.centerYAnchor),
|
|
])
|
|
statusView.headerInfoLabel.setContentHuggingPriority(.required - 1, for: .vertical)
|
|
statusView.headerInfoLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
|
|
statusView.headerIconImageView.setContentHuggingPriority(.defaultLow - 100, for: .vertical)
|
|
statusView.headerIconImageView.setContentHuggingPriority(.defaultLow - 100, for: .horizontal)
|
|
statusView.headerIconImageView.setContentCompressionResistancePriority(.defaultLow - 100, for: .vertical)
|
|
statusView.headerIconImageView.setContentCompressionResistancePriority(.defaultLow - 100, for: .horizontal)
|
|
|
|
// author container: H - [ avatarButton | author meta container | contentWarningToggleButton ]
|
|
statusView.authorAdaptiveMarginContainerView.contentView = statusView.authorContainerView
|
|
statusView.authorAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin
|
|
statusView.containerStackView.addArrangedSubview(statusView.authorAdaptiveMarginContainerView)
|
|
|
|
UIContentSizeCategory.publisher
|
|
.sink { category in
|
|
statusView.authorContainerView.axis = category > .accessibilityLarge ? .vertical : .horizontal
|
|
statusView.authorContainerView.alignment = category > .accessibilityLarge ? .leading : .center
|
|
}
|
|
.store(in: &statusView._disposeBag)
|
|
|
|
// avatarButton
|
|
let authorAvatarButtonSize = CGSize(width: 46, height: 46)
|
|
statusView.avatarButton.size = authorAvatarButtonSize
|
|
statusView.avatarButton.avatarImageView.imageViewSize = authorAvatarButtonSize
|
|
statusView.avatarButton.translatesAutoresizingMaskIntoConstraints = false
|
|
statusView.authorContainerView.addArrangedSubview(statusView.avatarButton)
|
|
NSLayoutConstraint.activate([
|
|
statusView.avatarButton.widthAnchor.constraint(equalToConstant: authorAvatarButtonSize.width).priority(.required - 1),
|
|
statusView.avatarButton.heightAnchor.constraint(equalToConstant: authorAvatarButtonSize.height).priority(.required - 1),
|
|
])
|
|
statusView.avatarButton.setContentHuggingPriority(.required - 1, for: .vertical)
|
|
statusView.avatarButton.setContentCompressionResistancePriority(.required - 1, for: .vertical)
|
|
|
|
// authrMetaContainer: V - [ authorPrimaryMetaContainer | authorSecondaryMetaContainer ]
|
|
let authorMetaContainer = UIStackView()
|
|
authorMetaContainer.axis = .vertical
|
|
authorMetaContainer.spacing = 4
|
|
statusView.authorContainerView.addArrangedSubview(authorMetaContainer)
|
|
|
|
// authorPrimaryMetaContainer: H - [ authorNameLabel | (padding) | menuButton ]
|
|
let authorPrimaryMetaContainer = UIStackView()
|
|
authorPrimaryMetaContainer.axis = .horizontal
|
|
authorPrimaryMetaContainer.spacing = 10
|
|
authorMetaContainer.addArrangedSubview(authorPrimaryMetaContainer)
|
|
|
|
// authorNameLabel
|
|
authorPrimaryMetaContainer.addArrangedSubview(statusView.authorNameLabel)
|
|
statusView.authorNameLabel.setContentHuggingPriority(.required - 10, for: .horizontal)
|
|
statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal)
|
|
authorPrimaryMetaContainer.addArrangedSubview(UIView())
|
|
// menuButton
|
|
authorPrimaryMetaContainer.addArrangedSubview(statusView.menuButton)
|
|
statusView.menuButton.setContentHuggingPriority(.required - 2, for: .horizontal)
|
|
statusView.menuButton.setContentCompressionResistancePriority(.required - 2, for: .horizontal)
|
|
|
|
// authorSecondaryMetaContainer: H - [ authorUsername | usernameTrialingDotLabel | dateLabel | (padding) | contentSensitiveeToggleButton ]
|
|
let authorSecondaryMetaContainer = UIStackView()
|
|
authorSecondaryMetaContainer.axis = .horizontal
|
|
authorSecondaryMetaContainer.spacing = 4
|
|
authorMetaContainer.addArrangedSubview(authorSecondaryMetaContainer)
|
|
|
|
authorSecondaryMetaContainer.addArrangedSubview(statusView.authorUsernameLabel)
|
|
statusView.authorUsernameLabel.setContentHuggingPriority(.required - 8, for: .horizontal)
|
|
statusView.authorUsernameLabel.setContentCompressionResistancePriority(.required - 8, for: .horizontal)
|
|
authorSecondaryMetaContainer.addArrangedSubview(statusView.usernameTrialingDotLabel)
|
|
statusView.usernameTrialingDotLabel.setContentHuggingPriority(.required - 2, for: .horizontal)
|
|
statusView.usernameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal)
|
|
authorSecondaryMetaContainer.addArrangedSubview(statusView.dateLabel)
|
|
statusView.dateLabel.setContentHuggingPriority(.required - 1, for: .horizontal)
|
|
statusView.dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
|
|
authorSecondaryMetaContainer.addArrangedSubview(UIView())
|
|
statusView.contentSensitiveeToggleButton.translatesAutoresizingMaskIntoConstraints = false
|
|
authorSecondaryMetaContainer.addArrangedSubview(statusView.contentSensitiveeToggleButton)
|
|
NSLayoutConstraint.activate([
|
|
statusView.contentSensitiveeToggleButton.heightAnchor.constraint(equalTo: statusView.authorUsernameLabel.heightAnchor, multiplier: 1.0).priority(.required - 1),
|
|
statusView.contentSensitiveeToggleButton.widthAnchor.constraint(equalTo: statusView.contentSensitiveeToggleButton.heightAnchor, multiplier: 1.0).priority(.required - 1),
|
|
])
|
|
statusView.authorUsernameLabel.setContentHuggingPriority(.required - 1, for: .vertical)
|
|
statusView.authorUsernameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
|
|
statusView.contentSensitiveeToggleButton.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
statusView.contentSensitiveeToggleButton.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
statusView.contentSensitiveeToggleButton.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
statusView.contentSensitiveeToggleButton.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
|
// content container: V - [ contentMetaText ]
|
|
statusView.contentContainer.axis = .vertical
|
|
statusView.contentContainer.spacing = 12
|
|
statusView.contentContainer.distribution = .fill
|
|
statusView.contentContainer.alignment = .top
|
|
|
|
statusView.contentAdaptiveMarginContainerView.contentView = statusView.contentContainer
|
|
statusView.contentAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin
|
|
statusView.containerStackView.addArrangedSubview(statusView.contentAdaptiveMarginContainerView)
|
|
statusView.contentContainer.setContentHuggingPriority(.required - 1, for: .vertical)
|
|
statusView.contentContainer.setContentCompressionResistancePriority(.required - 1, for: .vertical)
|
|
|
|
// status content
|
|
statusView.contentContainer.addArrangedSubview(statusView.contentMetaText.textView)
|
|
statusView.containerStackView.setCustomSpacing(16, after: statusView.contentMetaText.textView)
|
|
|
|
statusView.spoilerOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
|
statusView.containerStackView.addSubview(statusView.spoilerOverlayView)
|
|
NSLayoutConstraint.activate([
|
|
statusView.contentContainer.topAnchor.constraint(equalTo: statusView.spoilerOverlayView.topAnchor),
|
|
statusView.contentContainer.leadingAnchor.constraint(equalTo: statusView.spoilerOverlayView.leadingAnchor),
|
|
statusView.contentContainer.trailingAnchor.constraint(equalTo: statusView.spoilerOverlayView.trailingAnchor),
|
|
statusView.contentContainer.bottomAnchor.constraint(equalTo: statusView.spoilerOverlayView.bottomAnchor),
|
|
])
|
|
|
|
// media container: V - [ mediaGridContainerView ]
|
|
statusView.mediaContainerView.translatesAutoresizingMaskIntoConstraints = false
|
|
statusView.containerStackView.addArrangedSubview(statusView.mediaContainerView)
|
|
NSLayoutConstraint.activate([
|
|
statusView.mediaContainerView.leadingAnchor.constraint(equalTo: statusView.containerStackView.leadingAnchor),
|
|
statusView.mediaContainerView.trailingAnchor.constraint(equalTo: statusView.containerStackView.trailingAnchor),
|
|
])
|
|
|
|
statusView.mediaGridContainerView.translatesAutoresizingMaskIntoConstraints = false
|
|
statusView.mediaContainerView.addSubview(statusView.mediaGridContainerView)
|
|
NSLayoutConstraint.activate([
|
|
statusView.mediaGridContainerView.topAnchor.constraint(equalTo: statusView.mediaContainerView.topAnchor),
|
|
statusView.mediaGridContainerView.leadingAnchor.constraint(equalTo: statusView.mediaContainerView.leadingAnchor),
|
|
statusView.mediaGridContainerView.trailingAnchor.constraint(equalTo: statusView.mediaContainerView.trailingAnchor),
|
|
statusView.mediaGridContainerView.bottomAnchor.constraint(equalTo: statusView.mediaContainerView.bottomAnchor),
|
|
])
|
|
|
|
// pollContainerView: V - [ pollTableView | pollStatusStackView ]
|
|
statusView.pollAdaptiveMarginContainerView.contentView = statusView.pollContainerView
|
|
statusView.pollAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin
|
|
statusView.pollContainerView.axis = .vertical
|
|
statusView.containerStackView.addArrangedSubview(statusView.pollAdaptiveMarginContainerView)
|
|
|
|
// pollTableView
|
|
statusView.pollContainerView.addArrangedSubview(statusView.pollTableView)
|
|
|
|
// pollStatusStackView: H - [ pollVoteCountLabel | pollCountdownLabel | pollVoteButton ]
|
|
statusView.pollStatusStackView.axis = .horizontal
|
|
statusView.pollContainerView.addArrangedSubview(statusView.pollStatusStackView)
|
|
|
|
statusView.pollStatusStackView.addArrangedSubview(statusView.pollVoteCountLabel)
|
|
statusView.pollStatusStackView.addArrangedSubview(statusView.pollStatusDotLabel)
|
|
statusView.pollStatusStackView.addArrangedSubview(statusView.pollCountdownLabel)
|
|
statusView.pollStatusStackView.addArrangedSubview(statusView.pollVoteButton)
|
|
statusView.pollStatusStackView.addArrangedSubview(statusView.pollVoteActivityIndicatorView)
|
|
statusView.pollVoteCountLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal)
|
|
statusView.pollStatusDotLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal)
|
|
statusView.pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
statusView.pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal)
|
|
|
|
// action toolbar
|
|
statusView.actionToolbarAdaptiveMarginContainerView.contentView = statusView.actionToolbarContainer
|
|
statusView.actionToolbarAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin
|
|
statusView.actionToolbarContainer.configure(for: .inline)
|
|
statusView.containerStackView.addArrangedSubview(statusView.actionToolbarAdaptiveMarginContainerView)
|
|
|
|
// filterHintLabel
|
|
statusView.filterHintLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
statusView.addSubview(statusView.filterHintLabel)
|
|
NSLayoutConstraint.activate([
|
|
statusView.filterHintLabel.centerXAnchor.constraint(equalTo: statusView.containerStackView.centerXAnchor),
|
|
statusView.filterHintLabel.centerYAnchor.constraint(equalTo: statusView.containerStackView.centerYAnchor),
|
|
])
|
|
}
|
|
|
|
func inline(statusView: StatusView) {
|
|
base(statusView: statusView)
|
|
}
|
|
|
|
func plain(statusView: StatusView) {
|
|
// container: V - [ … | statusMetricView ]
|
|
base(statusView: statusView) // override the base style
|
|
|
|
// statusMetricView
|
|
statusView.statusMetricViewAdaptiveMarginContainerView.contentView = statusView.statusMetricView
|
|
statusView.statusMetricViewAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin
|
|
statusView.containerStackView.addArrangedSubview(statusView.statusMetricViewAdaptiveMarginContainerView)
|
|
|
|
UIContentSizeCategory.publisher
|
|
.sink { category in
|
|
statusView.statusMetricView.containerStackView.axis = category > .accessibilityLarge ? .vertical : .horizontal
|
|
statusView.statusMetricView.containerStackView.alignment = category > .accessibilityLarge ? .leading : .fill
|
|
}
|
|
.store(in: &statusView._disposeBag)
|
|
}
|
|
|
|
func report(statusView: StatusView) {
|
|
base(statusView: statusView) // override the base style
|
|
|
|
statusView.menuButton.removeFromSuperview()
|
|
statusView.actionToolbarAdaptiveMarginContainerView.removeFromSuperview()
|
|
}
|
|
|
|
func notification(statusView: StatusView) {
|
|
base(statusView: statusView) // override the base style
|
|
|
|
statusView.headerAdaptiveMarginContainerView.removeFromSuperview()
|
|
statusView.authorAdaptiveMarginContainerView.removeFromSuperview()
|
|
}
|
|
|
|
func notificationQuote(statusView: StatusView) {
|
|
base(statusView: statusView) // override the base style
|
|
|
|
statusView.contentAdaptiveMarginContainerView.bottomLayoutConstraint?.constant = 16 // fix bottom margin missing issue
|
|
statusView.pollAdaptiveMarginContainerView.bottomLayoutConstraint?.constant = 16 // fix bottom margin missing issue
|
|
statusView.contentSensitiveeToggleButton.removeFromSuperview()
|
|
statusView.menuButton.removeFromSuperview()
|
|
statusView.actionToolbarAdaptiveMarginContainerView.removeFromSuperview()
|
|
}
|
|
|
|
func composeStatusReplica(statusView: StatusView) {
|
|
base(statusView: statusView)
|
|
|
|
statusView.avatarButton.isUserInteractionEnabled = false
|
|
statusView.menuButton.removeFromSuperview()
|
|
statusView.actionToolbarAdaptiveMarginContainerView.removeFromSuperview()
|
|
}
|
|
|
|
func composeStatusAuthor(statusView: StatusView) {
|
|
base(statusView: statusView)
|
|
|
|
statusView.avatarButton.isUserInteractionEnabled = false
|
|
statusView.menuButton.removeFromSuperview()
|
|
statusView.usernameTrialingDotLabel.removeFromSuperview()
|
|
statusView.dateLabel.removeFromSuperview()
|
|
statusView.contentAdaptiveMarginContainerView.removeFromSuperview()
|
|
statusView.spoilerOverlayView.removeFromSuperview()
|
|
statusView.mediaContainerView.removeFromSuperview()
|
|
statusView.pollAdaptiveMarginContainerView.removeFromSuperview()
|
|
statusView.actionToolbarAdaptiveMarginContainerView.removeFromSuperview()
|
|
}
|
|
|
|
}
|
|
|
|
extension StatusView {
|
|
func setHeaderDisplay(isDisplay: Bool = true) {
|
|
headerAdaptiveMarginContainerView.isHidden = !isDisplay
|
|
}
|
|
|
|
func setContentSensitiveeToggleButtonDisplay(isDisplay: Bool = true) {
|
|
contentSensitiveeToggleButton.isHidden = !isDisplay
|
|
}
|
|
|
|
func setSpoilerOverlayViewHidden(isHidden: Bool) {
|
|
spoilerOverlayView.isHidden = isHidden
|
|
spoilerOverlayView.setComponentHidden(isHidden)
|
|
}
|
|
|
|
func setMediaDisplay(isDisplay: Bool = true) {
|
|
mediaContainerView.isHidden = !isDisplay
|
|
}
|
|
|
|
func setPollDisplay(isDisplay: Bool = true) {
|
|
pollAdaptiveMarginContainerView.isHidden = !isDisplay
|
|
}
|
|
|
|
func setFilterHintLabelDisplay(isDisplay: Bool = true) {
|
|
filterHintLabel.isHidden = !isDisplay
|
|
}
|
|
|
|
// container width
|
|
public var contentMaxLayoutWidth: CGFloat {
|
|
return frame.width
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - AdaptiveContainerView
|
|
extension StatusView: AdaptiveContainerView {
|
|
public func updateContainerViewComponentsLayoutMarginsRelativeArrangementBehavior(isEnabled: Bool) {
|
|
let margin = isEnabled ? StatusView.containerLayoutMargin : .zero
|
|
headerAdaptiveMarginContainerView.margin = margin
|
|
authorAdaptiveMarginContainerView.margin = margin
|
|
contentAdaptiveMarginContainerView.margin = margin
|
|
pollAdaptiveMarginContainerView.margin = margin
|
|
actionToolbarAdaptiveMarginContainerView.margin = margin
|
|
statusMetricViewAdaptiveMarginContainerView.margin = margin
|
|
}
|
|
}
|
|
|
|
extension StatusView {
|
|
|
|
public struct AuthorMenuContext {
|
|
public let name: String
|
|
|
|
public let isMuting: Bool
|
|
public let isBlocking: Bool
|
|
public let isMyself: Bool
|
|
}
|
|
|
|
public func setupAuthorMenu(menuContext: AuthorMenuContext) -> UIMenu {
|
|
var actions: [MastodonMenu.Action] = []
|
|
|
|
actions = [
|
|
.muteUser(.init(
|
|
name: menuContext.name,
|
|
isMuting: menuContext.isMuting
|
|
)),
|
|
.blockUser(.init(
|
|
name: menuContext.name,
|
|
isBlocking: menuContext.isBlocking
|
|
)),
|
|
.reportUser(
|
|
.init(name: menuContext.name)
|
|
),
|
|
]
|
|
|
|
if menuContext.isMyself {
|
|
actions.append(.deleteStatus)
|
|
}
|
|
|
|
|
|
let menu = MastodonMenu.setupMenu(
|
|
actions: actions,
|
|
delegate: self
|
|
)
|
|
|
|
return menu
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - UITextViewDelegate
|
|
extension StatusView: UITextViewDelegate {
|
|
|
|
public func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
|
|
switch textView {
|
|
case contentMetaText.textView:
|
|
return false
|
|
default:
|
|
assertionFailure()
|
|
return true
|
|
}
|
|
}
|
|
|
|
public func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
|
|
switch textView {
|
|
case contentMetaText.textView:
|
|
return false
|
|
default:
|
|
assertionFailure()
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - MetaTextViewDelegate
|
|
extension StatusView: MetaTextViewDelegate {
|
|
public func metaTextView(_ metaTextView: MetaTextView, didSelectMeta meta: Meta) {
|
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): meta: \(String(describing: meta))")
|
|
switch metaTextView {
|
|
case contentMetaText.textView:
|
|
delegate?.statusView(self, metaText: contentMetaText, didSelectMeta: meta)
|
|
default:
|
|
assertionFailure()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - MediaGridContainerViewDelegate
|
|
extension StatusView: MediaGridContainerViewDelegate {
|
|
public func mediaGridContainerView(_ container: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) {
|
|
delegate?.statusView(self, mediaGridContainerView: container, mediaView: mediaView, didSelectMediaViewAt: index)
|
|
}
|
|
|
|
public func mediaGridContainerView(_ container: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) {
|
|
delegate?.statusView(self, mediaGridContainerView: container, mediaSensitiveButtonDidPressed: button)
|
|
}
|
|
}
|
|
|
|
// MARK: - UITableViewDelegate
|
|
extension StatusView: UITableViewDelegate {
|
|
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): select \(indexPath.debugDescription)")
|
|
|
|
switch tableView {
|
|
case pollTableView:
|
|
delegate?.statusView(self, pollTableView: tableView, didSelectRowAt: indexPath)
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: ActionToolbarContainerDelegate
|
|
extension StatusView: ActionToolbarContainerDelegate {
|
|
public func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) {
|
|
delegate?.statusView(self, actionToolbarContainer: actionToolbarContainer, buttonDidPressed: button, action: action)
|
|
}
|
|
}
|
|
|
|
// MARK: - StatusMetricViewDelegate
|
|
extension StatusView: StatusMetricViewDelegate {
|
|
func statusMetricView(_ statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton) {
|
|
delegate?.statusView(self, statusMetricView: statusMetricView, reblogButtonDidPressed: button)
|
|
}
|
|
|
|
func statusMetricView(_ statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) {
|
|
delegate?.statusView(self, statusMetricView: statusMetricView, favoriteButtonDidPressed: button)
|
|
}
|
|
}
|
|
|
|
// MARK: - MastodonMenuDelegate
|
|
extension StatusView: MastodonMenuDelegate {
|
|
public func menuAction(_ action: MastodonMenu.Action) {
|
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
|
delegate?.statusView(self, menuButton: menuButton, didSelectAction: action)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
import SwiftUI
|
|
|
|
struct StatusView_Preview: PreviewProvider {
|
|
static var previews: some View {
|
|
UIViewPreview {
|
|
let statusView = StatusView()
|
|
statusView.setup(style: .inline)
|
|
configureStub(statusView: statusView)
|
|
return statusView
|
|
}
|
|
}
|
|
|
|
static func configureStub(statusView: StatusView) {
|
|
// statusView.viewModel
|
|
}
|
|
}
|
|
#endif
|