diff --git a/Localization/app.json b/Localization/app.json index 52f0db07b..fc0e84c8b 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -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", diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index dacec8381..da5e97820 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -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 } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift index 230608e58..dc6a9fda5 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -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, diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 238f076f0..9dce85225 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -428,7 +428,7 @@ extension ProfileViewController { } let menu = MastodonMenu.setupMenu( - actions: menuActions, + actions: [menuActions], delegate: self ) return menu diff --git a/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift b/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift index 018628473..011435fff 100644 --- a/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift @@ -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) + } } diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 259af8fb9..65bb3abbe 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -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 { diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index 55cf7ec74..971de9623 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -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 %@"; diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift index ed038f47f..68b13fed5 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift @@ -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: "" diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift index 6bd0062e3..88956d9be 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift @@ -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]) } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift index 7569b640a..a5cb3e808 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift @@ -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) } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index 03eff8c27..653854bf4 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -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) { @@ -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) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index f466fd819..f52728da1 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 652c8c545..ed1d5ed8c 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -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) } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift index e67d11450..65231c51d 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift @@ -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 + } + } }