Merge pull request #474 from protolimit/feature/add-bookmarks
Add bookmarking and bookmarks view
This commit is contained in:
commit
28267fe6d8
|
@ -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 = "<group>"; };
|
||||
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
6213AF5728939C4700BCADB6 /* APIService+Bookmark.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Bookmark.swift"; sourceTree = "<group>"; };
|
||||
6213AF5928939C8400BCADB6 /* BookmarkViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkViewModel.swift; sourceTree = "<group>"; };
|
||||
6213AF5B28939C8A00BCADB6 /* BookmarkViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BookmarkViewModel+State.swift"; sourceTree = "<group>"; };
|
||||
6213AF5D2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Bookmark.swift"; sourceTree = "<group>"; };
|
||||
62FD27D02893707600B205C5 /* BookmarkViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkViewController.swift; sourceTree = "<group>"; };
|
||||
62FD27D22893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookmarkViewController+DataSourceProvider.swift"; sourceTree = "<group>"; };
|
||||
62FD27D42893708A00B205C5 /* BookmarkViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookmarkViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
|
@ -1934,6 +1948,18 @@
|
|||
path = Webview;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
|
@ -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 */,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Status>,
|
||||
authenticationBox: MastodonAuthenticationBox
|
||||
) async throws {
|
||||
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
|
||||
await selectionFeedbackGenerator.selectionChanged()
|
||||
|
||||
_ = try await provider.context.apiService.bookmark(
|
||||
record: status,
|
||||
authenticationBox: authenticationBox
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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<StatusSection, StatusItem>()
|
||||
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<StatusSection, StatusItem>()
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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 ?? "<nil>")")
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let activeMastodonAuthenticationBox: CurrentValueSubject<MastodonAuthenticationBox?, Never>
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<Status>,
|
||||
authenticationBox: MastodonAuthenticationBox
|
||||
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Status> {
|
||||
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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "bookmark-solid.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
BIN
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.fill.imageset/bookmark-solid.pdf
vendored
Executable file
BIN
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.fill.imageset/bookmark-solid.pdf
vendored
Executable file
Binary file not shown.
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "bookmark-regular.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
BIN
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.imageset/bookmark-regular.pdf
vendored
Executable file
BIN
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.imageset/bookmark-regular.pdf
vendored
Executable file
Binary file not shown.
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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";
|
||||
"Scene.Wizard.NewInMastodon" = "New in Mastodon";
|
||||
|
|
|
@ -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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, 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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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
|
||||
}
|
||||
|
||||
}
|
|
@ -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 { }
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 { }
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue