forked from shadowfacts/Tusker
Add reblog with visibility menu to reblog confirmation alert
This commit is contained in:
parent
7161861d36
commit
ca8a214cf6
|
@ -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 {
|
||||
|
|
|
@ -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<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 {
|
||||
|
|
|
@ -17,4 +17,5 @@ struct ViewTags {
|
|||
static let navForwardBarButton = 42004
|
||||
static let navEmptyTitleView = 42005
|
||||
static let splitNavCloseSecondaryButton = 42006
|
||||
static let customAlertSeparator = 42007
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue