Merge pull request #1055 from mastodon/ios-103-post-menu

Post Overflow Menus (IOS-103)
This commit is contained in:
Nathan Mattes 2023-06-20 19:06:20 +02:00 committed by GitHub
commit 09ff88d0b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 185 additions and 64 deletions

View File

@ -101,8 +101,11 @@
"title": "Translate from %s",
"unknown_language": "Unknown"
},
"edit_post": "Edit"
"edit_post": "Edit",
"bookmark": "Bookmark",
"remove_bookmark": "Remove Bookmark",
"follow": "Follow %s",
"unfollow": "Unfollow %s"
},
"tabs": {
"home": "Home",

View File

@ -384,6 +384,16 @@ extension DataSourceFacade {
composeContext: .editStatus(status: status, statusSource: statusSource),
destination: .topLevel)
_ = dependency.coordinator.present(scene: .editStatus(viewModel: editStatusViewModel), transition: .modal(animated: true))
case .showOriginal:
// do nothing, as the translation is reverted in `StatusTableViewCellDelegate` in `DataSourceProvider+StatusTableViewCellDelegate.swift`.
break
case .followUser(_):
guard let author = menuContext.author else { return }
try await DataSourceFacade.responseToUserFollowAction(dependency: dependency,
user: author)
}
} // end func
}

View File

@ -496,6 +496,14 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
cell.invalidateIntrinsicContentSize()
}
}
if case .showOriginal = action {
DispatchQueue.main.async {
if let cell = cell as? StatusTableViewCell {
cell.statusView.revertTranslation()
}
}
}
try await DataSourceFacade.responseToMenuAction(
dependency: self,

View File

@ -428,7 +428,7 @@ extension ProfileViewController {
}
let menu = MastodonMenu.setupMenu(
actions: menuActions,
actions: [menuActions],
delegate: self
)
return menu

View File

@ -187,6 +187,7 @@ extension NotificationView {
}
.assign(to: \.isBlocking, on: viewModel)
.store(in: &disposeBag)
// isMyself
Publishers.CombineLatest(
author.publisher(for: \.domain),
@ -199,12 +200,27 @@ extension NotificationView {
}
.assign(to: \.isMyself, on: viewModel)
.store(in: &disposeBag)
// follow request state
notification.publisher(for: \.followRequestState)
.assign(to: \.followRequestState, on: viewModel)
.store(in: &disposeBag)
notification.publisher(for: \.transientFollowRequestState)
.assign(to: \.transientFollowRequestState, on: viewModel)
.store(in: &disposeBag)
// Following
author.publisher(for: \.followingBy)
.map { [weak viewModel] followingBy in
guard let viewModel = viewModel else { return false }
guard let authContext = viewModel.authContext else { return false }
return followingBy.contains(where: {
$0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain
})
}
.assign(to: \.isFollowed, on: viewModel)
.store(in: &disposeBag)
}
}

View File

