diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift index 0c467778d..3155f8385 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift @@ -11,14 +11,14 @@ import CoreDataStack extension DataSourceFacade { static func responseToStatusBookmarkAction( - provider: DataSourceProvider, + dependency: NeedsDependency & UIViewController, status: ManagedObjectRecord, authenticationBox: MastodonAuthenticationBox ) async throws { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() - _ = try await provider.context.apiService.bookmark( + _ = try await dependency.context.apiService.bookmark( record: status, authenticationBox: authenticationBox ) diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index 4c948c716..1fa097d15 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -36,7 +36,7 @@ extension DataSourceFacade { button: UIButton ) async throws { let activityViewController = try await createActivityViewController( - provider: provider, + dependency: provider, status: status ) provider.coordinator.present( @@ -51,19 +51,19 @@ extension DataSourceFacade { } private static func createActivityViewController( - provider: DataSourceProvider, + dependency: NeedsDependency, status: ManagedObjectRecord ) async throws -> UIActivityViewController { - var activityItems: [Any] = try await provider.context.managedObjectContext.perform { - guard let status = status.object(in: provider.context.managedObjectContext) else { return [] } + var activityItems: [Any] = try await dependency.context.managedObjectContext.perform { + guard let status = status.object(in: dependency.context.managedObjectContext) else { return [] } let url = status.url ?? status.uri return [URL(string: url)].compactMap { $0 } as [Any] } var applicationActivities: [UIActivity] = [ - SafariActivity(sceneCoordinator: provider.coordinator), // open URL + SafariActivity(sceneCoordinator: dependency.coordinator), // open URL ] - if let provider = provider as? ShareActivityProvider { + if let provider = dependency as? ShareActivityProvider { activityItems.append(contentsOf: provider.activities) applicationActivities.append(contentsOf: provider.applicationActivities) } @@ -125,12 +125,6 @@ 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, @@ -254,6 +248,38 @@ extension DataSourceFacade { from: dependency, transition: .activityViewControllerPresent(animated: true, completion: nil) ) + case .bookmarkStatus(let actionContext): + Task { + guard let status = menuContext.status else { + assertionFailure() + return + } + try await DataSourceFacade.responseToStatusBookmarkAction( + dependency: dependency, + status: status, + authenticationBox: authenticationBox + ) + } // end Task + case .shareStatus: + Task { + guard let status = menuContext.status else { + assertionFailure() + return + } + let activityViewController = try await DataSourceFacade.createActivityViewController( + dependency: dependency, + status: status + ) + await dependency.coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: menuContext.button, + barButtonItem: menuContext.barButtonItem + ), + from: dependency, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) + } // end Task case .deleteStatus: let alertController = UIAlertController( title: "Delete Post", diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift index 0e8c394b7..519a66062 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift @@ -143,7 +143,8 @@ extension NotificationView.ViewModel { name: name, isMuting: isMuting, isBlocking: isBlocking, - isMyself: isMyself + isMyself: isMyself, + isBookmarking: false // no bookmark action display for notification item ) notificationView.menuButton.menu = notificationView.setupAuthorMenu(menuContext: menuContext) notificationView.menuButton.showsMenuAsPrimaryAction = true diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index bd80afe86..8c088db99 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -511,13 +511,6 @@ extension StatusView.ViewModel { ) } .store(in: &disposeBag) - $isBookmark - .sink { isHighlighted in - statusView.actionToolbarContainer.configureBookmark( - isHighlighted: isHighlighted - ) - } - .store(in: &disposeBag) } private func bindMetric(statusView: StatusView) { @@ -574,13 +567,24 @@ extension StatusView.ViewModel { } private func bindMenu(statusView: StatusView) { - Publishers.CombineLatest4( + let publisherOne = Publishers.CombineLatest( $authorName, - $isMuting, - $isBlocking, $isMyself ) - .sink { authorName, isMuting, isBlocking, isMyself in + let publishersTwo = Publishers.CombineLatest3( + $isMuting, + $isBlocking, + $isBookmark + ) + + Publishers.CombineLatest( + publisherOne.eraseToAnyPublisher(), + publishersTwo.eraseToAnyPublisher() + ).eraseToAnyPublisher() + .sink { tupleOne, tupleTwo in + let (authorName, isMyself) = tupleOne + let (isMuting, isBlocking, isBookmark) = tupleTwo + guard let name = authorName?.string else { statusView.menuButton.menu = nil return @@ -590,7 +594,8 @@ extension StatusView.ViewModel { name: name, isMuting: isMuting, isBlocking: isBlocking, - isMyself: isMyself + isMyself: isMyself, + isBookmarking: isBookmark ) statusView.menuButton.menu = statusView.setupAuthorMenu(menuContext: menuContext) statusView.menuButton.showsMenuAsPrimaryAction = true diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 4c983df34..15b5251c6 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -704,6 +704,7 @@ extension StatusView { public let isMuting: Bool public let isBlocking: Bool public let isMyself: Bool + public let isBookmarking: Bool } public func setupAuthorMenu(menuContext: AuthorMenuContext) -> UIMenu { @@ -721,6 +722,10 @@ extension StatusView { .reportUser( .init(name: menuContext.name) ), + .bookmarkStatus( + .init(isBookmarking: menuContext.isBookmarking) + ), + .shareStatus ] if menuContext.isMyself { diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift b/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift index ccfc8020e..1cfd611b6 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift @@ -22,14 +22,11 @@ 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) + static let shareImage = Asset.Arrow.squareAndArrowUp.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? @@ -64,7 +61,6 @@ 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) } @@ -79,7 +75,7 @@ extension ActionToolbarContainer { subview.removeFromSuperview() } - let buttons = [replyButton, reblogButton, favoriteButton, bookmarkButton, shareButton] + let buttons = [replyButton, reblogButton, favoriteButton, shareButton] buttons.forEach { button in button.tintColor = Asset.Colors.Button.actionToolbar.color button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) @@ -94,7 +90,6 @@ 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 { @@ -105,7 +100,6 @@ 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 @@ -114,22 +108,18 @@ 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) @@ -141,7 +131,6 @@ 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 @@ -150,7 +139,6 @@ extension ActionToolbarContainer { container.addArrangedSubview(replyButton) container.addArrangedSubview(reblogButton) container.addArrangedSubview(favoriteButton) - container.addArrangedSubview(bookmarkButton) } } @@ -167,7 +155,6 @@ extension ActionToolbarContainer { case reply case reblog case like - case bookmark case share } @@ -197,11 +184,6 @@ 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 { @@ -214,7 +196,6 @@ 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 } @@ -275,20 +256,6 @@ 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 { @@ -300,7 +267,7 @@ extension ActionToolbarContainer { extension ActionToolbarContainer { public override var accessibilityElements: [Any]? { - get { [replyButton, reblogButton, favoriteButton, bookmarkButton, shareButton] } + get { [replyButton, reblogButton, favoriteButton, shareButton] } set { } } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift index de4bc403d..f5763f638 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift @@ -32,6 +32,8 @@ extension MastodonMenu { case blockUser(BlockUserActionContext) case reportUser(ReportUserActionContext) case shareUser(ShareUserActionContext) + case bookmarkStatus(BookmarkStatusActionContext) + case shareStatus case deleteStatus func build(delegate: MastodonMenuDelegate) -> UIMenuElement { @@ -88,6 +90,32 @@ extension MastodonMenu { delegate.menuAction(self) } return shareAction + case .bookmarkStatus(let context): + let action = UIAction( + title: context.isBookmarking ? "Remove Bookmark" : "Bookmark", // TODO: i18n + image: context.isBookmarking ? UIImage(systemName: "bookmark.slash.fill") : UIImage(systemName: "bookmark"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak delegate] _ in + guard let delegate = delegate else { return } + delegate.menuAction(self) + } + return action + case .shareStatus: + let action = UIAction( + title: "Share", // TODO: i18n + image: UIImage(systemName: "square.and.arrow.up"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak delegate] _ in + guard let delegate = delegate else { return } + delegate.menuAction(self) + } + return action case .deleteStatus: let deleteAction = UIAction( title: L10n.Common.Controls.Actions.delete, @@ -100,7 +128,7 @@ extension MastodonMenu { guard let delegate = delegate else { return } delegate.menuAction(self) } - return deleteAction + return UIMenu(options: .displayInline, children: [deleteAction]) } // end switch } // end func build } // end enum Action @@ -127,6 +155,14 @@ extension MastodonMenu { } } + public struct BookmarkStatusActionContext { + public let isBookmarking: Bool + + public init(isBookmarking: Bool) { + self.isBookmarking = isBookmarking + } + } + public struct ReportUserActionContext { public let name: String