Add reblog with visibility menu to reblog confirmation alert

This commit is contained in:
Shadowfacts 2022-09-18 00:28:44 -04:00
parent 7161861d36
commit ca8a214cf6
4 changed files with 277 additions and 105 deletions

View File

@ -39,7 +39,11 @@ struct InstanceFeatures {
} }
var trendingStatusesAndLinks: Bool { 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?) { mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
@ -60,6 +64,14 @@ struct InstanceFeatures {
maxStatusChars = instance.maxStatusCharacters ?? 500 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 { extension InstanceFeatures {

View File

@ -15,7 +15,6 @@ class CustomAlertController: UIViewController {
fileprivate var dimmingView: UIView! fileprivate var dimmingView: UIView!
fileprivate var buttonsStack: UIStackView! fileprivate var buttonsStack: UIStackView!
fileprivate var actionsView: CustomAlertActionsView! fileprivate var actionsView: CustomAlertActionsView!
private var separatorHeightConstraint: NSLayoutConstraint!
init(config: Configuration) { init(config: Configuration) {
self.config = config self.config = config
@ -88,12 +87,12 @@ class CustomAlertController: UIViewController {
stack.addSpacer(length: 16) stack.addSpacer(length: 16)
let separator = UIView() let separator = UIView()
separator.tag = ViewTags.customAlertSeparator
separator.backgroundColor = .separator separator.backgroundColor = .separator
stack.addArrangedSubview(separator) stack.addArrangedSubview(separator)
separatorHeightConstraint = separator.heightAnchor.constraint(equalToConstant: 0.5)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
separator.widthAnchor.constraint(equalTo: stack.widthAnchor), separator.widthAnchor.constraint(equalTo: stack.widthAnchor),
separatorHeightConstraint, separator.heightAnchor.constraint(equalToConstant: 0.5),
]) ])
actionsView = CustomAlertActionsView(config: config, dismiss: { [unowned self] in 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 { struct Configuration {
var title: String var title: String
var content: UIView var content: UIView
@ -118,9 +111,29 @@ class CustomAlertController: UIViewController {
} }
struct Action { struct Action {
let title: String var title: String?
let style: UIAlertAction.Style var image: UIImage?
let handler: (() -> Void)? 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 dismiss: () -> Void
private let stack = UIStackView() private let stack = UIStackView()
private var labels: [UIView] = [] private var actionButtons: [CustomAlertActionButton] = []
private var labelWrappers: [UIView] = []
private var labelWrapperWidthConstraints: [NSLayoutConstraint] = [] private var labelWrapperWidthConstraints: [NSLayoutConstraint] = []
// the actions from the config but reordered to match labelWrappers order // the actions from the config but reordered to match labelWrappers order
private var reorderedActions: [CustomAlertController.Action] = [] private var reorderedActions: [CustomAlertController.Action] = []
@ -155,55 +167,54 @@ class CustomAlertActionsView: UIControl {
]) ])
for action in config.actions { for action in config.actions {
let labelWrapper = UIView() let button = CustomAlertActionButton(action: action, dismiss: dismiss)
labelWrapper.isAccessibilityElement = true
labelWrapper.accessibilityTraits = .button
labelWrapper.accessibilityRespondsToUserInteraction = true
labelWrapper.accessibilityLabel = action.title
let label = UILabel() button.isAccessibilityElement = true
labels.append(label) button.accessibilityTraits = .button
label.text = action.title button.accessibilityRespondsToUserInteraction = true
label.textColor = .tintColor button.accessibilityLabel = action.title
switch action.style {
case .cancel: if action.isSecondaryMenu {
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0) button.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
case .destructive:
label.textColor = .systemRed
default:
break
} }
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 { if case .cancel = action.style {
labelWrappers.insert(labelWrapper, at: 0) actionButtons.insert(button, at: 0)
reorderedActions.insert(action, at: 0) reorderedActions.insert(action, at: 0)
} else { } else {
labelWrappers.append(labelWrapper) actionButtons.append(button)
reorderedActions.append(action) reorderedActions.append(action)
} }
} }
var first = true var first = true
for wrapper in labelWrappers { for (action, button) in zip(reorderedActions, actionButtons) {
if first { if first {
first = false first = false
} else { } else if !action.isSecondaryMenu {
let separator = UIView() let separator = UIView()
separator.tag = ViewTags.customAlertSeparator
separator.backgroundColor = .separator separator.backgroundColor = .separator
stack.addArrangedSubview(separator) stack.addArrangedSubview(separator)
separators.append(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) { required init?(coder: NSCoder) {
@ -215,8 +226,33 @@ class CustomAlertActionsView: UIControl {
super.layoutSubviews() 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() { private func updateAxis() {
if reorderedActions.count > 2 || labels.map({ $0.intrinsicContentSize.width }).contains(where: { $0 > (bounds.width - 16) / 2 }) { if needsVertical {
stack.axis = .vertical stack.axis = .vertical
NSLayoutConstraint.deactivate(labelWrapperWidthConstraints) NSLayoutConstraint.deactivate(labelWrapperWidthConstraints)
labelWrapperWidthConstraints = [] labelWrapperWidthConstraints = []
@ -227,8 +263,19 @@ class CustomAlertActionsView: UIControl {
NSLayoutConstraint.activate(separatorSizeConstraints) NSLayoutConstraint.activate(separatorSizeConstraints)
} else { } else {
stack.axis = .horizontal stack.axis = .horizontal
labelWrapperWidthConstraints = labelWrappers.map { labelWrapperWidthConstraints = []
$0.widthAnchor.constraint(equalToConstant: (bounds.width - 0.5) / 2) 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.activate(labelWrapperWidthConstraints)
NSLayoutConstraint.deactivate(separatorSizeConstraints) NSLayoutConstraint.deactivate(separatorSizeConstraints)
@ -239,71 +286,164 @@ class CustomAlertActionsView: UIControl {
} }
} }
// MARK: - UIControl @objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
let selectedButton = actionButtons.enumerated().first {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { $0.1.point(inside: recognizer.location(in: $0.1), with: nil)
// 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
}
} }
return false
}
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { switch recognizer.state {
for (index, labelWrapper) in labelWrappers.enumerated() { case .began:
if labelWrapper.point(inside: touch.location(in: labelWrapper), with: event) { currentSelectedActionIndex = selectedButton?.offset
if index != currentSelectedActionIndex { selectedButton?.element.backgroundColor = .secondarySystemFill
if let currentSelectedActionIndex {
labelWrappers[currentSelectedActionIndex].backgroundColor = nil generator.prepare()
}
generator.selectionChanged() 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
} }
generator.selectionChanged()
currentSelectedActionIndex = index
labelWrapper.backgroundColor = .secondarySystemFill
generator.prepare()
return true
} }
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
} }
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) { class CustomAlertActionButton: UIControl {
super.endTracking(touch, with: event) private let action: CustomAlertController.Action
private let dismiss: () -> Void
if let currentSelectedActionIndex { var titleView = UIStackView()
labelWrappers[currentSelectedActionIndex].backgroundColor = nil
reorderedActions[currentSelectedActionIndex].handler?() init(action: CustomAlertController.Action, dismiss: @escaping () -> Void) {
dismiss() precondition(action.title != nil || action.image != nil, "action must have image and/or title")
self.action = action
self.dismiss = dismiss
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
} }
} }
override func cancelTracking(with event: UIEvent?) { required init?(coder: NSCoder) {
super.cancelTracking(with: event) fatalError("init(coder:) has not been implemented")
}
if let currentSelectedActionIndex { override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
labelWrappers[currentSelectedActionIndex].backgroundColor = nil 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<UITouch>, 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<UITouch>, with event: UIEvent?) {
super.touchesMoved(touches, with: event)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
backgroundColor = nil
action.handler?()
dismiss()
}
} }
extension CustomAlertController { extension CustomAlertController {

View File

@ -17,4 +17,5 @@ struct ViewTags {
static let navForwardBarButton = 42004 static let navForwardBarButton = 42004
static let navEmptyTitleView = 42005 static let navEmptyTitleView = 42005
static let splitNavCloseSecondaryButton = 42006 static let splitNavCloseSecondaryButton = 42006
static let customAlertSeparator = 42007
} }

View File

@ -412,13 +412,32 @@ class BaseStatusTableViewCell: UITableViewCell {
// if we are about to reblog and the user has confirmation enabled // if we are about to reblog and the user has confirmation enabled
if !reblogged, if !reblogged,
Preferences.shared.confirmBeforeReblog { 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 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: "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) 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) let alert = CustomAlertController(config: config)
delegate?.present(alert, animated: true) delegate?.present(alert, animated: true)
} else { } else {