diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift index 9dd97f38a6..7116d3562d 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift @@ -66,10 +66,10 @@ extension DataSourceFacade { previewContext: AttachmentPreviewContext ) async throws { let managedObjectContext = dependency.context.managedObjectContext - let attachments: [MastodonAttachment] = try await managedObjectContext.perform { - guard let _status = status.object(in: managedObjectContext) else { return [] } + let (attachments, language): ([MastodonAttachment], String?) = try await managedObjectContext.perform { + guard let _status = status.object(in: managedObjectContext) else { return ([], nil) } let status = _status.reblog ?? _status - return status.attachments + return (status.attachments, status.language) } let thumbnails = await previewContext.thumbnails() @@ -120,7 +120,8 @@ extension DataSourceFacade { let mediaPreviewItem = MediaPreviewViewModel.PreviewItem.attachment(.init( attachments: attachments, initialIndex: previewContext.index, - thumbnails: thumbnails + thumbnails: thumbnails, + language: language )) coordinateToMediaPreviewScene( diff --git a/Mastodon/Scene/MediaPreview/AltTextViewController.swift b/Mastodon/Scene/MediaPreview/AltTextViewController.swift index 15f79b8054..ea51d5a9ea 100644 --- a/Mastodon/Scene/MediaPreview/AltTextViewController.swift +++ b/Mastodon/Scene/MediaPreview/AltTextViewController.swift @@ -20,12 +20,10 @@ class AltTextViewController: UIViewController { textView.textContainer.maximumNumberOfLines = 0 textView.textContainer.lineBreakMode = .byWordWrapping - textView.font = .preferredFont(forTextStyle: .callout) textView.isScrollEnabled = true textView.backgroundColor = .clear textView.isOpaque = false textView.isEditable = false - textView.tintColor = .white textView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 8, right: 8) textView.contentInsetAdjustmentBehavior = .always textView.verticalScrollIndicatorInsets.bottom = 4 @@ -33,8 +31,12 @@ class AltTextViewController: UIViewController { return textView }() - init(alt: String, sourceView: UIView?) { - textView.text = alt + init(alt: String, language: String?, sourceView: UIView?) { + textView.attributedText = NSAttributedString(string: alt, attributes: [ + .languageIdentifier: "", + .foregroundColor: UIColor.white, + .font: UIFont.preferredFont(forTextStyle: .callout), + ]) super.init(nibName: nil, bundle: nil) self.modalPresentationStyle = .popover self.popoverPresentationController?.delegate = self diff --git a/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift index 68bc0219fb..1af8dacdd0 100644 --- a/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift +++ b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift @@ -68,7 +68,12 @@ extension MediaPreviewImageViewController { let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self) previewImageView.addInteraction(previewImageViewContextMenuInteraction) - previewImageView.imageView.accessibilityLabel = viewModel.item.altText + previewImageView.imageView.attributedAccessibilityLabel = viewModel.item.altText.map { altText in + AttributedString( + altText, + attributes: AttributeContainer(\.languageIdentifier, value: viewModel.item.language) + ) + } if let thumbnail = viewModel.item.thumbnail { previewImageView.imageView.image = thumbnail diff --git a/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift index 3a4d9edd27..804c03fe09 100644 --- a/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift @@ -34,6 +34,7 @@ extension MediaPreviewImageViewModel { let assetURL: URL? let thumbnail: UIImage? let altText: String? + let language: String? } } diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index d1197ebc86..678ad15245 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -186,7 +186,7 @@ extension MediaPreviewViewController { @objc private func altButtonPressed(_ sender: UIButton) { guard let alt = viewModel.altText else { return } - present(AltTextViewController(alt: alt, sourceView: sender), animated: true) + present(AltTextViewController(alt: alt, language: viewModel.language, sourceView: sender), animated: true) } } diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index a6b604d6fa..70a5c375f2 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -28,6 +28,7 @@ final class MediaPreviewViewModel: NSObject { @Published var currentPage: Int @Published var showingChrome = true @Published var altText: String? + @Published var language: String? // output let viewControllers: [MediaPreviewPage] @@ -47,6 +48,7 @@ final class MediaPreviewViewModel: NSObject { switch item { case .attachment(let previewContext): getAltText = { previewContext.attachments[$0].altDescription } + self.language = previewContext.language currentPage = previewContext.initialIndex for (i, attachment) in previewContext.attachments.enumerated() { @@ -58,7 +60,8 @@ final class MediaPreviewViewModel: NSObject { item: .init( assetURL: attachment.assetURL.flatMap { URL(string: $0) }, thumbnail: previewContext.thumbnail(at: i), - altText: attachment.altDescription + altText: attachment.altDescription, + language: previewContext.language ) ) viewController.viewModel = viewModel @@ -96,7 +99,8 @@ final class MediaPreviewViewModel: NSObject { item: .init( assetURL: previewContext.assetURL.flatMap { URL(string: $0) }, thumbnail: previewContext.thumbnail, - altText: nil + altText: nil, + language: nil ) ) viewController.viewModel = viewModel @@ -108,7 +112,8 @@ final class MediaPreviewViewModel: NSObject { item: .init( assetURL: previewContext.assetURL.flatMap { URL(string: $0) }, thumbnail: previewContext.thumbnail, - altText: nil + altText: nil, + language: nil ) ) viewController.viewModel = viewModel @@ -161,6 +166,7 @@ extension MediaPreviewViewModel { let attachments: [MastodonAttachment] let initialIndex: Int let thumbnails: [UIImage?] + let language: String? func thumbnail(at index: Int) -> UIImage? { guard index < thumbnails.count else { return nil } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 5cf498fdb6..903ede9ce4 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -83,7 +83,7 @@ extension StatusTableViewCell { .receive(on: DispatchQueue.main) .sink { [weak self] accessibilityLabel in guard let self = self else { return } - self.accessibilityLabel = accessibilityLabel + self.attributedAccessibilityLabel = accessibilityLabel } .store(in: &_disposeBag) @@ -92,7 +92,7 @@ extension StatusTableViewCell { .receive(on: DispatchQueue.main) .sink { [weak self] contentLabel, accessibilityLabel in guard let self = self else { return } - self.accessibilityUserInputLabels = [contentLabel, accessibilityLabel] + self.attributedAccessibilityUserInputLabels = [contentLabel, accessibilityLabel] } .store(in: &_disposeBag) diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/StatusEdit.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/StatusEdit.swift index 1cb7aa1a44..68bf975567 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/StatusEdit.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/StatusEdit.swift @@ -31,6 +31,9 @@ public final class StatusEdit: NSManagedObject { // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public var spoilerText: String? + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public var status: Status? + // MARK: - AutoGenerateProperty // sourcery:inline:StatusEdit.AutoGenerateProperty @@ -205,6 +208,11 @@ extension StatusEdit: AutoUpdatableObject { self.spoilerText = spoilerText } } + public func update(status: Status?) { + if self.status != status { + self.status = status + } + } public func update(emojis: [MastodonEmoji]) { if self.emojis != emojis { self.emojis = emojis diff --git a/MastodonSDK/Sources/MastodonExtension/AttributeContainer.swift b/MastodonSDK/Sources/MastodonExtension/AttributeContainer.swift new file mode 100644 index 0000000000..8f1f337918 --- /dev/null +++ b/MastodonSDK/Sources/MastodonExtension/AttributeContainer.swift @@ -0,0 +1,17 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation + +extension AttributeContainer { + public init(_ attribute: WritableKeyPath, value: T) { + self.init() + self[keyPath: attribute] = value + } + + public init(_ attribute: WritableKeyPath, value: T?) { + self.init() + if let value { + self[keyPath: attribute] = value + } + } +} diff --git a/MastodonSDK/Sources/MastodonExtension/NSAccessibility.swift b/MastodonSDK/Sources/MastodonExtension/NSAccessibility.swift new file mode 100644 index 0000000000..4098cef2b1 --- /dev/null +++ b/MastodonSDK/Sources/MastodonExtension/NSAccessibility.swift @@ -0,0 +1,25 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import UIKit + +extension NSObject { + @inlinable public var attributedAccessibilityLabel: AttributedString? { + get { accessibilityAttributedLabel.map(AttributedString.init) } + set { accessibilityAttributedLabel = newValue.map(NSAttributedString.init) } + } + + @inlinable public var attributedAccessibilityValue: AttributedString? { + get { accessibilityAttributedValue.map(AttributedString.init) } + set { accessibilityAttributedValue = newValue.map(NSAttributedString.init) } + } + + @inlinable public var attributedAccessibilityHint: AttributedString? { + get { accessibilityAttributedHint.map(AttributedString.init) } + set { accessibilityAttributedHint = newValue.map(NSAttributedString.init) } + } + + @inlinable public var attributedAccessibilityUserInputLabels: [AttributedString]! { + get { accessibilityAttributedUserInputLabels?.map(AttributedString.init) } + set { accessibilityAttributedUserInputLabels = newValue?.map(NSAttributedString.init) } + } +} diff --git a/MastodonSDK/Sources/MastodonExtension/Sequence.swift b/MastodonSDK/Sources/MastodonExtension/Sequence.swift new file mode 100644 index 0000000000..e17dc9e3cf --- /dev/null +++ b/MastodonSDK/Sources/MastodonExtension/Sequence.swift @@ -0,0 +1,26 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation + +extension Collection { + // ref: https://github.com/apple/swift/blob/700bcb4e4b97da61517c8b8831c72015207612f9/stdlib/public/core/String.swift#L727-L750 + @inlinable public func joined(separator: AttributedString = "") -> AttributedString { + var result: AttributedString = "" + if separator.characters.isEmpty { + for x in self { + result.append(x) + } + return result + } + + var iter = makeIterator() + if let first = iter.next() { + result.append(first) + while let next = iter.next() { + result.append(separator) + result.append(next) + } + } + return result + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Protocol/StatusCompatible.swift b/MastodonSDK/Sources/MastodonUI/Protocol/StatusCompatible.swift index 7ae2a932d3..8b6e8a572f 100644 --- a/MastodonSDK/Sources/MastodonUI/Protocol/StatusCompatible.swift +++ b/MastodonSDK/Sources/MastodonUI/Protocol/StatusCompatible.swift @@ -8,6 +8,7 @@ public protocol StatusCompatible { var attachments: [MastodonAttachment] { get } var isMediaSensitive: Bool { get } var isSensitiveToggled: Bool { get } + var language: String? { get } } extension Status: StatusCompatible {} @@ -24,4 +25,8 @@ extension StatusEdit: StatusCompatible { public var isSensitiveToggled: Bool { true } + + public var language: String? { + status?.language + } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift index 173f043f66..4dd4ba8f85 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift @@ -20,6 +20,7 @@ extension MediaView { var disposeBag = Set() public let info: Info + public let language: String? public let blurhash: String? public let index: Int public let total: Int @@ -31,11 +32,13 @@ extension MediaView { public init( info: MediaView.Configuration.Info, + language: String?, blurhash: String?, index: Int, total: Int ) { self.info = info + self.language = language self.blurhash = blurhash self.index = index self.total = total @@ -203,6 +206,7 @@ extension MediaView { ) return .init( info: .image(info: info), + language: status.language, blurhash: attachment.blurhash, index: idx, total: attachments.count @@ -211,6 +215,7 @@ extension MediaView { let info = videoInfo(from: attachment) return .init( info: .video(info: info), + language: status.language, blurhash: attachment.blurhash, index: idx, total: attachments.count @@ -219,6 +224,7 @@ extension MediaView { let info = videoInfo(from: attachment) return .init( info: .gif(info: info), + language: status.language, blurhash: attachment.blurhash, index: idx, total: attachments.count @@ -227,6 +233,7 @@ extension MediaView { let info = videoInfo(from: attachment) return .init( info: .video(info: info), + language: status.language, blurhash: attachment.blurhash, index: idx, total: attachments.count diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift index 2a61525e93..6b95f16c74 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift @@ -209,14 +209,23 @@ extension MediaView { } private func bindAlt(configuration: Configuration, altDescription: String?) { + let languageAttributes = AttributeContainer(\.languageIdentifier, value: configuration.language) + if configuration.total > 1 { - accessibilityLabel = L10n.Common.Controls.Status.Media.accessibilityLabel( - altDescription ?? "", + let placeholder = "" + let labelString = L10n.Common.Controls.Status.Media.accessibilityLabel( + placeholder, configuration.index + 1, configuration.total ) + var label = AttributedString(labelString) + label.replaceSubrange( + label.range(of: placeholder)!, + with: AttributedString(altDescription ?? "", attributes: languageAttributes) + ) + self.attributedAccessibilityLabel = label } else { - accessibilityLabel = altDescription + self.attributedAccessibilityLabel = altDescription.map { AttributedString($0, attributes: languageAttributes) } } badgeViewController.rootView.altDescription = altDescription diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NewsView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NewsView+Configuration.swift index 8403be7569..d719134802 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NewsView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NewsView+Configuration.swift @@ -42,6 +42,7 @@ extension NewsView { assetURL: link.image, altDescription: nil )), + language: nil, blurhash: link.blurhash, index: 1, total: 1 diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index f466fd8192..80ec72c2a8 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -111,8 +111,8 @@ extension StatusView { @Published public var filterContext: Mastodon.Entity.Filter.Context? @Published public var isFiltered = false - @Published public var groupedAccessibilityLabel = "" - @Published public var contentAccessibilityLabel = "" + @Published public var groupedAccessibilityLabel: AttributedString = "" + @Published public var contentAccessibilityLabel: AttributedString = "" let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() @@ -734,7 +734,7 @@ extension StatusView.ViewModel { $authorUsername, $timestampText ) - .map { header, authorName, authorUsername, timestamp -> String? in + .map { header, authorName, authorUsername, timestamp -> String in var strings: [String?] = [] switch header { @@ -794,30 +794,35 @@ extension StatusView.ViewModel { .assign(to: \.accessibilityLabel, on: statusView.authorView) .store(in: &disposeBag) - Publishers.CombineLatest3( + Publishers.CombineLatest4( $isContentReveal, $spoilerContent, - $content + $content, + Publishers.CombineLatest($language, $translatedFromLanguage) + .map { language, translatedFromLanguage in + translatedFromLanguage != nil ? nil : language + } ) - .map { isContentReveal, spoilerContent, content in - var strings: [String?] = [] - + .map { isContentReveal, spoilerContent, content, language in + var strings: [AttributedString] = [] + let languageAttributes = AttributeContainer(\.languageIdentifier, value: language) if let spoilerContent = spoilerContent, !spoilerContent.string.isEmpty { - strings.append(L10n.Common.Controls.Status.contentWarning) - strings.append(spoilerContent.string) + strings.append(AttributedString(L10n.Common.Controls.Status.contentWarning)) + strings.append(AttributedString(spoilerContent.string, attributes: languageAttributes)) // TODO: replace with "Tap to reveal" - strings.append(L10n.Common.Controls.Status.mediaContentWarning) + strings.append(AttributedString(L10n.Common.Controls.Status.mediaContentWarning)) } if isContentReveal { - strings.append(content?.string) + strings.append(AttributedString(statusView.contentMetaText.backedString, attributes: languageAttributes)) } - return strings.compactMap { $0 }.joined(separator: ", ") + return strings.joined(separator: ", ") } + .removeDuplicates() .assign(to: &$contentAccessibilityLabel) - + $isContentReveal .map { isContentReveal in isContentReveal ? L10n.Scene.Compose.Accessibility.enableContentWarning : L10n.Scene.Compose.Accessibility.disableContentWarning @@ -829,12 +834,13 @@ extension StatusView.ViewModel { $contentAccessibilityLabel .sink { contentAccessibilityLabel in - statusView.spoilerOverlayView.accessibilityLabel = contentAccessibilityLabel + statusView.spoilerOverlayView.attributedAccessibilityLabel = contentAccessibilityLabel + statusView.contentMetaText.textView.attributedAccessibilityLabel = contentAccessibilityLabel } .store(in: &disposeBag) let mediaAccessibilityLabel = $mediaViewConfigurations - .map { configurations -> String? in + .map { configurations in let count = configurations.count return L10n.Plural.Count.media(count) } @@ -913,21 +919,23 @@ extension StatusView.ViewModel { mediaAccessibilityLabel ) .map { author, content, translated, media in - var labels: [String?] = [content, translated, media] + var labels = [content] + if let translated { + labels.append(content) + } + labels.append(AttributedString(media)) if statusView.style != .notification { - labels.insert(author, at: 0) + labels.insert(AttributedString(author), at: 0) } - return labels - .compactMap { $0 } - .joined(separator: ", ") + return labels.joined(separator: ", ") } .assign(to: &$groupedAccessibilityLabel) $groupedAccessibilityLabel .sink { accessibilityLabel in - statusView.accessibilityLabel = accessibilityLabel + statusView.attributedAccessibilityLabel = accessibilityLabel } .store(in: &disposeBag)