diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift index dff1e5f0..33efe385 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift @@ -223,3 +223,32 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider { } } + +// MARK: a11y +extension NotificationTableViewCellDelegate where Self: DataSourceProvider { + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, accessibilityActivate: Void) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + return + } + switch item { + case .status(let status): + await DataSourceFacade.coordinateToStatusThreadScene( + provider: self, + target: .status, // remove reblog wrapper + status: status + ) + case .user(let user): + await DataSourceFacade.coordinateToProfileScene( + provider: self, + user: user + ) + case .notification(let notification): + assertionFailure("TODO") + default: + assertionFailure("TODO") + } + } // end Task + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift index 9b3b9a37..da15b66d 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -431,3 +431,31 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider { } +// MARK: a11y +extension StatusTableViewCellDelegate where Self: DataSourceProvider { + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, accessibilityActivate: Void) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + return + } + switch item { + case .status(let status): + await DataSourceFacade.coordinateToStatusThreadScene( + provider: self, + target: .status, // remove reblog wrapper + status: status + ) + case .user(let user): + await DataSourceFacade.coordinateToProfileScene( + provider: self, + user: user + ) + case .notification(let notification): + assertionFailure("TODO") + default: + assertionFailure("TODO") + } + } + } +} diff --git a/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift b/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift index 1f98d4fb..77fbca71 100644 --- a/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift @@ -29,6 +29,7 @@ protocol NotificationTableViewCellDelegate: AnyObject, AutoGenerateProtocolDeleg func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, accessibilityActivate: Void) // sourcery:end } @@ -59,5 +60,9 @@ extension NotificationViewDelegate where Self: NotificationViewContainerTableVie func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { delegate?.tableViewCell(self, notificationView: notificationView, quoteStatusView: quoteStatusView, metaText: metaText, didSelectMeta: meta) } + + func notificationView(_ notificationView: NotificationView, accessibilityActivate: Void) { + delegate?.tableViewCell(self, notificationView: notificationView, accessibilityActivate: accessibilityActivate) + } // sourcery:end } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 5a88eeba..3ec9aa90 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -20,6 +20,7 @@ final class StatusTableViewCell: UITableViewCell { weak var delegate: StatusTableViewCellDelegate? var disposeBag = Set() + var _disposeBag = Set() let statusView = StatusView() let separatorLine = UIView.separatorLine @@ -89,6 +90,16 @@ extension StatusTableViewCell { ]) statusView.delegate = self + + isAccessibilityElement = true + accessibilityElements = [statusView] + statusView.viewModel.$groupedAccessibilityLabel + .receive(on: DispatchQueue.main) + .sink { [weak self] accessibilityLabel in + guard let self = self else { return } + self.accessibilityLabel = accessibilityLabel + } + .store(in: &_disposeBag) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -96,6 +107,11 @@ extension StatusTableViewCell { updateContainerViewMarginConstraints() } + + override func accessibilityActivate() -> Bool { + delegate?.tableViewCell(self, statusView: statusView, accessibilityActivate: Void()) + return true + } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift index 1c556b9c..0f879de0 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift @@ -34,6 +34,7 @@ protocol StatusTableViewCellDelegate: AnyObject, AutoGenerateProtocolDelegate { func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView) func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, accessibilityActivate: Void) // sourcery:end } @@ -85,5 +86,9 @@ extension StatusViewDelegate where Self: StatusViewContainerTableViewCell { func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) { delegate?.tableViewCell(self, statusView: statusView, mediaGridContainerView: mediaGridContainerView, mediaSensitiveButtonDidPressed: button) } + + func statusView(_ statusView: StatusView, accessibilityActivate: Void) { + delegate?.tableViewCell(self, statusView: statusView, accessibilityActivate: accessibilityActivate) + } // sourcery:end } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift index 50dd38be..a16684c9 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift @@ -22,6 +22,9 @@ public protocol NotificationViewDelegate: AnyObject { func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) + + // a11y + func notificationView(_ notificationView: NotificationView, accessibilityActivate: Void) } public final class NotificationView: UIView { @@ -392,6 +395,10 @@ extension NotificationView: StatusViewDelegate { assertionFailure() } + public func statusView(_ statusView: StatusView, accessibilityActivate: Void) { + assertionFailure() + } + } // MARK: - MastodonMenuDelegate diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 3cc71867..5b762c2f 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -43,6 +43,7 @@ extension StatusView { @Published public var timestamp: Date? public var timestampFormatter: ((_ date: Date) -> String)? + @Published public var timestampText = "" // Spoiler @Published public var spoilerContent: MetaContent? @@ -90,6 +91,8 @@ extension StatusView { public let isNeedsTableViewUpdate = PassthroughSubject() + @Published public var groupedAccessibilityLabel = "" + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() .share() @@ -176,6 +179,7 @@ extension StatusView.ViewModel { bindToolbar(statusView: statusView) bindMetric(statusView: statusView) bindMenu(statusView: statusView) + bindAccessibility(statusView: statusView) } private func bindHeader(statusView: StatusView) { @@ -246,11 +250,14 @@ extension StatusView.ViewModel { return text } .removeDuplicates() - .sink { [weak self] text in - guard let _ = self else { return } - statusView.dateLabel.configure(content: PlaintextMetaContent(string: text)) - } - .store(in: &disposeBag) + .assign(to: &$timestampText) + + $timestampText + .sink { [weak self] text in + guard let _ = self else { return } + statusView.dateLabel.configure(content: PlaintextMetaContent(string: text)) + } + .store(in: &disposeBag) } private func bindContent(statusView: StatusView) { @@ -635,6 +642,84 @@ extension StatusView.ViewModel { .store(in: &disposeBag) } + private func bindAccessibility(statusView: StatusView) { + let authorAccessibilityLabel = Publishers.CombineLatest3( + $header, + $authorName, + $timestampText + ) + .map { header, authorName, timestamp -> String? in + var strings: [String?] = [] + + switch header { + case .none: + break + case .reply(let info): + strings.append(info.header.string) + case .repost(let info): + strings.append(info.header.string) + } + + strings.append(authorName?.string) + strings.append(timestamp) + + return strings.compactMap { $0 }.joined(separator: ", ") + } + + let contentAccessibilityLabel = Publishers.CombineLatest3( + $isContentReveal, + $spoilerContent, + $content + ) + .map { isContentReveal, spoilerContent, content -> String? in + var strings: [String?] = [] + + if let spoilerContent = spoilerContent, !spoilerContent.string.isEmpty { + strings.append(L10n.Common.Controls.Status.contentWarning) + strings.append(spoilerContent.string) + } + + if isContentReveal { + strings.append(content?.string) + } + + return strings.compactMap { $0 }.joined(separator: ", ") + } + + let meidaAccessibilityLabel = $mediaViewConfigurations + .map { configurations -> String? in + let count = configurations.count + // TODO: i18n + return count > 0 ? "\(count) media" : nil + } + + // TODO: Toolbar + + Publishers.CombineLatest3( + authorAccessibilityLabel, + contentAccessibilityLabel, + meidaAccessibilityLabel + ) + .map { author, content, media in + let group = [ + author, + content, + media + ] + + return group + .compactMap { $0 } + .joined(separator: ", ") + } + .assign(to: &$groupedAccessibilityLabel) + + $groupedAccessibilityLabel + .sink { accessibilityLabel in + statusView.accessibilityLabel = accessibilityLabel + } + .store(in: &disposeBag) + } + } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 6f1eac0f..00415277 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -25,6 +25,10 @@ public protocol StatusViewDelegate: AnyObject { func statusView(_ statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) func statusView(_ statusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView) func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) + + // a11y + func statusView(_ statusView: StatusView, accessibilityActivate: Void) + // func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) // func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) }