diff --git a/Localization/StringsConvertor/input/Base.lproj/app.json b/Localization/StringsConvertor/input/Base.lproj/app.json index 4a680e512..8658e52d8 100644 --- a/Localization/StringsConvertor/input/Base.lproj/app.json +++ b/Localization/StringsConvertor/input/Base.lproj/app.json @@ -470,6 +470,10 @@ }, "home_timeline": { "title": "Home", + "timeline_menu": { + "following": "Following", + "local_community": "Local" + }, "navigation_bar_state": { "offline": "Offline", "new_posts": "See new posts", diff --git a/Localization/app.json b/Localization/app.json index 4a680e512..8658e52d8 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -470,6 +470,10 @@ }, "home_timeline": { "title": "Home", + "timeline_menu": { + "following": "Following", + "local_community": "Local" + }, "navigation_bar_state": { "offline": "Offline", "new_posts": "See new posts", diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift index 3a0457983..45ae33487 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift @@ -12,7 +12,7 @@ extension HomeTimelineViewController: DataSourceProvider { func item(from source: DataSourceItem.Source) async -> DataSourceItem? { var _indexPath = source.indexPath if _indexPath == nil, let cell = source.tableViewCell { - _indexPath = await self.indexPath(for: cell) + _indexPath = self.indexPath(for: cell) } guard let indexPath = _indexPath else { return nil } @@ -37,8 +37,7 @@ extension HomeTimelineViewController: DataSourceProvider { viewModel?.dataController.update(status: status, intent: intent) } - @MainActor - private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + private func indexPath(for cell: UITableViewCell) -> IndexPath? { return tableView.indexPath(for: cell) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 9666bf12e..100b41300 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -43,13 +43,42 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media emptyView.isLayoutMarginsRelativeArrangement = true return emptyView }() - + let titleView = HomeTimelineNavigationBarTitleView() - + + lazy var timelineSelectorButton = { + let button = UIButton(type: .custom) + button.setAttributedTitle( + .init(string: "Following", attributes: [ + .font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + ]), + for: .normal) + + let imageConfiguration = UIImage.SymbolConfiguration(paletteColors: [.secondaryLabel, .secondarySystemFill]) + .applying(UIImage.SymbolConfiguration(textStyle: .subheadline)) + .applying(UIImage.SymbolConfiguration(pointSize: 16, weight: .bold, scale: .medium)) + + button.configuration = { + var config = UIButton.Configuration.plain() + config.contentInsets = .init(top: 0, leading: 0, bottom: 0, trailing: 0) + config.imagePadding = 8 + config.image = UIImage(systemName: "chevron.down.circle.fill", withConfiguration: imageConfiguration) + return config + }() + + button.semanticContentAttribute = + UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? + .forceLeftToRight : + .forceRightToLeft + button.showsMenuAsPrimaryAction = true + button.menu = generateTimeSelectorMenu() + return button + }() + let settingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem() - barButtonItem.tintColor = SystemTheme.tintColor - barButtonItem.image = Asset.ObjectsAndTools.gear.image.withRenderingMode(.alwaysTemplate) + barButtonItem.tintColor = Asset.Colors.Brand.blurple.color + barButtonItem.image = UIImage(systemName: "gear") barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.settings return barButtonItem }() @@ -72,6 +101,52 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media }() let refreshControl = RefreshControl() + + private func generateTimeSelectorMenu() -> UIMenu { + let showFollowingAction = UIAction(title: L10n.Scene.HomeTimeline.TimelineMenu.following, image: .init(systemName: "house")) { [weak self] _ in + guard let self, let viewModel = self.viewModel else { return } + + viewModel.timelineContext = .home + viewModel.dataController.records = [] + + viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.ContextSwitch.self) + timelineSelectorButton.setAttributedTitle( + .init(string: L10n.Scene.HomeTimeline.TimelineMenu.following, attributes: [ + .font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + ]), + for: .normal) + + timelineSelectorButton.sizeToFit() + timelineSelectorButton.menu = generateTimeSelectorMenu() + } + + let showLocalTimelineAction = UIAction(title: L10n.Scene.HomeTimeline.TimelineMenu.localCommunity, image: .init(systemName: "building.2")) { [weak self] action in + guard let self, let viewModel = self.viewModel else { return } + + viewModel.timelineContext = .public + viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.ContextSwitch.self) + timelineSelectorButton.setAttributedTitle( + .init(string: L10n.Scene.HomeTimeline.TimelineMenu.localCommunity, attributes: [ + .font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + ]), + for: .normal) + timelineSelectorButton.sizeToFit() + timelineSelectorButton.menu = generateTimeSelectorMenu() + } + + if let viewModel { + switch viewModel.timelineContext { + case .public: + showLocalTimelineAction.state = .on + showFollowingAction.state = .off + case .home: + showLocalTimelineAction.state = .off + showFollowingAction.state = .on + } + } + + return UIMenu(children: [showFollowingAction, showLocalTimelineAction]) + } } extension HomeTimelineViewController { @@ -79,7 +154,7 @@ extension HomeTimelineViewController { override func viewDidLoad() { super.viewDidLoad() - title = L10n.Scene.HomeTimeline.title + title = "" view.backgroundColor = .secondarySystemBackground viewModel?.$displaySettingBarButtonItem @@ -94,8 +169,10 @@ extension HomeTimelineViewController { settingBarButtonItem.target = self settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) - navigationItem.titleView = titleView - titleView.delegate = self + self.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: timelineSelectorButton) + +// navigationItem.titleView = titleView +// titleView.delegate = self viewModel?.homeTimelineNavigationBarTitleViewModel.state .removeDuplicates() @@ -512,8 +589,12 @@ extension HomeTimelineViewController: UITableViewDelegate, AutoGenerateTableView // sourcery:end func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let viewModel, + let currentState = viewModel.loadLatestStateMachine.currentState as? HomeTimelineViewModel.LoadLatestState, + (currentState.self is HomeTimelineViewModel.LoadLatestState.ContextSwitch) == false else { return } + if indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 { - viewModel?.timelineDidReachEnd() + viewModel.timelineDidReachEnd() } } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index c9e81737f..9d2ae90fc 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -40,6 +40,9 @@ extension HomeTimelineViewModel { guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } + guard let currentState = loadLatestStateMachine.currentState as? HomeTimelineViewModel.LoadLatestState, + (currentState.self is HomeTimelineViewModel.LoadLatestState.ContextSwitch) == false else { return } + Task { @MainActor in let oldSnapshot = diffableDataSource.snapshot() var newSnapshot: NSDiffableDataSourceSnapshot = { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index a88bafebe..aca9f5f45 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -28,11 +28,6 @@ extension HomeTimelineViewModel { self.viewModel = viewModel } - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - viewModel?.loadLatestStateMachinePublisher.send(self) - } - @MainActor func enter(state: LoadLatestState.Type) { stateMachine?.enter(state) @@ -75,7 +70,33 @@ extension HomeTimelineViewModel.LoadLatestState { class Idle: HomeTimelineViewModel.LoadLatestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self || stateClass == LoadingManually.self + return stateClass == Loading.self || stateClass == LoadingManually.self || stateClass == ContextSwitch.self + } + } + + class ContextSwitch: HomeTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == LoadingManually.self || stateClass == ContextSwitch.self + } + + override func didEnter(from previousState: GKState?) { + guard let viewModel else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { + assertionFailure() + return + } + + OperationQueue.main.addOperation { + viewModel.dataController.records = [] + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems([.topLoader], toSection: .main) + diffableDataSource.apply(snapshot) { [weak self] in + guard let self else { return } + + self.stateMachine?.enter(Loading.self) + } + } } } @@ -83,7 +104,7 @@ extension HomeTimelineViewModel.LoadLatestState { super.didEnter(from: previousState) guard let viewModel else { return } - + let latestFeedRecords = viewModel.dataController.records.prefix(APIService.onceRequestStatusMaxCount) Task { @@ -93,10 +114,20 @@ extension HomeTimelineViewModel.LoadLatestState { do { await AuthenticationServiceProvider.shared.fetchAccounts(apiService: viewModel.context.apiService) - let response = try await viewModel.context.apiService.homeTimeline( - authenticationBox: viewModel.authContext.mastodonAuthenticationBox - ) - + let response: Mastodon.Response.Content<[Mastodon.Entity.Status]> + + switch viewModel.timelineContext { + case .home: + response = try await viewModel.context.apiService.homeTimeline( + authenticationBox: viewModel.authContext.mastodonAuthenticationBox + ) + case .public: + response = try await viewModel.context.apiService.publicTimeline( + query: .init(local: true), + authenticationBox: viewModel.authContext.mastodonAuthenticationBox + ) + } + await enter(state: Idle.self) viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.finished) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index 3e993141b..668095b24 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -53,9 +53,7 @@ extension HomeTimelineViewModel.LoadOldestState { } Task { - let _maxID = lastFeedRecord.status?.id - - guard let maxID = _maxID else { + guard let maxID = lastFeedRecord.status?.id else { await self.enter(state: Fail.self) return } @@ -63,11 +61,21 @@ extension HomeTimelineViewModel.LoadOldestState { do { await AuthenticationServiceProvider.shared.fetchAccounts(apiService: viewModel.context.apiService) - let response = try await viewModel.context.apiService.homeTimeline( - maxID: maxID, - authenticationBox: viewModel.authContext.mastodonAuthenticationBox - ) - + let response: Mastodon.Response.Content<[Mastodon.Entity.Status]> + + switch viewModel.timelineContext { + case .home: + response = try await viewModel.context.apiService.homeTimeline( + maxID: maxID, + authenticationBox: viewModel.authContext.mastodonAuthenticationBox + ) + case .public: + response = try await viewModel.context.apiService.publicTimeline( + query: .init(local: true, maxID: maxID), + authenticationBox: viewModel.authContext.mastodonAuthenticationBox + ) + } + let statuses = response.value // enter no more state when no new statuses if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index f4efba2a3..a67d44f8d 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -18,7 +18,6 @@ import MastodonUI import MastodonSDK final class HomeTimelineViewModel: NSObject { - var disposeBag = Set() var observations = Set() @@ -35,7 +34,8 @@ final class HomeTimelineViewModel: NSObject { @Published var scrollPositionRecord: ScrollPositionRecord? = nil @Published var displaySettingBarButtonItem = true @Published var hasPendingStatusEditReload = false - + var timelineContext: MastodonFeed.Kind.TimelineContext = .home + weak var tableView: UITableView? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? @@ -55,11 +55,11 @@ final class HomeTimelineViewModel: NSObject { LoadLatestState.LoadingManually(viewModel: self), LoadLatestState.Fail(viewModel: self), LoadLatestState.Idle(viewModel: self), + LoadLatestState.ContextSwitch(viewModel: self), ]) stateMachine.enter(LoadLatestState.Initial.self) return stateMachine }() - lazy var loadLatestStateMachinePublisher = CurrentValueSubject(nil) // bottom loader private(set) lazy var loadOldestStateMachine: GKStateMachine = { @@ -74,10 +74,9 @@ final class HomeTimelineViewModel: NSObject { stateMachine.enter(LoadOldestState.Initial.self) return stateMachine }() - lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) var cellFrameCache = NSCache() - + init(context: AppContext, authContext: AuthContext) { self.context = context self.authContext = authContext @@ -115,7 +114,7 @@ final class HomeTimelineViewModel: NSObject { }) .store(in: &disposeBag) - self.dataController.loadInitial(kind: .home) + self.dataController.loadInitial(kind: .home(timeline: timelineContext)) } } @@ -129,7 +128,7 @@ extension HomeTimelineViewModel { extension HomeTimelineViewModel { func timelineDidReachEnd() { - dataController.loadNext(kind: .home) + dataController.loadNext(kind: .home(timeline: timelineContext)) } } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 71b8b6a21..0e8500401 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -55,7 +55,7 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi private(set) lazy var settingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem( - image: Asset.ObjectsAndTools.gear.image.withRenderingMode(.alwaysTemplate), + image: UIImage(systemName: "gear")?.withRenderingMode(.alwaysTemplate), style: .plain, target: self, action: #selector(ProfileViewController.settingBarButtonItemPressed(_:)) diff --git a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift index 6bf2dcf05..793e1d436 100644 --- a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift +++ b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift @@ -164,12 +164,27 @@ final public class FeedDataController { } private extension FeedDataController { - func load(kind: MastodonFeed.Kind, maxID: MastodonStatus.ID?) async throws -> [MastodonFeed] { + func load(kind: MastodonFeed.Kind, maxID: MastodonStatus.ID?) async throws -> [MastodonFeed] { switch kind { - case .home: + case .home(let timeline): await context.authenticationService.authenticationServiceProvider.fetchAccounts(apiService: context.apiService) - return try await context.apiService.homeTimeline(maxID: maxID, authenticationBox: authContext.mastodonAuthenticationBox) - .value.map { .fromStatus(.fromEntity($0), kind: .home) } + + let response: Mastodon.Response.Content<[Mastodon.Entity.Status]> + + switch timeline { + case .home: + response = try await context.apiService.homeTimeline( + maxID: maxID, + authenticationBox: authContext.mastodonAuthenticationBox + ) + case .public: + response = try await context.apiService.publicTimeline( + query: .init(local: true, maxID: maxID), + authenticationBox: authContext.mastodonAuthenticationBox + ) + } + + return response.value.map { .fromStatus(.fromEntity($0), kind: .home) } case .notificationAll: return try await getFeeds(with: .everything) case .notificationMentions: diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 63d7a9945..6fddef9d2 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -859,6 +859,12 @@ public enum L10n { public static let logoLabel = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoLabel", fallback: "Mastodon") } } + public enum TimelineMenu { + /// Following + public static let following = L10n.tr("Localizable", "Scene.HomeTimeline.TimelineMenu.Following", fallback: "Following") + /// Local + public static let localCommunity = L10n.tr("Localizable", "Scene.HomeTimeline.TimelineMenu.LocalCommunity", fallback: "Local") + } } public enum Login { /// Log you in on the server you created your account on. diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index 4287c248d..60fb82e8f 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -304,6 +304,8 @@ uploaded to Mastodon."; "Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; "Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; "Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post..."; +"Scene.HomeTimeline.TimelineMenu.Following" = "Following"; +"Scene.HomeTimeline.TimelineMenu.LocalCommunity" = "Local"; "Scene.HomeTimeline.Title" = "Home"; "Scene.Login.ServerSearchField.Placeholder" = "Enter URL or search for your server"; "Scene.Login.Subtitle" = "Log you in on the server you created your account on."; diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift index 3bd5c940d..e512143fe 100644 --- a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift +++ b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift @@ -6,9 +6,15 @@ import CoreDataStack public final class MastodonFeed { public enum Kind { - case home + + case home(timeline: TimelineContext) case notificationAll case notificationMentions + + public enum TimelineContext { + case home + case `public` + } } public let id: String