@ -112,6 +112,8 @@ public enum L10n {
public static func blockDomain(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Actions.BlockDomain", String(describing: p1), fallback: "Block %@")
}
/// Bookmark
public static let bookmark = L10n.tr("Localizable", "Common.Controls.Actions.Bookmark", fallback: "Bookmark")
/// Cancel
public static let cancel = L10n.tr("Localizable", "Common.Controls.Actions.Cancel", fallback: "Cancel")
/// Compose
@ -136,6 +138,10 @@ public enum L10n {
public static let editPost = L10n.tr("Localizable", "Common.Controls.Actions.EditPost", fallback: "Edit")
/// Find people to follow
public static let findPeople = L10n.tr("Localizable", "Common.Controls.Actions.FindPeople", fallback: "Find people to follow")
/// Follow %@
public static func follow(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Actions.Follow", String(describing: p1), fallback: "Follow %@")
}
/// Manually search instead
public static let manuallySearch = L10n.tr("Localizable", "Common.Controls.Actions.ManuallySearch", fallback: "Manually search instead")
/// Next
@ -154,6 +160,8 @@ public enum L10n {
public static let previous = L10n.tr("Localizable", "Common.Controls.Actions.Previous", fallback: "Previous")
/// Remove
public static let remove = L10n.tr("Localizable", "Common.Controls.Actions.Remove", fallback: "Remove")
/// Remove Bookmark
public static let removeBookmark = L10n.tr("Localizable", "Common.Controls.Actions.RemoveBookmark", fallback: "Remove Bookmark")
/// Reply
public static let reply = L10n.tr("Localizable", "Common.Controls.Actions.Reply", fallback: "Reply")
/// Report %@
@ -188,6 +196,10 @@ public enum L10n {
public static func unblockDomain(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Actions.UnblockDomain", String(describing: p1), fallback: "Unblock %@")
}
/// Unfollow %@
public static func unfollow(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Actions.Unfollow", String(describing: p1), fallback: "Unfollow %@")
}
public enum TranslatePost {
/// Translate from %@
public static func title(_ p1: Any) -> String {

View File

@ -40,6 +40,8 @@ Please check your internet connection.";
"Common.Controls.Actions.Discard" = "Discard";
"Common.Controls.Actions.Done" = "Done";
"Common.Controls.Actions.Edit" = "Edit";
"Common.Controls.Actions.Bookmark" = "Bookmark";
"Common.Controls.Actions.RemoveBookmark" = "Remove Bookmark";
"Common.Controls.Actions.EditPost" = "Edit";
"Common.Controls.Actions.FindPeople" = "Find people to follow";
"Common.Controls.Actions.ManuallySearch" = "Manually search instead";
@ -67,6 +69,8 @@ Please check your internet connection.";
"Common.Controls.Actions.TranslatePost.UnknownLanguage" = "Unknown";
"Common.Controls.Actions.TryAgain" = "Try Again";
"Common.Controls.Actions.UnblockDomain" = "Unblock %@";
"Common.Controls.Actions.Follow" = "Follow %@";
"Common.Controls.Actions.Unfollow" = "Unfollow %@";
"Common.Controls.Friendship.Block" = "Block";
"Common.Controls.Friendship.BlockDomain" = "Block %@";
"Common.Controls.Friendship.BlockUser" = "Block %@";

View File

@ -39,6 +39,7 @@ extension NotificationView {
@Published public var isMuting = false
@Published public var isBlocking = false
@Published public var isTranslated = false
@Published public var isFollowed = false
@Published public var timestamp: Date?
@ -208,18 +209,19 @@ extension NotificationView.ViewModel {
$authorName,
$isMuting,
$isBlocking,
Publishers.CombineLatest(
Publishers.CombineLatest3(
$isMyself,
$isTranslated
$isTranslated,
$isFollowed
)
)
.sink { [weak self] authorName, isMuting, isBlocking, isMyselfIsTranslated in
.sink { [weak self] authorName, isMuting, isBlocking, isMyselfIsTranslatedIsFollowed in
guard let name = authorName?.string else {
notificationView.menuButton.menu = nil
return
}
let (isMyself, isTranslated) = isMyselfIsTranslated
let (isMyself, isTranslated, isFollowed) = isMyselfIsTranslatedIsFollowed
lazy var instanceConfigurationV2: Mastodon.Entity.V2.Instance.Configuration? = {
guard
@ -243,6 +245,7 @@ extension NotificationView.ViewModel {
isBlocking: isBlocking,
isMyself: isMyself,
isBookmarking: false, // no bookmark action display for notification item
isFollowed: isFollowed,
isTranslationEnabled: instanceConfigurationV2?.translation?.enabled == true,
isTranslated: isTranslated,
statusLanguage: ""

View File

@ -460,9 +460,10 @@ extension NotificationView {
public typealias AuthorMenuContext = StatusAuthorView.AuthorMenuContext
public func setupAuthorMenu(menuContext: AuthorMenuContext) -> (UIMenu, [UIAccessibilityCustomAction]) {
var actions: [MastodonMenu.Action] = []
actions = [
var actions: [[MastodonMenu.Action]] = []
var upperActions: [MastodonMenu.Action] = []
upperActions = [
.muteUser(.init(
name: menuContext.name,
isMuting: menuContext.isMuting
@ -473,11 +474,13 @@ extension NotificationView {
)),
.reportUser(
.init(name: menuContext.name)
),
)
]
actions.append(upperActions)
if menuContext.isMyself {
actions.append(.deleteStatus)
actions.append([.deleteStatus])
}

View File

@ -150,6 +150,7 @@ extension StatusAuthorView {
public let isBlocking: Bool
public let isMyself: Bool
public let isBookmarking: Bool
public let isFollowed: Bool
public let isTranslationEnabled: Bool
public let isTranslated: Bool
@ -157,46 +158,54 @@ extension StatusAuthorView {
}
public func setupAuthorMenu(menuContext: AuthorMenuContext) -> (UIMenu, [UIAccessibilityCustomAction]) {
var actions = [MastodonMenu.Action]()
var actions: [[MastodonMenu.Action]] = []
var postActions: [MastodonMenu.Action] = []
var userActions: [MastodonMenu.Action] = []
if menuContext.isMyself {
actions.append(.editStatus)
postActions.append(.editStatus)
}
if !menuContext.isMyself {
if let statusLanguage = menuContext.statusLanguage, menuContext.isTranslationEnabled, !menuContext.isTranslated {
actions.append(
.translateStatus(.init(language: statusLanguage))
)
if let statusLanguage = menuContext.statusLanguage, menuContext.isTranslationEnabled {
if menuContext.isTranslated == false {
postActions.append(.translateStatus(.init(language: statusLanguage)))
} else {
postActions.append(.showOriginal)
}
actions.append(contentsOf: [
.muteUser(.init(
name: menuContext.name,
isMuting: menuContext.isMuting
)),
.blockUser(.init(
name: menuContext.name,
isBlocking: menuContext.isBlocking
)),
.reportUser(
.init(name: menuContext.name)
)
])
}
actions.append(contentsOf: [
.bookmarkStatus(
.init(isBookmarking: menuContext.isBookmarking)
),
.shareStatus
])
postActions.append(.bookmarkStatus(.init(isBookmarking: menuContext.isBookmarking)))
postActions.append(.shareStatus)
if menuContext.isMyself == false {
userActions.append(.followUser(.init(
name: menuContext.name,
isFollowing: menuContext.isFollowed
)))
userActions.append(.muteUser(.init(
name: menuContext.name,
isMuting: menuContext.isMuting
)))
userActions.append(.blockUser(.init(
name: menuContext.name,
isBlocking: menuContext.isBlocking
)))
userActions.append(.reportUser(
.init(name: menuContext.name)
))
}
actions.append(postActions)
actions.append(userActions)
if menuContext.isMyself {
actions.append(.deleteStatus)
actions.append([.deleteStatus])
}
let menu = MastodonMenu.setupMenu(
actions: actions,
delegate: self.statusView!
@ -214,14 +223,14 @@ extension StatusAuthorView {
extension StatusAuthorView {
@objc private func authorAvatarButtonDidPressed(_ sender: UIButton) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
guard let statusView = statusView else { return }
statusView.delegate?.statusView(statusView, authorAvatarButtonDidPressed: avatarButton)
}
@objc private func contentSensitiveeToggleButtonDidPressed(_ sender: UIButton) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
guard let statusView = statusView else { return }
statusView.delegate?.statusView(statusView, contentSensitiveeToggleButtonDidPressed: sender)
}
}

View File

@ -258,6 +258,18 @@ extension StatusView {
}
.assign(to: \.isMyself, on: viewModel)
.store(in: &disposeBag)
// Following
author.publisher(for: \.followingBy)
.map { [weak viewModel] followingBy in
guard let viewModel = viewModel else { return false }
guard let authContext = viewModel.authContext else { return false }
return followingBy.contains(where: {
$0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain
})
}
.assign(to: \.isFollowed, on: viewModel)
.store(in: &disposeBag)
}
private func configureTimestamp(timestamp: AnyPublisher<Date, Never>) {
@ -278,8 +290,9 @@ extension StatusView {
viewModel.applicationName = applicationName
}
func revertTranslation() {
public func revertTranslation() {
guard let originalStatus = viewModel.originalStatus else { return }
viewModel.translatedFromLanguage = nil
viewModel.translatedUsingProvider = nil
originalStatus.reblog?.update(translatedContent: nil)

View File

@ -45,6 +45,7 @@ extension StatusView {
@Published public var isMyself = false
@Published public var isMuting = false
@Published public var isBlocking = false
@Published public var isFollowed = false
// Translation
@Published public var isCurrentlyTranslating = false
@ -656,10 +657,11 @@ extension StatusView.ViewModel {
$authorName,
$isMyself
)
let publishersTwo = Publishers.CombineLatest3(
let publishersTwo = Publishers.CombineLatest4(
$isMuting,
$isBlocking,
$isBookmark
$isBookmark,
$isFollowed
)
let publishersThree = Publishers.CombineLatest(
$translatedFromLanguage,
@ -673,7 +675,7 @@ extension StatusView.ViewModel {
).eraseToAnyPublisher()
.sink { tupleOne, tupleTwo, tupleThree in
let (authorName, isMyself) = tupleOne
let (isMuting, isBlocking, isBookmark) = tupleTwo
let (isMuting, isBlocking, isBookmark, isFollowed) = tupleTwo
let (translatedFromLanguage, language) = tupleThree
guard let name = authorName?.string else {
@ -704,6 +706,7 @@ extension StatusView.ViewModel {
isBlocking: isBlocking,
isMyself: isMyself,
isBookmarking: isBookmark,
isFollowed: isFollowed,
isTranslationEnabled: instanceConfigurationV2?.translation?.enabled == true,
isTranslated: translatedFromLanguage != nil,
statusLanguage: language

View File

@ -792,7 +792,6 @@ extension StatusView: StatusMetricViewDelegate {
// 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: authorView.menuButton, didSelectAction: action)
}
}

View File

@ -14,31 +14,30 @@ public protocol MastodonMenuDelegate: AnyObject {
public enum MastodonMenu {
public static func setupMenu(
actions: [Action],
actions: [[Action]],
delegate: MastodonMenuDelegate
) -> UIMenu {
var children: [UIMenuElement] = []
for action in actions {
let element: UIMenuElement
if case let .deleteStatus = action {
let deleteAction = action.build(delegate: delegate).menuElement
element = UIMenu(options: .displayInline, children: [deleteAction])
} else {
element = action.build(delegate: delegate).menuElement
for actionGroup in actions {
var submenuChildren: [UIMenuElement] = []
for action in actionGroup {
let element = action.build(delegate: delegate).menuElement
submenuChildren.append(element)
}
children.append(element)
let submenu = UIMenu(options: .displayInline, children: submenuChildren)
children.append(submenu)
}
return UIMenu(children: children)
}
public static func setupAccessibilityActions(
actions: [Action],
actions: [[Action]],
delegate: MastodonMenuDelegate
) -> [UIAccessibilityCustomAction] {
var accessibilityActions: [UIAccessibilityCustomAction] = []
for action in actions {
for action in actions.flatMap({ $0 }) {
let element = action.build(delegate: delegate)
accessibilityActions.append(element.accessibilityCustomAction)
}
@ -49,6 +48,7 @@ public enum MastodonMenu {
extension MastodonMenu {
public enum Action {
case translateStatus(TranslateStatusActionContext)
case showOriginal
case muteUser(MuteUserActionContext)
case blockUser(BlockUserActionContext)
case reportUser(ReportUserActionContext)
@ -58,6 +58,7 @@ extension MastodonMenu {
case shareStatus
case deleteStatus
case editStatus
case followUser(FollowUserActionContext)
func build(delegate: MastodonMenuDelegate) -> LabeledAction {
switch self {
@ -121,10 +122,10 @@ extension MastodonMenu {
let title: String
let image: UIImage?
if context.isBookmarking {
title = "Remove Bookmark" // TODO: i18n
title = L10n.Common.Controls.Actions.removeBookmark
image = UIImage(systemName: "bookmark.slash.fill")
} else {
title = "Bookmark" // TODO: i18n
title = L10n.Common.Controls.Actions.bookmark
image = UIImage(systemName: "bookmark")
}
let action = LabeledAction(title: title, image: image) { [weak delegate] in
@ -134,7 +135,7 @@ extension MastodonMenu {
return action
case .shareStatus:
let action = LabeledAction(
title: "Share", // TODO: i18n
title: L10n.Common.Controls.Actions.sharePost,
image: UIImage(systemName: "square.and.arrow.up")
) { [weak delegate] in
guard let delegate = delegate else { return }
@ -161,6 +162,16 @@ extension MastodonMenu {
delegate.menuAction(self)
}
return translateAction
case .showOriginal:
let action = LabeledAction(
title: L10n.Common.Controls.Status.Translation.showOriginal,
image: UIImage(systemName: "character.book.closed")
) { [weak delegate] in
guard let delegate = delegate else { return }
delegate.menuAction(self)
}
return action
case .editStatus:
let editStatusAction = LabeledAction(
title: L10n.Common.Controls.Actions.editPost,
@ -172,6 +183,22 @@ extension MastodonMenu {
}
return editStatusAction
case .followUser(let context):
let title: String
let image: UIImage?
if context.isFollowing {
title = L10n.Common.Controls.Actions.unfollow(context.name)
image = UIImage(systemName: "person.fill.badge.minus")
} else {
title = L10n.Common.Controls.Actions.follow(context.name)
image = UIImage(systemName: "person.fill.badge.plus")
}
let action = LabeledAction(title: title, image: image) { [weak delegate] in
guard let delegate = delegate else { return }
delegate.menuAction(self)
}
return action
} // end switch
} // end func build
} // end enum Action
@ -237,4 +264,15 @@ extension MastodonMenu {
self.language = language
}
}
public struct FollowUserActionContext {
public let name: String
public let isFollowing: Bool
init(name: String, isFollowing: Bool) {
self.name = name
self.isFollowing = isFollowing
}
}
}