diff --git a/Localization/app.json b/Localization/app.json index 9514fbd3..0830a08b 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -68,6 +68,7 @@ "find_people": "Find people to follow", "manually_search": "Manually search instead", "skip": "Skip", + "reply": "Reply", "report_user": "Report %s", "block_domain": "Block %s", "unblock_domain": "Unblock %s", @@ -85,6 +86,18 @@ "switch_to_tab": "Switch to %s", "show_favorites": "Show Favorites", "open_settings": "Open Settings" + }, + "timeline": { + "previous_status": "Previous Status", + "next_status": "Next Status", + "open_status": "Open Status", + "open_author_profile": "Open Author Profile", + "open_reblogger_profile": "Open Reblogger Profile", + "reply_status": "Reply Status", + "toggle_reblog": "Toggle Status Reblog", + "toggle_favorite": "Toggle Status Favorite", + "toggle_content_warning": "Toggle Content Warning", + "preview_image": "Preview Image" } }, "status": { @@ -498,6 +511,13 @@ "send": "Send Report", "skip_to_send": "Send without comment", "text_placeholder": "Type or paste additional comments" + }, + "preview": { + "keyboard": { + "close_preview": "Close Preview", + "show_next": "Show Next", + "show_previous": "Show Previous" + } } } } \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0ef6f675..37a480c7 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -197,6 +197,9 @@ DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; }; + DB1D842C26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D842B26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift */; }; + DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D842D26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift */; }; + DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D842F26566512000346B3 /* KeyboardPreference.swift */; }; DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */; }; DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E347725F519300079D7DF /* PickServerItem.swift */; }; DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; }; @@ -752,6 +755,9 @@ DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; + DB1D842B26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewKeyCommandNavigateable.swift"; sourceTree = ""; }; + DB1D842D26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerNavigateable.swift; sourceTree = ""; }; + DB1D842F26566512000346B3 /* KeyboardPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPreference.swift; sourceTree = ""; }; DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; DB1E347725F519300079D7DF /* PickServerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PickServerItem.swift; sourceTree = ""; }; DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = ""; }; @@ -1241,6 +1247,7 @@ 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */, DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */, DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */, + DB1D842B26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift */, ); path = StatusProvider; sourceTree = ""; @@ -1341,6 +1348,7 @@ 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */, DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */, + DB1D842D26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift */, ); path = Protocol; sourceTree = ""; @@ -1789,6 +1797,7 @@ DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */, DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */, DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */, + DB1D842F26566512000346B3 /* KeyboardPreference.swift */, ); path = Preference; sourceTree = ""; @@ -3054,6 +3063,7 @@ 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+Provider.swift in Sources */, + DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */, DBBF1DC92652538500E5B703 /* AutoCompleteSection.swift in Sources */, @@ -3205,6 +3215,7 @@ 0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, + DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */, DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */, @@ -3243,6 +3254,7 @@ DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, + DB1D842C26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, diff --git a/Mastodon/Extension/UITableView.swift b/Mastodon/Extension/UITableView.swift index 22ae6c0b..8e3cc957 100644 --- a/Mastodon/Extension/UITableView.swift +++ b/Mastodon/Extension/UITableView.swift @@ -7,15 +7,6 @@ import UIKit -extension UITableView { - - // static let groupedTableViewPaddingHeaderViewHeight: CGFloat = 16 - // static var groupedTableViewPaddingHeaderView: UIView { - // return UIView(frame: CGRect(x: 0, y: 0, width: 100, height: groupedTableViewPaddingHeaderViewHeight)) - // } - -} - extension UITableView { func deselectRow(with transitionCoordinator: UIViewControllerTransitionCoordinator?, animated: Bool) { diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 5b7580b9..42498794 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -110,6 +110,8 @@ internal enum L10n { internal static let preview = L10n.tr("Localizable", "Common.Controls.Actions.Preview") /// Remove internal static let remove = L10n.tr("Localizable", "Common.Controls.Actions.Remove") + /// Reply + internal static let reply = L10n.tr("Localizable", "Common.Controls.Actions.Reply") /// Report %@ internal static func reportUser(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Actions.ReportUser", String(describing: p1)) @@ -200,6 +202,28 @@ internal enum L10n { return L10n.tr("Localizable", "Common.Controls.Keyboard.Common.SwitchToTab", String(describing: p1)) } } + internal enum Timeline { + /// Next Status + internal static let nextStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.NextStatus") + /// Open Author Profile + internal static let openAuthorProfile = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenAuthorProfile") + /// Open Reblogger Profile + internal static let openRebloggerProfile = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenRebloggerProfile") + /// Open Status + internal static let openStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenStatus") + /// Preview Image + internal static let previewImage = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.PreviewImage") + /// Previous Status + internal static let previousStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.PreviousStatus") + /// Reply Status + internal static let replyStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ReplyStatus") + /// Toggle Content Warning + internal static let toggleContentWarning = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleContentWarning") + /// Toggle Status Favorite + internal static let toggleFavorite = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleFavorite") + /// Toggle Status Reblog + internal static let toggleReblog = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleReblog") + } } internal enum Status { /// content warning @@ -527,6 +551,16 @@ internal enum L10n { internal static let mentions = L10n.tr("Localizable", "Scene.Notification.Title.Mentions") } } + internal enum Preview { + internal enum Keyboard { + /// Close Preview + internal static let closePreview = L10n.tr("Localizable", "Scene.Preview.Keyboard.ClosePreview") + /// Show Next + internal static let showNext = L10n.tr("Localizable", "Scene.Preview.Keyboard.ShowNext") + /// Show Previous + internal static let showPrevious = L10n.tr("Localizable", "Scene.Preview.Keyboard.ShowPrevious") + } + } internal enum Profile { /// %@ posts internal static func subtitle(_ p1: Any) -> String { diff --git a/Mastodon/Preference/KeyboardPreference.swift b/Mastodon/Preference/KeyboardPreference.swift new file mode 100644 index 00000000..d1d70a16 --- /dev/null +++ b/Mastodon/Preference/KeyboardPreference.swift @@ -0,0 +1,20 @@ +// +// KeyboardPreference.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-20. +// + +import UIKit + +extension UserDefaults { + + @objc dynamic var backKeyCommandPressDate: Date? { + get { + register(defaults: [#function: Date().timeIntervalSinceReferenceDate]) + return Date(timeIntervalSinceReferenceDate: double(forKey: #function)) + } + set { self[#function] = newValue?.timeIntervalSinceReferenceDate } + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewKeyCommandNavigateable.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewKeyCommandNavigateable.swift new file mode 100644 index 00000000..3055b0d0 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewKeyCommandNavigateable.swift @@ -0,0 +1,204 @@ +// +// StatusProvider+KeyCommands.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-19. +// + +import os.log +import UIKit + +extension StatusTableViewCellDelegate where Self: StatusProvider & StatusTableViewControllerNavigateable { + + func keyCommandHandler(_ sender: UIKeyCommand) { + guard let rawValue = sender.propertyList as? String, + let navigation = StatusTableViewNavigation(rawValue: rawValue) else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, navigation.title) + switch navigation { + case .up: navigateStatus(direction: .up) + case .down: navigateStatus(direction: .down) + case .back: backTimeline() + case .openStatus: openStatus() + case .openAuthorProfile: openAuthorProfile() + case .openRebloggerProfile: openRebloggerProfile() + case .replyStatus: replyStatus() + case .toggleReblog: toggleReblog() + case .toggleFavorite: toggleFavorite() + case .toggleContentWarning: toggleContentWarning() + case .previewImage: previewImage() + } + } + +} + +// navigate status up/down +extension StatusTableViewCellDelegate where Self: StatusProvider & StatusTableViewControllerNavigateable { + + private func navigateStatus(direction: StatusTableViewNavigationDirection) { + if let indexPathForSelectedRow = tableView.indexPathForSelectedRow { + // navigate up/down on the current selected item + navigateToStatus(direction: direction, indexPath: indexPathForSelectedRow) + } else { + // set first visible item selected + navigateToFirstVisibleStatus() + } + } + + private func navigateToStatus(direction: StatusTableViewNavigationDirection, indexPath: IndexPath) { + guard let diffableDataSource = tableViewDiffableDataSource else { return } + let items = diffableDataSource.snapshot().itemIdentifiers + guard let selectedItem = diffableDataSource.itemIdentifier(for: indexPath), + let selectedItemIndex = items.firstIndex(of: selectedItem) else { + return + } + + let _navigateToItem: Item? = { + var index = selectedItemIndex + while 0.. 1 { + // drop first when visible not the first cell of table + visibleItems.removeFirst() + } + guard let item = visibleItems.first, let indexPath = diffableDataSource.indexPath(for: item) else { return } + let scrollPosition: UITableView.ScrollPosition = overrideNavigationScrollPosition ?? Self.navigateScrollPosition(tableView: tableView, indexPath: indexPath) + tableView.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition) + } + + static func validNavigateableItem(_ item: Item) -> Bool { + switch item { + case .homeTimelineIndex, + .status, + .root, .leaf, .reply: + return true + default: + return false + } + } + + // check is visible and not the first and last + static func navigateScrollPosition(tableView: UITableView, indexPath: IndexPath) -> UITableView.ScrollPosition { + let middleVisibleIndexPaths = (tableView.indexPathsForVisibleRows ?? []) + .sorted() + .dropFirst() + .dropLast() + guard middleVisibleIndexPaths.contains(indexPath) else { + return .top + } + guard middleVisibleIndexPaths.count > 2 else { + return .middle + } + return .none + } + +} + +// status coordinate +extension StatusTableViewCellDelegate where Self: StatusProvider & StatusTableViewControllerNavigateable { + + private func openStatus() { + guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } + StatusProviderFacade.coordinateToStatusThreadScene(for: .primary, provider: self, indexPath: indexPathForSelectedRow) + } + + private func backTimeline() { + UserDefaults.shared.backKeyCommandPressDate = Date() + navigationController?.popViewController(animated: true) + } + + private func openAuthorProfile() { + guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } + StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .primary, provider: self, indexPath: indexPathForSelectedRow) + } + + private func openRebloggerProfile() { + guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } + StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .secondary, provider: self, indexPath: indexPathForSelectedRow) + } + + private func replyStatus() { + guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } + StatusProviderFacade.responseToStatusReplyAction(provider: self, indexPath: indexPathForSelectedRow) + } + + private func previewImage() { + guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } + guard let provider = self as? (StatusProvider & MediaPreviewableViewController) else { return } + guard let cell = tableView.cellForRow(at: indexPathForSelectedRow), + let presentable = cell as? MosaicImageViewContainerPresentable else { return } + let mosaicImageView = presentable.mosaicImageViewContainer + guard let imageView = mosaicImageView.imageViews.first else { return } + StatusProviderFacade.coordinateToStatusMediaPreviewScene(provider: provider, cell: cell, mosaicImageView: mosaicImageView, didTapImageView: imageView, atIndex: 0) + } + +} + +// toggle +extension StatusTableViewCellDelegate where Self: StatusProvider & StatusTableViewControllerNavigateable { + + private func toggleReblog() { + guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } + StatusProviderFacade.responseToStatusReblogAction(provider: self, indexPath: indexPathForSelectedRow) + } + + private func toggleFavorite() { + guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } + StatusProviderFacade.responseToStatusLikeAction(provider: self, indexPath: indexPathForSelectedRow) + } + + private func toggleContentWarning() { + guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } + StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, indexPath: indexPathForSelectedRow) + } + +} + +extension StatusTableViewCellDelegate where Self: StatusProvider & StatusTableViewControllerNavigateable { + + var statusNavigationKeyCommands: [UIKeyCommand] { + StatusTableViewNavigation.allCases.map { navigation in + UIKeyCommand( + title: navigation.title, + image: nil, + action: #selector(Self.keyCommandHandlerRelay(_:)), + input: navigation.input, + modifierFlags: navigation.modifierFlags, + propertyList: navigation.propertyList, + alternates: [], + discoverabilityTitle: nil, + attributes: [], + state: .off + ) + } + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 56e9d474..8c75b955 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -25,6 +25,14 @@ extension StatusProviderFacade { ) } + static func coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider, indexPath: IndexPath) { + _coordinateToStatusAuthorProfileScene( + for: target, + provider: provider, + status: provider.status(for: nil, indexPath: indexPath) + ) + } + static func coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell) { _coordinateToStatusAuthorProfileScene( for: target, @@ -189,6 +197,13 @@ extension StatusProviderFacade { ) } + static func responseToStatusLikeAction(provider: StatusProvider, indexPath: IndexPath) { + _responseToStatusLikeAction( + provider: provider, + status: provider.status(for: nil, indexPath: indexPath) + ) + } + private static func _responseToStatusLikeAction(provider: StatusProvider, status: Future) { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { @@ -292,6 +307,13 @@ extension StatusProviderFacade { ) } + static func responseToStatusReblogAction(provider: StatusProvider, indexPath: IndexPath) { + _responseToStatusReblogAction( + provider: provider, + status: provider.status(for: nil, indexPath: indexPath) + ) + } + private static func _responseToStatusReblogAction(provider: StatusProvider, status: Future) { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { @@ -400,6 +422,13 @@ extension StatusProviderFacade { ) } + static func responseToStatusReplyAction(provider: StatusProvider, indexPath: IndexPath) { + _responseToStatusReplyAction( + provider: provider, + status: provider.status(for: nil, indexPath: indexPath) + ) + } + private static func _responseToStatusReplyAction(provider: StatusProvider, status: Future) { status .sink { [weak provider] status in @@ -450,6 +479,13 @@ extension StatusProviderFacade { ) } + static func responseToStatusContentWarningRevealAction(provider: StatusProvider, indexPath: IndexPath) { + _responseToStatusContentWarningRevealAction( + dependency: provider, + status: provider.status(for: nil, indexPath: indexPath) + ) + } + private static func _responseToStatusContentWarningRevealAction(dependency: NeedsDependency, status: Future) { status .compactMap { [weak dependency] status -> AnyPublisher? in diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift index e418569c..58ffd6e5 100644 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -7,8 +7,9 @@ import UIKit import AVKit +import GameController -// Check List Last Updated +// Check List Last Updated // - HomeViewController: 2021/4/30 // - FavoriteViewController: 2021/4/30 // - HashtagTimelineViewController: 2021/4/30 @@ -34,6 +35,12 @@ protocol StatusTableViewControllerAspect: UIViewController { extension StatusTableViewControllerAspect { /// [UI] hook to deselect row in the transitioning for the table view func aspectViewWillAppear(_ animated: Bool) { + if GCKeyboard.coalesced != nil, let backKeyCommandPressDate = UserDefaults.shared.backKeyCommandPressDate { + guard backKeyCommandPressDate.timeIntervalSinceNow <= -0.5 else { + // break if interval greater than 0.5s + return + } + } tableView.deselectRow(with: transitionCoordinator, animated: animated) } } diff --git a/Mastodon/Protocol/StatusTableViewControllerNavigateable.swift b/Mastodon/Protocol/StatusTableViewControllerNavigateable.swift new file mode 100644 index 00000000..cd88abe0 --- /dev/null +++ b/Mastodon/Protocol/StatusTableViewControllerNavigateable.swift @@ -0,0 +1,101 @@ +// +// StatusTableViewControllerNavigateable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-19. +// + +import os.log +import UIKit + +typealias StatusTableViewControllerNavigateable = StatusTableViewControllerNavigateableCore & StatusTableViewControllerNavigateableRelay + +protocol StatusTableViewControllerNavigateableCore: AnyObject { + var tableView: UITableView { get } + var overrideNavigationScrollPosition: UITableView.ScrollPosition? { get set } + func keyCommandHandler(_ sender: UIKeyCommand) +} + +extension StatusTableViewControllerNavigateableCore { + var overrideNavigationScrollPosition: UITableView.ScrollPosition? { + get { return nil } + set { } + } +} + +@objc protocol StatusTableViewControllerNavigateableRelay: AnyObject { + func keyCommandHandlerRelay(_ sender: UIKeyCommand) +} + +enum StatusTableViewNavigationDirection { + case up + case down +} + + +enum StatusTableViewNavigation: String, CaseIterable { + case up + case down + case back // pop + case openStatus + case openAuthorProfile + case openRebloggerProfile + case replyStatus + case toggleReblog + case toggleFavorite + case toggleContentWarning + case previewImage + + var title: String { + switch self { + case .up: return L10n.Common.Controls.Keyboard.Timeline.previousStatus + case .down: return L10n.Common.Controls.Keyboard.Timeline.nextStatus + case .back: return L10n.Common.Controls.Actions.back + case .openStatus: return L10n.Common.Controls.Keyboard.Timeline.openStatus + case .openAuthorProfile: return L10n.Common.Controls.Keyboard.Timeline.openAuthorProfile + case .openRebloggerProfile: return L10n.Common.Controls.Keyboard.Timeline.openRebloggerProfile + case .replyStatus: return L10n.Common.Controls.Keyboard.Timeline.replyStatus + case .toggleReblog: return L10n.Common.Controls.Keyboard.Timeline.toggleReblog + case .toggleFavorite: return L10n.Common.Controls.Keyboard.Timeline.toggleFavorite + case .toggleContentWarning: return L10n.Common.Controls.Keyboard.Timeline.toggleContentWarning + case .previewImage: return L10n.Common.Controls.Keyboard.Timeline.previewImage + } + } + + // UIKeyCommand input + var input: String { + switch self { + case .up: return "k" + case .down: return "j" + case .back: return "h" + case .openStatus: return "l" // little "L" + case .openAuthorProfile: return "p" + case .openRebloggerProfile: return "p" // + option + case .replyStatus: return "n" // + shift + command + case .toggleReblog: return "r" + case .toggleFavorite: return "f" + case .toggleContentWarning: return "o" + case .previewImage: return "i" + } + } + + var modifierFlags: UIKeyModifierFlags { + switch self { + case .up: return [] + case .down: return [] + case .back: return [] + case .openStatus: return [] + case .openAuthorProfile: return [] + case .openRebloggerProfile: return [.alternate] + case .replyStatus: return [.shift, .alternate] + case .toggleReblog: return [] + case .toggleFavorite: return [] + case .toggleContentWarning: return [] + case .previewImage: return [] + } + } + + var propertyList: Any { + return rawValue + } +} diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index 5b3a62ff..55197f9b 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -34,6 +34,7 @@ Please check your internet connection."; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; "Common.Controls.Actions.Preview" = "Preview"; "Common.Controls.Actions.Remove" = "Remove"; +"Common.Controls.Actions.Reply" = "Reply"; "Common.Controls.Actions.ReportUser" = "Report %@"; "Common.Controls.Actions.Save" = "Save"; "Common.Controls.Actions.SavePhoto" = "Save photo"; @@ -67,6 +68,16 @@ Please check your internet connection."; "Common.Controls.Keyboard.Common.OpenSettings" = "Open Settings"; "Common.Controls.Keyboard.Common.ShowFavorites" = "Show Favorites"; "Common.Controls.Keyboard.Common.SwitchToTab" = "Switch to %@"; +"Common.Controls.Keyboard.Timeline.NextStatus" = "Next Status"; +"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Open Author Profile"; +"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Open Reblogger Profile"; +"Common.Controls.Keyboard.Timeline.OpenStatus" = "Open Status"; +"Common.Controls.Keyboard.Timeline.PreviewImage" = "Preview Image"; +"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Previous Status"; +"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Reply Status"; +"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "Toggle Content Warning"; +"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toggle Status Favorite"; +"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toggle Status Reblog"; "Common.Controls.Status.Actions.Favorite" = "Favorite"; "Common.Controls.Status.Actions.Menu" = "Menu"; "Common.Controls.Status.Actions.Reblog" = "Reblog"; @@ -178,6 +189,9 @@ tap the link to confirm your account."; "Scene.Notification.Action.Reblog" = "rebloged your post"; "Scene.Notification.Title.Everything" = "Everything"; "Scene.Notification.Title.Mentions" = "Mentions"; +"Scene.Preview.Keyboard.ClosePreview" = "Close Preview"; +"Scene.Preview.Keyboard.ShowNext" = "Show Next"; +"Scene.Preview.Keyboard.ShowPrevious" = "Show Previous"; "Scene.Profile.Dashboard.Accessibility.CountFollowers" = "%ld followers"; "Scene.Profile.Dashboard.Accessibility.CountFollowing" = "%ld following"; "Scene.Profile.Dashboard.Accessibility.CountPosts" = "%ld posts"; diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 5b3a62ff..55197f9b 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -34,6 +34,7 @@ Please check your internet connection."; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; "Common.Controls.Actions.Preview" = "Preview"; "Common.Controls.Actions.Remove" = "Remove"; +"Common.Controls.Actions.Reply" = "Reply"; "Common.Controls.Actions.ReportUser" = "Report %@"; "Common.Controls.Actions.Save" = "Save"; "Common.Controls.Actions.SavePhoto" = "Save photo"; @@ -67,6 +68,16 @@ Please check your internet connection."; "Common.Controls.Keyboard.Common.OpenSettings" = "Open Settings"; "Common.Controls.Keyboard.Common.ShowFavorites" = "Show Favorites"; "Common.Controls.Keyboard.Common.SwitchToTab" = "Switch to %@"; +"Common.Controls.Keyboard.Timeline.NextStatus" = "Next Status"; +"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Open Author Profile"; +"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Open Reblogger Profile"; +"Common.Controls.Keyboard.Timeline.OpenStatus" = "Open Status"; +"Common.Controls.Keyboard.Timeline.PreviewImage" = "Preview Image"; +"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Previous Status"; +"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Reply Status"; +"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "Toggle Content Warning"; +"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toggle Status Favorite"; +"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toggle Status Reblog"; "Common.Controls.Status.Actions.Favorite" = "Favorite"; "Common.Controls.Status.Actions.Menu" = "Menu"; "Common.Controls.Status.Actions.Reblog" = "Reblog"; @@ -178,6 +189,9 @@ tap the link to confirm your account."; "Scene.Notification.Action.Reblog" = "rebloged your post"; "Scene.Notification.Title.Everything" = "Everything"; "Scene.Notification.Title.Mentions" = "Mentions"; +"Scene.Preview.Keyboard.ClosePreview" = "Close Preview"; +"Scene.Preview.Keyboard.ShowNext" = "Show Next"; +"Scene.Preview.Keyboard.ShowPrevious" = "Show Previous"; "Scene.Profile.Dashboard.Accessibility.CountFollowers" = "%ld followers"; "Scene.Profile.Dashboard.Accessibility.CountFollowing" = "%ld following"; "Scene.Profile.Dashboard.Accessibility.CountPosts" = "%ld posts"; diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 638aa766..5a30f659 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -339,3 +339,16 @@ extension HashtagTimelineViewController: StatusTableViewCellDelegate { weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } func parent() -> UIViewController { return self } } + +extension HashtagTimelineViewController { + override var keyCommands: [UIKeyCommand]? { + return statusNavigationKeyCommands + } +} + +// MARK: - StatusTableViewControllerNavigateable +extension HashtagTimelineViewController: StatusTableViewControllerNavigateable { + @objc func keyCommandHandlerRelay(_ sender: UIKeyCommand) { + keyCommandHandler(sender) + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 6db1c26f..47f2ae04 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -537,3 +537,16 @@ extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate } } } + +extension HomeTimelineViewController { + override var keyCommands: [UIKeyCommand]? { + return statusNavigationKeyCommands + } +} + +// MARK: - StatusTableViewControllerNavigateable +extension HomeTimelineViewController: StatusTableViewControllerNavigateable { + @objc func keyCommandHandlerRelay(_ sender: UIKeyCommand) { + keyCommandHandler(sender) + } +} diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index eee56e4d..c9cbf744 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -240,3 +240,74 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { } } + +extension MediaPreviewViewController { + + var closeKeyCommand: UIKeyCommand { + UIKeyCommand( + title: L10n.Scene.Preview.Keyboard.closePreview, + image: nil, + action: #selector(MediaPreviewViewController.closePreviewKeyCommandHandler(_:)), + input: "i", + modifierFlags: [], + propertyList: nil, + alternates: [], + discoverabilityTitle: nil, + attributes: [], + state: .off + ) + } + + var showNextKeyCommand: UIKeyCommand { + UIKeyCommand( + title: L10n.Scene.Preview.Keyboard.closePreview, + image: nil, + action: #selector(MediaPreviewViewController.showNextKeyCommandHandler(_:)), + input: "j", + modifierFlags: [], + propertyList: nil, + alternates: [], + discoverabilityTitle: nil, + attributes: [], + state: .off + ) + } + + + var showPreviousKeyCommand: UIKeyCommand { + UIKeyCommand( + title: L10n.Scene.Preview.Keyboard.closePreview, + image: nil, + action: #selector(MediaPreviewViewController.showPreviousKeyCommandHandler(_:)), + input: "k", + modifierFlags: [], + propertyList: nil, + alternates: [], + discoverabilityTitle: nil, + attributes: [], + state: .off + ) + } + + + override var keyCommands: [UIKeyCommand] { + return [ + closeKeyCommand, + showNextKeyCommand, + showPreviousKeyCommand, + ] + } + + @objc private func closePreviewKeyCommandHandler(_ sender: UIKeyCommand) { + dismiss(animated: true, completion: nil) + } + + @objc private func showNextKeyCommandHandler(_ sender: UIKeyCommand) { + pagingViewConttroller.scrollToPage(.next, animated: true, completion: nil) + } + + @objc private func showPreviousKeyCommandHandler(_ sender: UIKeyCommand) { + pagingViewConttroller.scrollToPage(.previous, animated: true, completion: nil) + } +} + diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index 01d76f4b..0a91c2af 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -173,3 +173,16 @@ extension FavoriteViewController: LoadMoreConfigurableTableViewContainer { var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } } + +extension FavoriteViewController { + override var keyCommands: [UIKeyCommand]? { + return statusNavigationKeyCommands + } +} + +// MARK: - StatusTableViewControllerNavigateable +extension FavoriteViewController: StatusTableViewControllerNavigateable { + @objc func keyCommandHandlerRelay(_ sender: UIKeyCommand) { + keyCommandHandler(sender) + } +} diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index c60c2040..caac9a18 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -440,6 +440,13 @@ extension ProfileViewController { viewModel.isEditing .handleEvents(receiveOutput: { [weak self] isEditing in guard let self = self else { return } + // set firset responder for key command + if !isEditing { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.profileSegmentedViewController.pagingViewController.becomeFirstResponder() + } + } + // dismiss keyboard if needs if !isEditing { self.view.endEditing(true) } @@ -860,7 +867,6 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView) { - } } @@ -869,3 +875,4 @@ extension ProfileViewController: ProfileHeaderViewDelegate { extension ProfileViewController: ScrollViewContainer { var scrollView: UIScrollView { return overlayScrollView } } + diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift index 3b00b1c6..f7419862 100644 --- a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift +++ b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift @@ -26,9 +26,15 @@ final class ProfilePagingViewController: TabmanViewController { super.pageboyViewController(pageboyViewController, didScrollToPageAt: index, direction: direction, animated: animated) let viewController = viewModel.viewControllers[index] + (viewController as? StatusTableViewControllerNavigateable)?.overrideNavigationScrollPosition = .top pagingDelegate?.profilePagingViewController(self, didScrollToPostCustomScrollViewContainerController: viewController, atIndex: index) } + // make key commands works + override var canBecomeFirstResponder: Bool { + return true + } + deinit { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } @@ -43,5 +49,23 @@ extension ProfilePagingViewController { view.backgroundColor = .clear dataSource = viewModel } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + becomeFirstResponder() + } } + +extension ProfilePagingViewController { + + override var keyCommands: [UIKeyCommand]? { + return currentViewController?.keyCommands + } + + @objc func keyCommandHandlerRelay(_ sender: UIKeyCommand) { + (currentViewController as? StatusTableViewControllerNavigateable)?.keyCommandHandlerRelay(sender) + } + +} diff --git a/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift b/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift index 06eaab3f..5d5241c5 100644 --- a/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift +++ b/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift @@ -14,6 +14,7 @@ final class ProfileSegmentedViewController: UIViewController { deinit { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } + } extension ProfileSegmentedViewController { diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 503ce04c..de3afabb 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -14,7 +14,7 @@ import GameplayKit // TODO: adopt MediaPreviewableViewController final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { - + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -34,6 +34,8 @@ final class UserTimelineViewController: UIViewController, NeedsDependency, Media return tableView }() + var overrideNavigationScrollPosition: UITableView.ScrollPosition? = nil + deinit { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } @@ -185,3 +187,22 @@ extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer { var loadMoreConfigurableTableView: UITableView { return tableView } var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } } + +extension UserTimelineViewController { + override var keyCommands: [UIKeyCommand]? { + return [ + UIKeyCommand(title: "Test", image: nil, action: #selector(UserTimelineViewController.test(_:)), input: "t", modifierFlags: [], propertyList: nil, alternates: [], discoverabilityTitle: nil, attributes: [], state: .off) + ] + statusNavigationKeyCommands + } + + @objc private func test(_ sender: UIKeyCommand) { + + } +} + +// MARK: - StatusTableViewControllerNavigateable +extension UserTimelineViewController: StatusTableViewControllerNavigateable { + @objc func keyCommandHandlerRelay(_ sender: UIKeyCommand) { + keyCommandHandler(sender) + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index ca6a9e7e..32de2c3e 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -56,7 +56,6 @@ final class StatusTableViewCell: UITableViewCell, StatusCell { var disposeBag = Set() var pollCountdownSubscription: AnyCancellable? var observations = Set() - private var selectionBackgroundViewObservation: NSKeyValueObservation? let statusView = StatusView() let threadMetaStackView = UIStackView() diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index 6c801ae4..10dd291a 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -227,3 +227,16 @@ extension ThreadViewController: ThreadReplyLoaderTableViewCellDelegate { } } } + +extension ThreadViewController { + override var keyCommands: [UIKeyCommand]? { + return statusNavigationKeyCommands + } +} + +// MARK: - StatusTableViewControllerNavigateable +extension ThreadViewController: StatusTableViewControllerNavigateable { + @objc func keyCommandHandlerRelay(_ sender: UIKeyCommand) { + keyCommandHandler(sender) + } +}