Merge pull request #474 from protolimit/feature/add-bookmarks

Add bookmarking and bookmarks view
This commit is contained in:
CMK 2022-09-13 18:07:02 +08:00 committed by GitHub
commit 28267fe6d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 969 additions and 3 deletions

View File

@ -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 */,

View File

@ -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

View File

@ -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
)
}
}

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}
}
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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) {

View File

@ -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
}

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "bookmark-solid.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "bookmark-regular.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -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")

View File

@ -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")

View File

@ -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";

View File

@ -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
}
}

View File

@ -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 { }

View File

@ -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) {

View File

@ -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 { }
}
}