From 213ef94ec553e3c8d43ca1aac041c9fe600b33d9 Mon Sep 17 00:00:00 2001 From: nyaxix <97578718+protolimit@users.noreply.github.com> Date: Fri, 29 Jul 2022 15:31:38 -0500 Subject: [PATCH] Add bookmarking and bookmarks view Based heavily on the work for favorites. Adds bookmarking functionality to the application. The status view has been updated to include a bookmark button that can bookmark/unbookmark a status. The profile page has been updated to include a button in the header to navigate to a page that lists your bookmarks. --- Mastodon.xcodeproj/project.pbxproj | 36 ++++ Mastodon/Coordinator/SceneCoordinator.swift | 5 + .../Provider/DataSourceFacade+Bookmark.swift | 26 +++ .../Provider/DataSourceFacade+Status.swift | 6 + ...arkViewController+DataSourceProvider.swift | 34 ++++ .../Bookmark/BookmarkViewController.swift | 151 ++++++++++++++ .../Bookmark/BookmarkViewModel+Diffable.swift | 65 ++++++ .../Bookmark/BookmarkViewModel+State.swift | 191 ++++++++++++++++++ .../Profile/Bookmark/BookmarkViewModel.swift | 58 ++++++ .../Scene/Profile/ProfileViewController.swift | 18 ++ .../Content/StatusView+Configuration.swift | 13 ++ .../APIService/APIService+Bookmark.swift | 142 +++++++++++++ .../bookmark.fill.imageset/Contents.json | 15 ++ .../bookmark.fill.imageset/bookmark-solid.pdf | Bin 0 -> 1200 bytes .../bookmark.imageset/Contents.json | 15 ++ .../bookmark.imageset/bookmark-regular.pdf | Bin 0 -> 1226 bytes .../MastodonAsset/Generated/Assets.swift | 2 + .../Generated/Strings.swift | 8 + .../Resources/en.lproj/Localizable.strings | 5 +- .../API/Mastodon+API+Bookmarks.swift | 136 +++++++++++++ .../MastodonSDK/API/Mastodon+API.swift | 1 + .../View/Content/StatusView+ViewModel.swift | 8 + .../View/Control/ActionToolbarContainer.swift | 37 +++- 23 files changed, 969 insertions(+), 3 deletions(-) create mode 100644 Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift create mode 100644 Mastodon/Scene/Profile/Bookmark/BookmarkViewController+DataSourceProvider.swift create mode 100644 Mastodon/Scene/Profile/Bookmark/BookmarkViewController.swift create mode 100644 Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift create mode 100644 Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+State.swift create mode 100644 Mastodon/Scene/Profile/Bookmark/BookmarkViewModel.swift create mode 100644 Mastodon/Service/APIService/APIService+Bookmark.swift create mode 100644 MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.fill.imageset/Contents.json create mode 100755 MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.fill.imageset/bookmark-solid.pdf create mode 100644 MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.imageset/Contents.json create mode 100755 MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.imageset/bookmark-regular.pdf create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Bookmarks.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 04c6b5c8f..4106af7de 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -107,6 +107,13 @@ 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; + 6213AF5828939C4800BCADB6 /* APIService+Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213AF5728939C4700BCADB6 /* APIService+Bookmark.swift */; }; + 6213AF5A28939C8400BCADB6 /* BookmarkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213AF5928939C8400BCADB6 /* BookmarkViewModel.swift */; }; + 6213AF5C28939C8A00BCADB6 /* BookmarkViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213AF5B28939C8A00BCADB6 /* BookmarkViewModel+State.swift */; }; + 6213AF5E2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213AF5D2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift */; }; + 62FD27D12893707600B205C5 /* BookmarkViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FD27D02893707600B205C5 /* BookmarkViewController.swift */; }; + 62FD27D32893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FD27D22893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift */; }; + 62FD27D52893708A00B205C5 /* BookmarkViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FD27D42893708A00B205C5 /* BookmarkViewModel+Diffable.swift */; }; 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */; settings = {ATTRIBUTES = (codegen, ); }; }; DB0009A726AEE5DC009B9D2D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */; }; @@ -824,6 +831,13 @@ 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+History.swift"; sourceTree = ""; }; 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = ""; }; 6130CBE4B26E3C976ACC1688 /* Pods-ShareActionExtension.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareActionExtension.asdk - debug.xcconfig"; path = "Target Support Files/Pods-ShareActionExtension/Pods-ShareActionExtension.asdk - debug.xcconfig"; sourceTree = ""; }; + 6213AF5728939C4700BCADB6 /* APIService+Bookmark.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Bookmark.swift"; sourceTree = ""; }; + 6213AF5928939C8400BCADB6 /* BookmarkViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkViewModel.swift; sourceTree = ""; }; + 6213AF5B28939C8A00BCADB6 /* BookmarkViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BookmarkViewModel+State.swift"; sourceTree = ""; }; + 6213AF5D2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Bookmark.swift"; sourceTree = ""; }; + 62FD27D02893707600B205C5 /* BookmarkViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkViewController.swift; sourceTree = ""; }; + 62FD27D22893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookmarkViewController+DataSourceProvider.swift"; sourceTree = ""; }; + 62FD27D42893708A00B205C5 /* BookmarkViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookmarkViewModel+Diffable.swift"; sourceTree = ""; }; 63EF9E6E5B575CD2A8B0475D /* Pods-AppShared.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.profile.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.profile.xcconfig"; sourceTree = ""; }; 728DE51ADA27C395C6E1BAB5 /* Pods-Mastodon-MastodonUITests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.profile.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.profile.xcconfig"; sourceTree = ""; }; 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = ""; }; @@ -1934,6 +1948,18 @@ path = Webview; sourceTree = ""; }; + 62047EBE28874C8F00A3BA5D /* Bookmark */ = { + isa = PBXGroup; + children = ( + 62FD27D02893707600B205C5 /* BookmarkViewController.swift */, + 62FD27D22893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift */, + 6213AF5928939C8400BCADB6 /* BookmarkViewModel.swift */, + 62FD27D42893708A00B205C5 /* BookmarkViewModel+Diffable.swift */, + 6213AF5B28939C8A00BCADB6 /* BookmarkViewModel+State.swift */, + ); + path = Bookmark; + sourceTree = ""; + }; DB01409B25C40BB600F9F3CF /* Onboarding */ = { isa = PBXGroup; children = ( @@ -2332,6 +2358,7 @@ DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */, DB9D7C20269824B80054B3DF /* APIService+Filter.swift */, + 6213AF5728939C4700BCADB6 /* APIService+Bookmark.swift */, ); path = APIService; sourceTree = ""; @@ -2678,6 +2705,7 @@ isa = PBXGroup; children = ( DB697DDC278F521D004EF2F7 /* DataSourceFacade.swift */, + 6213AF5D2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift */, DB697DE0278F5296004EF2F7 /* DataSourceFacade+Model.swift */, DB697DDE278F524F004EF2F7 /* DataSourceFacade+Profile.swift */, DB8F7075279E954700E1225B /* DataSourceFacade+Follow.swift */, @@ -3024,6 +3052,7 @@ DB9D6C0825E4F5A60051B173 /* Profile */ = { isa = PBXGroup; children = ( + 62047EBE28874C8F00A3BA5D /* Bookmark */, DBB525462611ED57002F1F29 /* Header */, DBB525262611EBDA002F1F29 /* Paging */, DBB5253B2611ECF5002F1F29 /* Timeline */, @@ -3953,6 +3982,7 @@ DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */, DB6746E7278ED633008A6B94 /* MastodonAuthenticationBox.swift in Sources */, DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */, + 62FD27D32893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift in Sources */, DB1D843426579931000346B3 /* TableViewControllerNavigateable.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, DB65C63727A2AF6C008BAC2E /* ReportItem.swift in Sources */, @@ -3997,6 +4027,7 @@ 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */, DBF1D24E269DAF5D00C1C08A /* SearchDetailViewController.swift in Sources */, + 62FD27D52893708A00B205C5 /* BookmarkViewModel+Diffable.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */, DB336F36278D77A40031E64B /* PollOption+Property.swift in Sources */, @@ -4020,6 +4051,7 @@ 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */, DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */, + 62FD27D12893707600B205C5 /* BookmarkViewController.swift in Sources */, DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */, DBD5B1F627BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */, 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */, @@ -4169,6 +4201,7 @@ DB63F75C279956D000455B82 /* Persistence+Tag.swift in Sources */, 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */, + 6213AF5A28939C8400BCADB6 /* BookmarkViewModel.swift in Sources */, 5B24BBDB262DB14800A9381B /* ReportStatusViewModel+Diffable.swift in Sources */, DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift in Sources */, @@ -4288,6 +4321,7 @@ DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, DB336F38278D7AAF0031E64B /* Poll+Property.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, + 6213AF5C28939C8A00BCADB6 /* BookmarkViewModel+State.swift in Sources */, DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DBA465932696B495002B41DB /* APIService+WebFinger.swift in Sources */, @@ -4297,6 +4331,7 @@ DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */, + 6213AF5E2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift in Sources */, DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */, DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, @@ -4445,6 +4480,7 @@ 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DBEFCD82282A2AB100C0ABEA /* ReportServerRulesView.swift in Sources */, DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */, + 6213AF5828939C4800BCADB6 /* APIService+Bookmark.swift in Sources */, DB023D2A27A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift in Sources */, DB98EB6027B10E150082E365 /* ReportCommentTableViewCell.swift in Sources */, DB0FCB962797E6C2006C02E2 /* SearchResultViewController+DataSourceProvider.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 4491d383a..82c58e1f6 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -181,6 +181,7 @@ extension SceneCoordinator { case familiarFollowers(viewModel: FamiliarFollowersViewModel) case rebloggedBy(viewModel: UserListViewModel) case favoritedBy(viewModel: UserListViewModel) + case bookmark(viewModel: BookmarkViewModel) // setting case settings(viewModel: SettingsViewModel) @@ -437,6 +438,10 @@ private extension SceneCoordinator { let _viewController = ProfileViewController() _viewController.viewModel = viewModel viewController = _viewController + case .bookmark(let viewModel): + let _viewController = BookmarkViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .favorite(let viewModel): let _viewController = FavoriteViewController() _viewController.viewModel = viewModel diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift new file mode 100644 index 000000000..0c467778d --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift @@ -0,0 +1,26 @@ +// +// DataSourceFacade+Bookmark.swift +// Mastodon +// +// Created by ProtoLimit on 2022/07/29. +// + +import UIKit +import CoreData +import CoreDataStack + +extension DataSourceFacade { + static func responseToStatusBookmarkAction( + provider: DataSourceProvider, + status: ManagedObjectRecord, + authenticationBox: MastodonAuthenticationBox + ) async throws { + let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() + await selectionFeedbackGenerator.selectionChanged() + + _ = try await provider.context.apiService.bookmark( + record: status, + authenticationBox: authenticationBox + ) + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index 36ceb6dd6..4c948c716 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -125,6 +125,12 @@ extension DataSourceFacade { status: status, authenticationBox: authenticationBox ) + case .bookmark: + try await DataSourceFacade.responseToStatusBookmarkAction( + provider: provider, + status: status, + authenticationBox: authenticationBox + ) case .share: try await DataSourceFacade.responseToStatusShareAction( provider: provider, diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewController+DataSourceProvider.swift new file mode 100644 index 000000000..a22fb4309 --- /dev/null +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewController+DataSourceProvider.swift @@ -0,0 +1,34 @@ +// +// BookmarkViewController+DataSourceProvider.swift +// Mastodon +// +// Created by ProtoLimit on 2022-07-19. +// + +import UIKit + +extension BookmarkViewController: 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) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .status(let record): + return .status(record: record) + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewController.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewController.swift new file mode 100644 index 000000000..18e3c34fd --- /dev/null +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewController.swift @@ -0,0 +1,151 @@ +// +// BookmarkViewController.swift +// Mastodon +// +// Created by ProtoLimit on 2022-07-19. +// + +import os.log +import UIKit +import AVKit +import Combine +import GameplayKit +import MastodonAsset +import MastodonLocalization + +final class BookmarkViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "BookmarkViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: BookmarkViewModel! + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + let titleView = DoubleTitleLabelNavigationBarTitleView() + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension BookmarkViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.view.backgroundColor = theme.secondarySystemBackgroundColor + } + .store(in: &disposeBag) + + navigationItem.titleView = titleView + titleView.update(title: L10n.Scene.Bookmark.title, subtitle: nil) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + tableView: tableView, + statusTableViewCellDelegate: self + ) + + // setup batch fetch + viewModel.listBatchFetchViewModel.setup(scrollView: tableView) + viewModel.listBatchFetchViewModel.shouldFetch + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.viewModel.stateMachine.enter(BookmarkViewModel.State.Loading.self) + } + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + +// aspectViewDidDisappear(animated) + } + +} + +// MARK: - UITableViewDelegate +extension BookmarkViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:BookmarkViewController.AutoGenerateTableViewDelegate + + // Generated using Sourcery + // DO NOT EDIT + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + + + // sourcery:end +} + +// MARK: - StatusTableViewCellDelegate +extension BookmarkViewController: StatusTableViewCellDelegate { } + +extension BookmarkViewController { + override var keyCommands: [UIKeyCommand]? { + return navigationKeyCommands + statusNavigationKeyCommands + } +} + +// MARK: - StatusTableViewControllerNavigateable +extension BookmarkViewController: StatusTableViewControllerNavigateable { + @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { + navigateKeyCommandHandler(sender) + } + + @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { + statusKeyCommandHandler(sender) + } +} diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift new file mode 100644 index 000000000..06483012c --- /dev/null +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift @@ -0,0 +1,65 @@ +// +// BookmarkViewModel+Diffable.swift +// Mastodon +// +// Created by ProtoLimit on 2022-07-19. +// + +import UIKit + +extension BookmarkViewModel { + + func setupDiffableDataSource( + tableView: UITableView, + statusTableViewCellDelegate: StatusTableViewCellDelegate + ) { + diffableDataSource = StatusSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: StatusSection.Configuration( + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil, + filterContext: .none, + activeFilters: nil + ) + ) + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + + stateMachine.enter(State.Reloading.self) + + statusFetchedResultsController.$records + .receive(on: DispatchQueue.main) + .sink { [weak self] records in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + let items = records.map { StatusItem.status(record: $0) } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Reloading, + is State.Loading, + is State.Idle, + is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is State.NoMore: + break + default: + assertionFailure() + break + } + } + + diffableDataSource.applySnapshot(snapshot, animated: false) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+State.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+State.swift new file mode 100644 index 000000000..eda823b50 --- /dev/null +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+State.swift @@ -0,0 +1,191 @@ +// +// BookmarkViewModel+State.swift +// Mastodon +// +// Created by ProtoLimit on 2022-07-19. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension BookmarkViewModel { + class State: GKState, NamingState { + + let logger = Logger(subsystem: "BookmarkViewModel.State", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + + weak var viewModel: BookmarkViewModel? + + init(viewModel: BookmarkViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + let previousState = previousState as? BookmarkViewModel.State + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "")") + } + + @MainActor + func enter(state: State.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") + } + } +} + +extension BookmarkViewModel.State { + class Initial: BookmarkViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + switch stateClass { + case is Reloading.Type: + return viewModel.activeMastodonAuthenticationBox.value != nil + default: + return false + } + } + } + + class Reloading: BookmarkViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + // reset + viewModel.statusFetchedResultsController.statusIDs.value = [] + + stateMachine.enter(Loading.self) + } + } + + class Fail: BookmarkViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } + } + + class Idle: BookmarkViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: BookmarkViewModel.State { + + // prefer use `maxID` token in response header + var maxID: String? + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + guard let authenticationBox = viewModel.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + if previousState is Reloading { + maxID = nil + } + + + Task { + do { + let response = try await viewModel.context.apiService.bookmarkedStatuses( + maxID: maxID, + authenticationBox: authenticationBox + ) + + var hasNewStatusesAppend = false + var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value + for status in response.value { + guard !statusIDs.contains(status.id) else { continue } + statusIDs.append(status.id) + hasNewStatusesAppend = true + } + + self.maxID = response.link?.maxID + + let hasNextPage: Bool = { + guard let link = response.link else { return true } // assert has more when link invalid + return link.maxID != nil + }() + + if hasNewStatusesAppend && hasNextPage { + await enter(state: Idle.self) + } else { + await enter(state: NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch user bookmarks fail: \(error.localizedDescription)") + await enter(state: Fail.self) + } + } // end Task + } // end func + } + + class NoMore: BookmarkViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + } +} diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel.swift new file mode 100644 index 000000000..8dc8d734a --- /dev/null +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel.swift @@ -0,0 +1,58 @@ +// +// BookmarkViewModel.swift +// Mastodon +// +// Created by ProtoLimit on 2022-07-19. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit + +final class BookmarkViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let activeMastodonAuthenticationBox: CurrentValueSubject + let statusFetchedResultsController: StatusFetchedResultsController + let listBatchFetchViewModel = ListBatchFetchViewModel() + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.Loading(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + init(context: AppContext) { + self.context = context + self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalTweetPredicate: nil + ) + + context.authenticationService.activeMastodonAuthenticationBox + .assign(to: \.value, on: activeMastodonAuthenticationBox) + .store(in: &disposeBag) + + activeMastodonAuthenticationBox + .map { $0?.domain } + .assign(to: \.value, on: statusFetchedResultsController.domain) + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 96af86026..3e437bb7c 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -74,6 +74,17 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi barButtonItem.tintColor = .white return barButtonItem }() + + private(set) lazy var bookmarkBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem( + image: Asset.ObjectsAndTools.bookmark.image.withRenderingMode(.alwaysTemplate), + style: .plain, + target: self, + action: #selector(ProfileViewController.bookmarkBarButtonItemPressed(_:)) + ) + barButtonItem.tintColor = .white + return barButtonItem + }() private(set) lazy var replyBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:))) @@ -224,6 +235,7 @@ extension ProfileViewController { items.append(self.settingBarButtonItem) items.append(self.shareBarButtonItem) items.append(self.favoriteBarButtonItem) + items.append(self.bookmarkBarButtonItem) return } @@ -503,6 +515,12 @@ extension ProfileViewController { let favoriteViewModel = FavoriteViewModel(context: context) coordinator.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show) } + + @objc private func bookmarkBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + let bookmarkViewModel = BookmarkViewModel(context: context) + coordinator.present(scene: .bookmark(viewModel: bookmarkViewModel), from: self, transition: .show) + } @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) diff --git a/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift b/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift index ab4fea1ab..1bdff4d80 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift @@ -395,6 +395,19 @@ extension StatusView { } .assign(to: \.isFavorite, on: viewModel) .store(in: &disposeBag) + + Publishers.CombineLatest( + viewModel.$userIdentifier, + status.publisher(for: \.bookmarkedBy) + ) + .map { userIdentifier, bookmarkedBy in + guard let userIdentifier = userIdentifier else { return false } + return bookmarkedBy.contains(where: { + $0.id == userIdentifier.userID && $0.domain == userIdentifier.domain + }) + } + .assign(to: \.isBookmark, on: viewModel) + .store(in: &disposeBag) } private func configureFilter(status: Status) { diff --git a/Mastodon/Service/APIService/APIService+Bookmark.swift b/Mastodon/Service/APIService/APIService+Bookmark.swift new file mode 100644 index 000000000..8100b3b58 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Bookmark.swift @@ -0,0 +1,142 @@ +// +// APIService+Bookmark.swift +// Mastodon +// +// Created by ProtoLimit on 2022/07/28. +// + +import Foundation +import Combine +import MastodonSDK +import CoreData +import CoreDataStack +import CommonOSLog + +extension APIService { + + private struct MastodonBookmarkContext { + let statusID: Status.ID + let isBookmarked: Bool + } + + func bookmark( + record: ManagedObjectRecord, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + let logger = Logger(subsystem: "APIService", category: "Bookmark") + + let managedObjectContext = backgroundManagedObjectContext + + // update bookmark state and retrieve bookmark context + let bookmarkContext: MastodonBookmarkContext = try await managedObjectContext.performChanges { + guard let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext), + let _status = record.object(in: managedObjectContext) + else { + throw APIError.implicit(.badRequest) + } + let me = authentication.user + let status = _status.reblog ?? _status + let isBookmarked = status.bookmarkedBy.contains(me) + status.update(bookmarked: !isBookmarked, by: me) + let context = MastodonBookmarkContext( + statusID: status.id, + isBookmarked: isBookmarked + ) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update status bookmark: \(!isBookmarked)") + return context + } + + // request bookmark or undo bookmark + let result: Result, Error> + do { + let response = try await Mastodon.API.Bookmarks.bookmarks( + domain: authenticationBox.domain, + statusID: bookmarkContext.statusID, + session: session, + authorization: authenticationBox.userAuthorization, + bookmarkKind: bookmarkContext.isBookmarked ? .destroy : .create + ).singleOutput() + result = .success(response) + } catch { + result = .failure(error) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update bookmark failure: \(error.localizedDescription)") + } + + // update bookmark state + try await managedObjectContext.performChanges { + guard let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext), + let _status = record.object(in: managedObjectContext) + else { return } + let me = authentication.user + let status = _status.reblog ?? _status + + switch result { + case .success(let response): + _ = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: authenticationBox.domain, + entity: response.value, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update status bookmark: \(response.value.bookmarked.debugDescription)") + case .failure: + // rollback + status.update(bookmarked: bookmarkContext.isBookmarked, by: me) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): rollback status bookmark") + } + } + + let response = try result.get() + return response + } + +} + +extension APIService { + func bookmarkedStatuses( + limit: Int = onceRequestStatusMaxCount, + maxID: String? = nil, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> { + let query = Mastodon.API.Bookmarks.BookmarkStatusesQuery(limit: limit, minID: nil, maxID: maxID) + + let response = try await Mastodon.API.Bookmarks.bookmarkedStatus( + domain: authenticationBox.domain, + session: session, + authorization: authenticationBox.userAuthorization, + query: query + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { + assertionFailure() + return + } + + for entity in response.value { + let result = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: authenticationBox.domain, + entity: entity, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) + + result.status.update(bookmarked: true, by: me) + result.status.reblog?.update(bookmarked: true, by: me) + } // end for … in + } + + return response + } // end func +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.fill.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.fill.imageset/Contents.json new file mode 100644 index 000000000..33bc2f0de --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.fill.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "bookmark-solid.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.fill.imageset/bookmark-solid.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.fill.imageset/bookmark-solid.pdf new file mode 100755 index 0000000000000000000000000000000000000000..89d499e8405eb1e9f4b44e5bc61ad6917dcbf40e GIT binary patch literal 1200 zcmY!laB<2%8dkI9gu?faZX;sOgD8P!q?9rn0 ze0TdC-3WD-U$J2ox3eoWnE{)(lV+xx!6nVhWDvEcby?|D#dy=FqVxp6(C z<}{;j%m4;?4;H{Crm%pqAR=H44Us~|7%5~7&4>yb-+sgVLk0q^<^Q|3n@VQQ=U*DA zbcD(KXp?~S%LG>KjZsG@=C=K-?#L8;z5DmQyUQIHIl4Lt&(PW!=RCJAox9YaxtFQo z)^6`3l01h5<1KIfdOc;q(X`2(&L5^{=QDTR+woxG(sfVSy9}3FZu5Ft!jUEyE5DTA zInpkx$@Jv?jptule|uIJ<~>d7?&OmCX4Y46n$MTd^%Oh0E;VV{Ea|Ai!&j|C^Ie|) z&*q=G*7MnMe#!Gm7rM`GDq&_QbiIxlkl^5eg`}k+EF=w$FoH57C^ZcnLa_X7fit{7 zDLFW^DpkP{62RdK(S`~}3P!PD9YOi|B?^WH@M!SNOUqZV1apx~f#igQAOGifHa0gl zb~g4lHckv=^)UF*98sg7#O`76w7DZ}f)JkqvxkA9(R+q2!};k63=F#$v8H1BvA86$ UsHCC@=zL3aU;(A->hHz{0DQ5&5C8xG literal 0 HcmV?d00001 diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.imageset/Contents.json new file mode 100644 index 000000000..81aaf7f9b --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "bookmark-regular.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.imageset/bookmark-regular.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.imageset/bookmark-regular.pdf new file mode 100755 index 0000000000000000000000000000000000000000..c9eebdfb982515c3cf967cc7233fa9f0d921e6b5 GIT binary patch literal 1226 zcmY!laBlN;d8$E3(}x0p*WA@}FB}RjuK4a> zV~|>s=;0xsd!PMdPq9$UqJP_VE`R*-OVppAr|K%LJ&&k9%KSVrDkMZ(>sgCdz=?S) zpKY1$xY%E{Yox}iwAdakx} zv60k`t9inF>Z=#tRkDcEwq2cCBvF5+Wk!g_?Oojllh#eUVa3k!D>KMdAi$+?*({GG za`lQ6O=|uotafV-t_+Z2c=Nya?%kuc3>RZUJh@WyQlS0s}7&4}2O2CwD$b zm>_!YOU#uF`yJ^?586M;eKm;`F1AegpMUMrwF zEY;lJ_a)BcWTlP;&)0g-gKFzF6T;1n>lrnt8Fga@Fvxq*05&v%2e1VZ0b^*06f(w0 zA!BGpRM7bL@8mmVAmCD6f3!q}_1i5CnLrn2-bIcbd>U(xaEWR3PT2R(Ai5 z9cL1^$PpFCz=Nx}9({Phd&Fp5+DY9wg=epYp^&8Dd6 z_X<1beAL|J-BYX4>XG@nw0>jhgLB&}|E>Gz=KlSV*Z3j;i|EU#eqcF8Q^5 z&FgDcQYo*4CI*=VbI)4wv*lPwPsgYC+4(c4_WgQxt3}=L#KE+PDyIKAu9q-_5gZt> zaI~<5B``xHjDU;?N=*ZY4=kfw;0!HLIu6dPN>wm~1aG)Pw4s8Lf>A73M^Ju#iGra4 zJQ6(f(()B7!Cd6xAvq!8$N%}Ajm?dXosGSXjT0}*c^G_Xj+mveNY=xkw6TL#K~=_q tZ9+m)%03Q}TSTmYqP#Cre$ literal 0 HcmV?d00001 diff --git a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift index adcee2944..edbf78a0b 100644 --- a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift +++ b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift @@ -117,6 +117,8 @@ public enum Asset { public static let bellBadge = ImageAsset(name: "ObjectsAndTools/bell.badge") public static let bellFill = ImageAsset(name: "ObjectsAndTools/bell.fill") public static let bell = ImageAsset(name: "ObjectsAndTools/bell") + public static let bookmarkFill = ImageAsset(name: "ObjectsAndTools/bookmark.fill") + public static let bookmark = ImageAsset(name: "ObjectsAndTools/bookmark") public static let gear = ImageAsset(name: "ObjectsAndTools/gear") public static let houseFill = ImageAsset(name: "ObjectsAndTools/house.fill") public static let house = ImageAsset(name: "ObjectsAndTools/house") diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 70a807fcb..c64a50fa2 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -287,6 +287,8 @@ public enum L10n { return L10n.tr("Localizable", "Common.Controls.Status.UserRepliedTo", String(describing: p1)) } public enum Actions { + /// Bookmark + public static let bookmark = L10n.tr("Localizable", "Common.Controls.Status.Actions.Bookmark") /// Favorite public static let favorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Favorite") /// Hide @@ -305,6 +307,8 @@ public enum L10n { public static let showVideoPlayer = L10n.tr("Localizable", "Common.Controls.Status.Actions.ShowVideoPlayer") /// Tap then hold to show menu public static let tapThenHoldToShowMenu = L10n.tr("Localizable", "Common.Controls.Status.Actions.TapThenHoldToShowMenu") + /// Unbookmark + public static let unbookmark = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unbookmark") /// Unfavorite public static let unfavorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unfavorite") /// Undo reblog @@ -403,6 +407,10 @@ public enum L10n { return L10n.tr("Localizable", "Scene.AccountList.TabBarHint", String(describing: p1)) } } + public enum Bookmark { + /// Your Bookmarks + public static let title = L10n.tr("Localizable", "Scene.Bookmark.Title") + } public enum Compose { /// Publish public static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction") diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings index 12df948fc..1c40bf855 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings @@ -104,6 +104,8 @@ Please check your internet connection."; "Common.Controls.Status.Actions.TapThenHoldToShowMenu" = "Tap then hold to show menu"; "Common.Controls.Status.Actions.Unfavorite" = "Unfavorite"; "Common.Controls.Status.Actions.Unreblog" = "Undo reblog"; +"Common.Controls.Status.Actions.Bookmark" = "Bookmark"; +"Common.Controls.Status.Actions.Unbookmark" = "Unbookmark"; "Common.Controls.Status.ContentWarning" = "Content Warning"; "Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal"; "Common.Controls.Status.Poll.Closed" = "Closed"; @@ -149,6 +151,7 @@ Your profile looks like this to them."; "Scene.AccountList.AddAccount" = "Add Account"; "Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher"; "Scene.AccountList.TabBarHint" = "Current selected profile: %@. Double tap then hold to show account switcher"; +"Scene.Bookmark.Title" = "Your Bookmarks"; "Scene.Compose.Accessibility.AppendAttachment" = "Add Attachment"; "Scene.Compose.Accessibility.AppendPoll" = "Add Poll"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker"; @@ -437,4 +440,4 @@ uploaded to Mastodon."; back in your hands."; "Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; "Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button."; -"Scene.Wizard.NewInMastodon" = "New in Mastodon"; \ No newline at end of file +"Scene.Wizard.NewInMastodon" = "New in Mastodon"; diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Bookmarks.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Bookmarks.swift new file mode 100644 index 000000000..a50a8e337 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Bookmarks.swift @@ -0,0 +1,136 @@ +// +// Mastodon+API+Bookmarks.swift +// +// +// Created by ProtoLimit on 2022/07/28. +// + +import Combine +import Foundation + +extension Mastodon.API.Bookmarks { + + static func bookmarksStatusesEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("bookmarks") + } + + /// Bookmarked statuses + /// + /// Using this endpoint to view the bookmarked list for user + /// + /// - Since: 3.1.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2022/7/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/bookmarks/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func bookmarkedStatus( + domain: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization, + query: Mastodon.API.Bookmarks.BookmarkStatusesQuery + ) -> AnyPublisher, Error> { + let url = bookmarksStatusesEndpointURL(domain: domain) + let request = Mastodon.API.get(url: url, query: query, authorization: authorization) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct BookmarkStatusesQuery: GetQuery, PagedQueryType { + + public var limit: Int? + public var minID: String? + public var maxID: String? + public var sinceID: Mastodon.Entity.Status.ID? + + public init(limit: Int? = nil, minID: String? = nil, maxID: String? = nil, sinceID: String? = nil) { + self.limit = limit + self.minID = minID + self.maxID = maxID + self.sinceID = sinceID + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + if let limit = self.limit { + items.append(URLQueryItem(name: "limit", value: String(limit))) + } + if let minID = self.minID { + items.append(URLQueryItem(name: "min_id", value: minID)) + } + if let maxID = self.maxID { + items.append(URLQueryItem(name: "max_id", value: maxID)) + } + if let sinceID = self.sinceID { + items.append(URLQueryItem(name: "since_id", value: sinceID)) + } + guard !items.isEmpty else { return nil } + return items + } + } + +} + +extension Mastodon.API.Bookmarks { + + static func bookmarkActionEndpointURL(domain: String, statusID: String, bookmarkKind: BookmarkKind) -> URL { + var actionString: String + switch bookmarkKind { + case .create: + actionString = "/bookmark" + case .destroy: + actionString = "/unbookmark" + } + let pathComponent = "statuses/" + statusID + actionString + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Bookmark / Undo Bookmark + /// + /// Add a status to your bookmarks list / Remove a status from your bookmarks list + /// + /// - Since: 3.1.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2022/7/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: Mastodon status id + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func bookmarks( + domain: String, + statusID: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization, + bookmarkKind: BookmarkKind + ) -> AnyPublisher, Error> { + let url: URL = bookmarkActionEndpointURL(domain: domain, statusID: statusID, bookmarkKind: bookmarkKind) + var request = Mastodon.API.post(url: url, query: nil, authorization: authorization) + request.httpMethod = "POST" + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public enum BookmarkKind { + case create + case destroy + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 66c822b32..43d5873d0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -102,6 +102,7 @@ extension Mastodon.API { public enum V2 { } public enum Account { } public enum App { } + public enum Bookmarks { } public enum CustomEmojis { } public enum Favorites { } public enum Instance { } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index f3d9f6f80..bd80afe86 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -84,6 +84,7 @@ extension StatusView { @Published public var isReblog: Bool = false @Published public var isReblogEnabled: Bool = true @Published public var isFavorite: Bool = false + @Published public var isBookmark: Bool = false @Published public var replyCount: Int = 0 @Published public var reblogCount: Int = 0 @@ -510,6 +511,13 @@ extension StatusView.ViewModel { ) } .store(in: &disposeBag) + $isBookmark + .sink { isHighlighted in + statusView.actionToolbarContainer.configureBookmark( + isHighlighted: isHighlighted + ) + } + .store(in: &disposeBag) } private func bindMetric(statusView: StatusView) { diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift b/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift index 4a5c44850..ccfc8020e 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift @@ -22,11 +22,14 @@ public final class ActionToolbarContainer: UIView { static let reblogImage = Asset.Arrow.repeat.image.withRenderingMode(.alwaysTemplate) static let starImage = Asset.ObjectsAndTools.star.image.withRenderingMode(.alwaysTemplate) static let starFillImage = Asset.ObjectsAndTools.starFill.image.withRenderingMode(.alwaysTemplate) + static let bookmarkImage = Asset.ObjectsAndTools.bookmark.image.withRenderingMode(.alwaysTemplate) + static let bookmarkFillImage = Asset.ObjectsAndTools.bookmarkFill.image.withRenderingMode(.alwaysTemplate) static let shareImage = Asset.Communication.share.image.withRenderingMode(.alwaysTemplate) public let replyButton = HighlightDimmableButton() public let reblogButton = HighlightDimmableButton() public let favoriteButton = HighlightDimmableButton() + public let bookmarkButton = HighlightDimmableButton() public let shareButton = HighlightDimmableButton() public weak var delegate: ActionToolbarContainerDelegate? @@ -61,6 +64,7 @@ extension ActionToolbarContainer { replyButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside) reblogButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside) favoriteButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside) + bookmarkButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside) shareButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside) } @@ -75,7 +79,7 @@ extension ActionToolbarContainer { subview.removeFromSuperview() } - let buttons = [replyButton, reblogButton, favoriteButton, shareButton] + let buttons = [replyButton, reblogButton, favoriteButton, bookmarkButton, shareButton] buttons.forEach { button in button.tintColor = Asset.Colors.Button.actionToolbar.color button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) @@ -90,6 +94,7 @@ extension ActionToolbarContainer { replyButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reply reblogButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reblog // needs update to follow state favoriteButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.favorite // needs update to follow state + bookmarkButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.bookmark // needs update to follow state shareButton.accessibilityLabel = L10n.Common.Controls.Actions.share switch style { @@ -100,6 +105,7 @@ extension ActionToolbarContainer { replyButton.setImage(ActionToolbarContainer.replyImage, for: .normal) reblogButton.setImage(ActionToolbarContainer.reblogImage, for: .normal) favoriteButton.setImage(ActionToolbarContainer.starImage, for: .normal) + bookmarkButton.setImage(ActionToolbarContainer.bookmarkImage, for: .normal) shareButton.setImage(ActionToolbarContainer.shareImage, for: .normal) container.axis = .horizontal @@ -108,18 +114,22 @@ extension ActionToolbarContainer { replyButton.translatesAutoresizingMaskIntoConstraints = false reblogButton.translatesAutoresizingMaskIntoConstraints = false favoriteButton.translatesAutoresizingMaskIntoConstraints = false + bookmarkButton.translatesAutoresizingMaskIntoConstraints = false shareButton.translatesAutoresizingMaskIntoConstraints = false container.addArrangedSubview(replyButton) container.addArrangedSubview(reblogButton) container.addArrangedSubview(favoriteButton) + container.addArrangedSubview(bookmarkButton) container.addArrangedSubview(shareButton) NSLayoutConstraint.activate([ replyButton.heightAnchor.constraint(equalToConstant: 36).priority(.defaultHigh), replyButton.heightAnchor.constraint(equalTo: reblogButton.heightAnchor).priority(.defaultHigh), replyButton.heightAnchor.constraint(equalTo: favoriteButton.heightAnchor).priority(.defaultHigh), + replyButton.heightAnchor.constraint(equalTo: bookmarkButton.heightAnchor).priority(.defaultHigh), replyButton.heightAnchor.constraint(equalTo: shareButton.heightAnchor).priority(.defaultHigh), replyButton.widthAnchor.constraint(equalTo: reblogButton.widthAnchor).priority(.defaultHigh), replyButton.widthAnchor.constraint(equalTo: favoriteButton.widthAnchor).priority(.defaultHigh), + replyButton.widthAnchor.constraint(equalTo: bookmarkButton.widthAnchor).priority(.defaultHigh), ]) shareButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) shareButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) @@ -131,6 +141,7 @@ extension ActionToolbarContainer { replyButton.setImage(ActionToolbarContainer.replyImage, for: .normal) reblogButton.setImage(ActionToolbarContainer.reblogImage, for: .normal) favoriteButton.setImage(ActionToolbarContainer.starImage, for: .normal) + bookmarkButton.setImage(ActionToolbarContainer.bookmarkImage, for: .normal) container.axis = .horizontal container.spacing = 8 @@ -139,6 +150,7 @@ extension ActionToolbarContainer { container.addArrangedSubview(replyButton) container.addArrangedSubview(reblogButton) container.addArrangedSubview(favoriteButton) + container.addArrangedSubview(bookmarkButton) } } @@ -155,6 +167,7 @@ extension ActionToolbarContainer { case reply case reblog case like + case bookmark case share } @@ -184,6 +197,11 @@ extension ActionToolbarContainer { favoriteButton.setTitleColor(tintColor, for: .highlighted) } + private func isBookmarkButtonHighlightStateDidChange(to isHighlight: Bool) { + let tintColor = isHighlight ? Asset.Colors.brand.color : Asset.Colors.Button.actionToolbar.color + bookmarkButton.tintColor = tintColor + } + } extension ActionToolbarContainer { @@ -196,6 +214,7 @@ extension ActionToolbarContainer { case replyButton: _action = .reply case reblogButton: _action = .reblog case favoriteButton: _action = .like + case bookmarkButton: _action = .bookmark case shareButton: _action = .share default: _action = nil } @@ -256,6 +275,20 @@ extension ActionToolbarContainer { favoriteButton.accessibilityLabel = L10n.Plural.Count.favorite(count) } + public func configureBookmark(isHighlighted: Bool) { + let image = isHighlighted ? ActionToolbarContainer.bookmarkFillImage : ActionToolbarContainer.bookmarkImage + bookmarkButton.setImage(image, for: .normal) + let tintColor = isHighlighted ? Asset.Colors.brand.color : Asset.Colors.Button.actionToolbar.color + bookmarkButton.tintColor = tintColor + + if isHighlighted { + bookmarkButton.accessibilityTraits.insert(.selected) + } else { + bookmarkButton.accessibilityTraits.remove(.selected) + } + bookmarkButton.accessibilityLabel = isHighlighted ? L10n.Common.Controls.Status.Actions.unbookmark : L10n.Common.Controls.Status.Actions.bookmark + } + } extension ActionToolbarContainer { @@ -267,7 +300,7 @@ extension ActionToolbarContainer { extension ActionToolbarContainer { public override var accessibilityElements: [Any]? { - get { [replyButton, reblogButton, favoriteButton, shareButton] } + get { [replyButton, reblogButton, favoriteButton, bookmarkButton, shareButton] } set { } } }