diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 96404cebb..332c73f2d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */; }; 2A76F75C2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A76F75B2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift */; }; 2A82294F29262EE000D2A1F7 /* AppContext+NextAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */; }; + 2AB12E4629362F27006BC925 /* DataSourceFacade+Translate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AB12E4529362F27006BC925 /* DataSourceFacade+Translate.swift */; }; 2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; }; @@ -535,6 +536,7 @@ 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderView.swift; sourceTree = ""; }; 2A76F75B2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderViewActionButton.swift; sourceTree = ""; }; 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppContext+NextAccount.swift"; sourceTree = ""; }; + 2AB12E4529362F27006BC925 /* DataSourceFacade+Translate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Translate.swift"; sourceTree = ""; }; 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; @@ -2120,6 +2122,7 @@ DB63F74627990B0600455B82 /* DataSourceFacade+Hashtag.swift */, DB63F7532799491600455B82 /* DataSourceFacade+SearchHistory.swift */, DB159C2A27A17BAC0068DC77 /* DataSourceFacade+Media.swift */, + 2AB12E4529362F27006BC925 /* DataSourceFacade+Translate.swift */, DB697DD5278F4C29004EF2F7 /* DataSourceProvider.swift */, DB697DDA278F4DE3004EF2F7 /* DataSourceProvider+StatusTableViewCellDelegate.swift */, DB023D2927A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift */, @@ -3286,6 +3289,7 @@ DBF1572F27046F1A00EC00B7 /* SecondaryPlaceholderViewController.swift in Sources */, 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */, DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, + 2AB12E4629362F27006BC925 /* DataSourceFacade+Translate.swift in Sources */, DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index ac9da6e81..9d83d1073 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -393,6 +393,12 @@ extension DataSourceFacade { alertController.addAction(cancelAction) dependency.present(alertController, animated: true) + case let .translateStatus(translationContext): + guard let status = menuContext.status else { return } + try await DataSourceFacade.translateStatus( + provider: dependency, + status: status + ) } } // end func } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift new file mode 100644 index 000000000..61e836ed2 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift @@ -0,0 +1,24 @@ +// +// DataSourceFacade+Translate.swift +// Mastodon +// +// Created by Marcus Kida on 29.11.22. +// + +import UIKit +import CoreData +import CoreDataStack +import MastodonCore + +extension DataSourceFacade { + public static func translateStatus( + provider: UIViewController & NeedsDependency & AuthContextProvider, + status: ManagedObjectRecord + ) async throws { + let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() + await selectionFeedbackGenerator.selectionChanged() + + let status = status.object(in: provider.context.managedObjectContext) + status?.translatedContent = "LOREM IPSUM TRANSLATED TEXT" + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index c9850a0d3..3bb57302a 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -86,6 +86,14 @@ extension StatusTableViewCell { self.accessibilityLabel = accessibilityLabel } .store(in: &_disposeBag) + + statusView.viewModel + .$isTranslated + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] _ in + self?.invalidateIntrinsicContentSize() + }) + .store(in: &_disposeBag) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift index 350bf8660..5cc6f6596 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift @@ -81,6 +81,14 @@ extension StatusThreadRootTableViewCell { // a11y statusView.contentMetaText.textView.isAccessibilityElement = true statusView.contentMetaText.textView.isSelectable = true + + statusView.viewModel + .$isTranslated + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] _ in + self?.invalidateIntrinsicContentSize() + }) + .store(in: &disposeBag) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift index 0c7291913..1f46a6ce1 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift @@ -99,6 +99,8 @@ public final class Status: NSManagedObject { @NSManaged public private(set) var deletedAt: Date? // sourcery: autoUpdatableObject @NSManaged public private(set) var revealedAt: Date? + + @Published public var translatedContent: String? } extension Status { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift index 714cf676d..11039875a 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift @@ -37,6 +37,7 @@ extension NotificationView { @Published public var isMyself = false @Published public var isMuting = false @Published public var isBlocking = false + @Published public var isTranslated = false @Published public var timestamp: Date? @@ -203,20 +204,27 @@ extension NotificationView.ViewModel { $authorName, $isMuting, $isBlocking, - $isMyself + Publishers.CombineLatest( + $isMyself, + $isTranslated + ) ) - .sink { authorName, isMuting, isBlocking, isMyself in + .sink { authorName, isMuting, isBlocking, comb2 in guard let name = authorName?.string else { notificationView.menuButton.menu = nil return } + let (isMyself, isTranslated) = comb2 + let menuContext = NotificationView.AuthorMenuContext( name: name, isMuting: isMuting, isBlocking: isBlocking, isMyself: isMyself, - isBookmarking: false // no bookmark action display for notification item + isBookmarking: false, // no bookmark action display for notification item + isTranslated: isTranslated, + statusLanguage: "" ) let (menu, actions) = notificationView.setupAuthorMenu(menuContext: menuContext) notificationView.menuButton.menu = menu diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift index 0631875c0..c930a7b66 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift @@ -149,12 +149,21 @@ extension StatusAuthorView { public let isBlocking: Bool public let isMyself: Bool public let isBookmarking: Bool + + public let isTranslated: Bool + public let statusLanguage: String? } public func setupAuthorMenu(menuContext: AuthorMenuContext) -> (UIMenu, [UIAccessibilityCustomAction]) { var actions = [MastodonMenu.Action]() if !menuContext.isMyself { + if let statusLanguage = menuContext.statusLanguage, !menuContext.isTranslated { + actions.append( + .translateStatus(.init(language: statusLanguage)) + ) + } + actions.append(contentsOf: [ .muteUser(.init( name: menuContext.name, diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index 47e4f18ff..b562e40dc 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -55,6 +55,14 @@ extension StatusView { configurePoll(status: status) configureToolbar(status: status) configureFilter(status: status) + + status.$translatedContent + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] _ in + self?.configureTranslated(status: status) + } + .store(in: &disposeBag) } } @@ -231,7 +239,28 @@ extension StatusView { .store(in: &disposeBag) } + func configureTranslated(status: Status) { + guard + let translatedContent = status.translatedContent + else { return } + + // content + do { + let content = MastodonContent(content: translatedContent, emojis: status.emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + viewModel.content = metaContent + viewModel.isTranslated = true + } catch { + assertionFailure(error.localizedDescription) + viewModel.content = PlaintextMetaContent(string: "") + } + } + private func configureContent(status: Status) { + guard status.translatedContent == nil else { + return configureTranslated(status: status) + } + let status = status.reblog ?? status // spoilerText @@ -254,6 +283,7 @@ extension StatusView { let content = MastodonContent(content: status.content, emojis: status.emojis.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.content = metaContent + viewModel.isTranslated = false } catch { assertionFailure(error.localizedDescription) viewModel.content = PlaintextMetaContent(string: "") diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 21ce0ae74..b56bba2e1 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -17,6 +17,7 @@ import MastodonCommon import MastodonExtension import MastodonLocalization import MastodonSDK +import MastodonMeta extension StatusView { public final class ViewModel: ObservableObject { @@ -27,7 +28,8 @@ extension StatusView { let logger = Logger(subsystem: "StatusView", category: "ViewModel") public var authContext: AuthContext? - + public var originalStatus: Status? + // Header @Published public var header: Header = .none @@ -42,6 +44,7 @@ extension StatusView { @Published public var isMyself = false @Published public var isMuting = false @Published public var isBlocking = false + @Published public var isTranslated = false @Published public var timestamp: Date? public var timestampFormatter: ((_ date: Date) -> String)? @@ -134,12 +137,13 @@ extension StatusView { isContentSensitive = false isMediaSensitive = false isSensitiveToggled = false + isTranslated = false activeFilters = [] filterContext = nil } - init() { + init() { // isReblogEnabled Publishers.CombineLatest( $visibility, @@ -581,15 +585,21 @@ extension StatusView.ViewModel { $isBlocking, $isBookmark ) + let publishersThree = Publishers.CombineLatest( + $isTranslated, + $language + ) - Publishers.CombineLatest( + Publishers.CombineLatest3( publisherOne.eraseToAnyPublisher(), - publishersTwo.eraseToAnyPublisher() + publishersTwo.eraseToAnyPublisher(), + publishersThree.eraseToAnyPublisher() ).eraseToAnyPublisher() - .sink { tupleOne, tupleTwo in + .sink { tupleOne, tupleTwo, tupleThree in let (authorName, isMyself) = tupleOne let (isMuting, isBlocking, isBookmark) = tupleTwo - + let (isTranslated, language) = tupleThree + guard let name = authorName?.string else { statusView.authorView.menuButton.menu = nil return @@ -600,7 +610,9 @@ extension StatusView.ViewModel { isMuting: isMuting, isBlocking: isBlocking, isMyself: isMyself, - isBookmarking: isBookmark + isBookmarking: isBookmark, + isTranslated: isTranslated, + statusLanguage: language ) let (menu, actions) = authorView.setupAuthorMenu(menuContext: menuContext) authorView.menuButton.menu = menu diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 30f62eaf7..bd901b08a 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -275,6 +275,16 @@ extension StatusView { // statusMetricView statusMetricView.delegate = self + + // status translation + viewModel.$isTranslated.sink { [weak self] isTranslated in + guard + let self = self, + let status = self.viewModel.originalStatus + else { return } + self.configureTranslated(status: status) + } + .store(in: &disposeBag) } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift index 422494328..e2e887db3 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift @@ -40,6 +40,7 @@ public enum MastodonMenu { extension MastodonMenu { public enum Action { + case translateStatus(TranslateStatusActionContext) case muteUser(MuteUserActionContext) case blockUser(BlockUserActionContext) case reportUser(ReportUserActionContext) @@ -126,6 +127,15 @@ extension MastodonMenu { delegate.menuAction(self) } return deleteAction + case let .translateStatus(context): + let translateAction = BuiltAction( + title: String(format: "Translate from %@", context.language), + image: UIImage(systemName: "character.book.closed") + ) { [weak delegate] in + guard let delegate = delegate else { return } + delegate.menuAction(self) + } + return translateAction } // end switch } // end func build } // end enum Action @@ -225,4 +235,12 @@ extension MastodonMenu { self.showReblogs = showReblogs } } + + public struct TranslateStatusActionContext { + public let language: String + + public init(language: String) { + self.language = language + } + } }