diff --git a/Tusker/InstanceFeatures.swift b/Tusker/InstanceFeatures.swift index d3773cfa..809d3b58 100644 --- a/Tusker/InstanceFeatures.swift +++ b/Tusker/InstanceFeatures.swift @@ -39,7 +39,11 @@ struct InstanceFeatures { } var trendingStatusesAndLinks: Bool { - instanceType == .mastodon && version != nil && version! >= Version(3, 5, 0) + instanceType == .mastodon && hasVersion(3, 5, 0) + } + + var reblogVisibility: Bool { + instanceType == .mastodon && hasVersion(2, 8, 0) } mutating func update(instance: Instance, nodeInfo: NodeInfo?) { @@ -60,6 +64,14 @@ struct InstanceFeatures { maxStatusChars = instance.maxStatusCharacters ?? 500 } + + func hasVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { + if let version { + return version >= Version(major, minor, patch) + } else { + return false + } + } } extension InstanceFeatures { diff --git a/Tusker/Screens/Utilities/CustomAlertController.swift b/Tusker/Screens/Utilities/CustomAlertController.swift index d96eb1b9..870709de 100644 --- a/Tusker/Screens/Utilities/CustomAlertController.swift +++ b/Tusker/Screens/Utilities/CustomAlertController.swift @@ -15,7 +15,6 @@ class CustomAlertController: UIViewController { fileprivate var dimmingView: UIView! fileprivate var buttonsStack: UIStackView! fileprivate var actionsView: CustomAlertActionsView! - private var separatorHeightConstraint: NSLayoutConstraint! init(config: Configuration) { self.config = config @@ -88,12 +87,12 @@ class CustomAlertController: UIViewController { stack.addSpacer(length: 16) let separator = UIView() + separator.tag = ViewTags.customAlertSeparator separator.backgroundColor = .separator stack.addArrangedSubview(separator) - separatorHeightConstraint = separator.heightAnchor.constraint(equalToConstant: 0.5) NSLayoutConstraint.activate([ separator.widthAnchor.constraint(equalTo: stack.widthAnchor), - separatorHeightConstraint, + separator.heightAnchor.constraint(equalToConstant: 0.5), ]) actionsView = CustomAlertActionsView(config: config, dismiss: { [unowned self] in @@ -105,12 +104,6 @@ class CustomAlertController: UIViewController { ]) } - override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - - separatorHeightConstraint.constant = 1 / view.window!.screen.scale - } - struct Configuration { var title: String var content: UIView @@ -118,9 +111,29 @@ class CustomAlertController: UIViewController { } struct Action { - let title: String - let style: UIAlertAction.Style - let handler: (() -> Void)? + var title: String? + var image: UIImage? + var style: Style + var handler: (() -> Void)? + var isSecondaryMenu: Bool = false + + init(title: String?, image: UIImage? = nil, style: Style, handler: (() -> Void)?) { + self.title = title + self.image = image + self.style = style + self.handler = handler + } + + enum Style { + case `default`, cancel, destructive, menu([MenuAction]) + } + } + + struct MenuAction { + var title: String + var subtitle: String? + var image: UIImage? + var handler: () -> Void } } @@ -128,8 +141,7 @@ class CustomAlertActionsView: UIControl { private let dismiss: () -> Void private let stack = UIStackView() - private var labels: [UIView] = [] - private var labelWrappers: [UIView] = [] + private var actionButtons: [CustomAlertActionButton] = [] private var labelWrapperWidthConstraints: [NSLayoutConstraint] = [] // the actions from the config but reordered to match labelWrappers order private var reorderedActions: [CustomAlertController.Action] = [] @@ -155,55 +167,54 @@ class CustomAlertActionsView: UIControl { ]) for action in config.actions { - let labelWrapper = UIView() - labelWrapper.isAccessibilityElement = true - labelWrapper.accessibilityTraits = .button - labelWrapper.accessibilityRespondsToUserInteraction = true - labelWrapper.accessibilityLabel = action.title + let button = CustomAlertActionButton(action: action, dismiss: dismiss) - let label = UILabel() - labels.append(label) - label.text = action.title - label.textColor = .tintColor - switch action.style { - case .cancel: - label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0) - case .destructive: - label.textColor = .systemRed - default: - break + button.isAccessibilityElement = true + button.accessibilityTraits = .button + button.accessibilityRespondsToUserInteraction = true + button.accessibilityLabel = action.title + + if action.isSecondaryMenu { + button.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true } - label.translatesAutoresizingMaskIntoConstraints = false - labelWrapper.addSubview(label) - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(greaterThanOrEqualTo: labelWrapper.leadingAnchor, constant: 4), - label.trailingAnchor.constraint(lessThanOrEqualTo: labelWrapper.trailingAnchor, constant: -4), - label.centerXAnchor.constraint(equalTo: labelWrapper.centerXAnchor), - label.centerYAnchor.constraint(equalTo: labelWrapper.centerYAnchor), - labelWrapper.heightAnchor.constraint(equalToConstant: 44), - ]) - if action.style == .cancel { - labelWrappers.insert(labelWrapper, at: 0) + if case .cancel = action.style { + actionButtons.insert(button, at: 0) reorderedActions.insert(action, at: 0) } else { - labelWrappers.append(labelWrapper) + actionButtons.append(button) reorderedActions.append(action) } } var first = true - for wrapper in labelWrappers { + for (action, button) in zip(reorderedActions, actionButtons) { if first { first = false - } else { + } else if !action.isSecondaryMenu { let separator = UIView() + separator.tag = ViewTags.customAlertSeparator separator.backgroundColor = .separator stack.addArrangedSubview(separator) separators.append(separator) } - stack.addArrangedSubview(wrapper) + if action.isSecondaryMenu { + // prev button + let prev = stack.arrangedSubviews.last! + stack.removeArrangedSubview(prev) + let separator = UIView() + separator.tag = ViewTags.customAlertSeparator + separator.backgroundColor = .separator + separator.widthAnchor.constraint(equalToConstant: 0.5).isActive = true + let hStack = UIStackView(arrangedSubviews: [prev, separator, button]) + hStack.axis = .horizontal + stack.addArrangedSubview(hStack) + } else { + stack.addArrangedSubview(button) + } } + + addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(panRecognized))) } required init?(coder: NSCoder) { @@ -215,8 +226,33 @@ class CustomAlertActionsView: UIControl { super.layoutSubviews() } + private var menuActionsCount: Int { + var count = 0 + for action in reorderedActions { + if case .menu(_) = action.style { + count += 1 + } + } + return count + } + + private var needsVertical: Bool { + if reorderedActions.count - menuActionsCount > 2 { + return false + } + var requiredWidth: CGFloat = 0 + for (index, action) in actionButtons.enumerated() { + if index > 0 { + requiredWidth += 0.5 + } + requiredWidth += action.titleView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize).width + requiredWidth += 8 + } + return requiredWidth > bounds.width + } + private func updateAxis() { - if reorderedActions.count > 2 || labels.map({ $0.intrinsicContentSize.width }).contains(where: { $0 > (bounds.width - 16) / 2 }) { + if needsVertical { stack.axis = .vertical NSLayoutConstraint.deactivate(labelWrapperWidthConstraints) labelWrapperWidthConstraints = [] @@ -227,8 +263,19 @@ class CustomAlertActionsView: UIControl { NSLayoutConstraint.activate(separatorSizeConstraints) } else { stack.axis = .horizontal - labelWrapperWidthConstraints = labelWrappers.map { - $0.widthAnchor.constraint(equalToConstant: (bounds.width - 0.5) / 2) + labelWrapperWidthConstraints = [] + var nonMenuAction: CustomAlertActionButton? + for (index, action) in reorderedActions.enumerated() { + if case .menu(_) = action.style { + } else { + if let nonMenuAction { + labelWrapperWidthConstraints.append( + actionButtons[index].widthAnchor.constraint(equalTo: nonMenuAction.widthAnchor) + ) + } else { + nonMenuAction = actionButtons[index] + } + } } NSLayoutConstraint.activate(labelWrapperWidthConstraints) NSLayoutConstraint.deactivate(separatorSizeConstraints) @@ -239,71 +286,164 @@ class CustomAlertActionsView: UIControl { } } - // MARK: - UIControl - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - // we want this view to handle all touches inside it - return self - } - - override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { - for (index, labelWrapper) in labelWrappers.enumerated() { - if labelWrapper.point(inside: touch.location(in: labelWrapper), with: event) { - currentSelectedActionIndex = index - labelWrapper.backgroundColor = .secondarySystemFill - - generator.prepare() - - return true - } + @objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) { + let selectedButton = actionButtons.enumerated().first { + $0.1.point(inside: recognizer.location(in: $0.1), with: nil) } - return false - } - - override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { - for (index, labelWrapper) in labelWrappers.enumerated() { - if labelWrapper.point(inside: touch.location(in: labelWrapper), with: event) { - if index != currentSelectedActionIndex { - if let currentSelectedActionIndex { - labelWrappers[currentSelectedActionIndex].backgroundColor = nil - } - generator.selectionChanged() + + switch recognizer.state { + case .began: + currentSelectedActionIndex = selectedButton?.offset + selectedButton?.element.backgroundColor = .secondarySystemFill + + generator.prepare() + + case .changed: + if selectedButton == nil && hitTest(recognizer.location(in: self), with: nil)?.tag == ViewTags.customAlertSeparator { + break + } + + if selectedButton?.offset != currentSelectedActionIndex { + if let currentSelectedActionIndex { + actionButtons[currentSelectedActionIndex].backgroundColor = nil } - - currentSelectedActionIndex = index - labelWrapper.backgroundColor = .secondarySystemFill - - generator.prepare() - - return true + generator.selectionChanged() } + + currentSelectedActionIndex = selectedButton?.offset + selectedButton?.element.backgroundColor = .secondarySystemFill + + generator.prepare() + + case .ended: + if let currentSelectedActionIndex { + let button = actionButtons[currentSelectedActionIndex] + button.backgroundColor = nil + let action = reorderedActions[currentSelectedActionIndex] + if action.handler == nil, + case .menu(_) = action.style, + let interaction = button.contextMenuInteraction { + let selector = NSSelectorFromString(["Location:", "At", "Menu", "present", "_"].reversed().joined()) + if interaction.responds(to: selector) { + interaction.perform(selector, with: recognizer.location(in: button)) + } + } else { + action.handler?() + self.dismiss() + } + } + + default: + break } - // didn't hit any button - if let currentSelectedActionIndex { - labelWrappers[currentSelectedActionIndex].backgroundColor = nil - self.currentSelectedActionIndex = nil - } - return true } +} + +class CustomAlertActionButton: UIControl { + private let action: CustomAlertController.Action + private let dismiss: () -> Void - override func endTracking(_ touch: UITouch?, with event: UIEvent?) { - super.endTracking(touch, with: event) + var titleView = UIStackView() + + init(action: CustomAlertController.Action, dismiss: @escaping () -> Void) { + precondition(action.title != nil || action.image != nil, "action must have image and/or title") - if let currentSelectedActionIndex { - labelWrappers[currentSelectedActionIndex].backgroundColor = nil - reorderedActions[currentSelectedActionIndex].handler?() - dismiss() - } - } - - override func cancelTracking(with event: UIEvent?) { - super.cancelTracking(with: event) + self.action = action + self.dismiss = dismiss - if let currentSelectedActionIndex { - labelWrappers[currentSelectedActionIndex].backgroundColor = nil + super.init(frame: .zero) + + titleView = UIStackView() + titleView.axis = .horizontal + titleView.spacing = 4 + + if let title = action.title { + let label = UILabel() + label.text = title + label.textColor = .tintColor + switch action.style { + case .cancel: + label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0) + case .destructive: + label.textColor = .systemRed + default: + break + } + titleView.addArrangedSubview(label) + } + + if let image = action.image { + let imageView = UIImageView(image: image) + titleView.addArrangedSubview(imageView) + } + + titleView.translatesAutoresizingMaskIntoConstraints = false + addSubview(titleView) + NSLayoutConstraint.activate([ + titleView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4), + titleView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4), + titleView.centerXAnchor.constraint(equalTo: centerXAnchor), + titleView.centerYAnchor.constraint(equalTo: centerYAnchor), + heightAnchor.constraint(equalToConstant: 44), + ]) + + if case .menu(_) = action.style { + self.isContextMenuInteractionEnabled = true + self.showsMenuAsPrimaryAction = action.handler == nil } } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + guard case .menu(let menuActions) = action.style else { + return nil + } + return UIContextMenuConfiguration(actionProvider: { _ in + return UIMenu(children: menuActions.map { action in + UIAction(title: action.title, subtitle: action.subtitle, image: action.image) { [unowned self] _ in + action.handler() + self.dismiss() + } + }) + }) + } + + override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + super.contextMenuInteraction(interaction, willDisplayMenuFor: configuration, animator: animator) + if let animator { + animator.addAnimations { + self.backgroundColor = nil + } + } else { + backgroundColor = nil + } + } + + override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + super.contextMenuInteraction(interaction, willEndFor: configuration, animator: animator) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + + if point(inside: touches.first!.location(in: self), with: event) { + backgroundColor = .secondarySystemFill + } + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + super.touchesMoved(touches, with: event) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + backgroundColor = nil + action.handler?() + dismiss() + } } extension CustomAlertController { diff --git a/Tusker/ViewTags.swift b/Tusker/ViewTags.swift index 953af036..e032b21a 100644 --- a/Tusker/ViewTags.swift +++ b/Tusker/ViewTags.swift @@ -17,4 +17,5 @@ struct ViewTags { static let navForwardBarButton = 42004 static let navEmptyTitleView = 42005 static let splitNavCloseSecondaryButton = 42006 + static let customAlertSeparator = 42007 } diff --git a/Tusker/Views/Status/BaseStatusTableViewCell.swift b/Tusker/Views/Status/BaseStatusTableViewCell.swift index 04cc21c3..658f9e2d 100644 --- a/Tusker/Views/Status/BaseStatusTableViewCell.swift +++ b/Tusker/Views/Status/BaseStatusTableViewCell.swift @@ -412,13 +412,32 @@ class BaseStatusTableViewCell: UITableViewCell { // if we are about to reblog and the user has confirmation enabled if !reblogged, Preferences.shared.confirmBeforeReblog { + let image: UIImage? + let reblogVisibilityActions: [CustomAlertController.MenuAction]? + if mastodonController.instanceFeatures.reblogVisibility { + image = UIImage(systemName: Status.Visibility.public.unfilledImageName) + reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in + CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) { [unowned self] in + self.toggleReblogInternal(visibility: visibility) + } + } + } else { + image = nil + reblogVisibilityActions = nil + } + let preview = ConfirmReblogStatusPreviewView(status: status) - let config = CustomAlertController.Configuration(title: "Are you sure you want to reblog this post?", content: preview, actions: [ + var config = CustomAlertController.Configuration(title: "Are you sure you want to reblog this post?", content: preview, actions: [ CustomAlertController.Action(title: "Cancel", style: .cancel, handler: nil), - CustomAlertController.Action(title: "Reblog", style: .default, handler: { [unowned self] in + CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: { [unowned self] in self.toggleReblogInternal(visibility: nil) }), ]) + if let reblogVisibilityActions { + var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil) + menuAction.isSecondaryMenu = true + config.actions.append(menuAction) + } let alert = CustomAlertController(config: config) delegate?.present(alert, animated: true) } else {