diff --git a/Localization/app.json b/Localization/app.json index c4a701948..82f1f7516 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -51,6 +51,11 @@ "clean_cache": { "title": "Clean Cache", "message": "Successfully cleaned %s cache." + }, + "translation_failed": { + "title": "Note", + "message": "Translation failed. Maybe the administrator has not enabled translations on this server or this server is running an older version of Mastodon where translations are not yet supported.", + "button": "OK" } }, "controls": { @@ -91,7 +96,11 @@ "block_domain": "Block %s", "unblock_domain": "Unblock %s", "settings": "Settings", - "delete": "Delete" + "delete": "Delete", + "translate_post": { + "title": "Translate from %s", + "unknown_language": "Unknown" + } }, "tabs": { "home": "Home", @@ -168,6 +177,11 @@ "private": "Only their followers can see this post.", "private_from_me": "Only my followers can see this post.", "direct": "Only mentioned user can see this post." + }, + "translation": { + "translated_from": "Translated from %s", + "unknown_language": "Unknown", + "show_original": "Shown Original" } }, "friendship": { 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/Diffable/Notification/NotificationSection.swift b/Mastodon/Diffable/Notification/NotificationSection.swift index 387affbc7..0271aac20 100644 --- a/Mastodon/Diffable/Notification/NotificationSection.swift +++ b/Mastodon/Diffable/Notification/NotificationSection.swift @@ -76,6 +76,7 @@ extension NotificationSection { viewModel: NotificationTableViewCell.ViewModel, configuration: Configuration ) { + cell.notificationView.viewModel.context = context cell.notificationView.viewModel.authContext = configuration.authContext StatusSection.setupStatusPollDataSource( diff --git a/Mastodon/Diffable/Report/ReportSection.swift b/Mastodon/Diffable/Report/ReportSection.swift index ba3c5525a..4c8fd4345 100644 --- a/Mastodon/Diffable/Report/ReportSection.swift +++ b/Mastodon/Diffable/Report/ReportSection.swift @@ -107,6 +107,7 @@ extension ReportSection { statusView: cell.statusView ) + cell.statusView.viewModel.context = context cell.statusView.viewModel.authContext = configuration.authContext cell.configure( diff --git a/Mastodon/Diffable/Search/SearchResultSection.swift b/Mastodon/Diffable/Search/SearchResultSection.swift index 8a5d7e75f..e4dcad891 100644 --- a/Mastodon/Diffable/Search/SearchResultSection.swift +++ b/Mastodon/Diffable/Search/SearchResultSection.swift @@ -104,6 +104,7 @@ extension SearchResultSection { statusView: cell.statusView ) + cell.statusView.viewModel.context = context cell.statusView.viewModel.authContext = configuration.authContext cell.configure( diff --git a/Mastodon/Diffable/Status/StatusSection.swift b/Mastodon/Diffable/Status/StatusSection.swift index 38b8e641f..8ccb32c0c 100644 --- a/Mastodon/Diffable/Status/StatusSection.swift +++ b/Mastodon/Diffable/Status/StatusSection.swift @@ -27,6 +27,7 @@ extension StatusSection { static let logger = Logger(subsystem: "StatusSection", category: "logic") struct Configuration { + let context: AppContext let authContext: AuthContext weak var statusTableViewCellDelegate: StatusTableViewCellDelegate? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? @@ -250,6 +251,7 @@ extension StatusSection { statusView: cell.statusView ) + cell.statusView.viewModel.context = configuration.context cell.statusView.viewModel.authContext = configuration.authContext cell.configure( diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index ac9da6e81..b28daf1b5 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -393,6 +393,18 @@ extension DataSourceFacade { alertController.addAction(cancelAction) dependency.present(alertController, animated: true) + case .translateStatus: + guard let status = menuContext.status else { return } + do { + try await DataSourceFacade.translateStatus( + provider: dependency, + status: status + ) + } catch TranslationFailure.emptyOrInvalidResponse { + let alertController = UIAlertController(title: L10n.Common.Alerts.TranslationFailed.title, message: L10n.Common.Alerts.TranslationFailed.message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: L10n.Common.Alerts.TranslationFailed.button, style: .default)) + dependency.present(alertController, animated: true) + } } } // end func } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift new file mode 100644 index 000000000..cd4cec4b0 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift @@ -0,0 +1,70 @@ +// +// DataSourceFacade+Translate.swift +// Mastodon +// +// Created by Marcus Kida on 29.11.22. +// + +import UIKit +import CoreData +import CoreDataStack +import MastodonCore + +typealias Provider = UIViewController & NeedsDependency & AuthContextProvider + +extension DataSourceFacade { + enum TranslationFailure: Error { + case emptyOrInvalidResponse + } + + public static func translateStatus( + provider: Provider, + status: ManagedObjectRecord + ) async throws { + let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() + await selectionFeedbackGenerator.selectionChanged() + + guard + let status = status.object(in: provider.context.managedObjectContext) + else { + return + } + + if let reblog = status.reblog { + try await translateAndApply(provider: provider, status: reblog) + } else { + try await translateAndApply(provider: provider, status: status) + } + } +} + +private extension DataSourceFacade { + static func translateStatus(provider: Provider, status: Status) async throws -> String? { + do { + let value = try await provider.context + .apiService + .translateStatus( + statusID: status.id, + authenticationBox: provider.authContext.mastodonAuthenticationBox + ).value + + guard let content = value.content else { + throw TranslationFailure.emptyOrInvalidResponse + } + + return content + } catch { + throw TranslationFailure.emptyOrInvalidResponse + } + } + + static func translateAndApply(provider: Provider, status: Status) async throws { + do { + let translated = try await translateStatus(provider: provider, status: status) + status.update(translatedContent: translated) + } catch { + status.update(translatedContent: nil) + throw TranslationFailure.emptyOrInvalidResponse + } + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift index c157b7086..be3f4dbab 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -360,6 +360,12 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte return } + if let cell = cell as? StatusTableViewCell { + DispatchQueue.main.async { + cell.statusView.viewModel.isCurrentlyTranslating = true + } + } + try await DataSourceFacade.responseToMenuAction( dependency: self, action: action, diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+Diffable.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+Diffable.swift index 64b4d3b6a..caa1f8460 100644 --- a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+Diffable.swift +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+Diffable.swift @@ -18,6 +18,7 @@ extension DiscoveryCommunityViewModel { tableView: tableView, context: context, configuration: StatusSection.Configuration( + context: context, authContext: authContext, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift index afa0594d5..f36812538 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift @@ -18,6 +18,7 @@ extension DiscoveryPostsViewModel { tableView: tableView, context: context, configuration: StatusSection.Configuration( + context: context, authContext: authContext, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index 8d8b0126a..c7c0a3bd7 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -20,6 +20,7 @@ extension HashtagTimelineViewModel { tableView: tableView, context: context, configuration: StatusSection.Configuration( + context: context, authContext: authContext, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 29bff623b..ff3224d3d 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -22,6 +22,7 @@ extension HomeTimelineViewModel { tableView: tableView, context: context, configuration: StatusSection.Configuration( + context: context, authContext: authContext, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift index 69075a8ce..bb9148687 100644 --- a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift @@ -17,6 +17,7 @@ extension BookmarkViewModel { tableView: tableView, context: context, configuration: StatusSection.Configuration( + context: context, authContext: authContext, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift index 3723dae5d..e0f741f62 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift @@ -17,6 +17,7 @@ extension FavoriteViewModel { tableView: tableView, context: context, configuration: StatusSection.Configuration( + context: context, authContext: authContext, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift index 863d7b44e..4992e653a 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -18,6 +18,7 @@ extension UserTimelineViewModel { tableView: tableView, context: context, configuration: StatusSection.Configuration( + context: context, authContext: authContext, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index c9850a0d3..b8482c564 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 + .$translatedFromLanguage + .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..73b700e27 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 + .$translatedFromLanguage + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] _ in + self?.invalidateIntrinsicContentSize() + }) + .store(in: &disposeBag) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift index 834d478e6..8846c8b95 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift @@ -24,6 +24,7 @@ extension ThreadViewModel { tableView: tableView, context: context, configuration: StatusSection.Configuration( + context: context, authContext: authContext, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion index 2145ac780..e660b0a08 100644 --- a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - CoreData 5.xcdatamodel + CoreData 6.xcdatamodel diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 6.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 6.xcdatamodel/contents new file mode 100644 index 000000000..30e89ccb4 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 6.xcdatamodel/contents @@ -0,0 +1,260 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Instance.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Instance.swift index cc21e8351..c11a92b76 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Instance.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Instance.swift @@ -16,7 +16,8 @@ public final class Instance: NSManagedObject { @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var configurationRaw: Data? - + @NSManaged public private(set) var configurationV2Raw: Data? + // MARK: one-to-many relationships @NSManaged public var authentications: Set } @@ -44,6 +45,10 @@ extension Instance { self.configurationRaw = configurationRaw } + public func update(configurationV2Raw: Data?) { + self.configurationV2Raw = configurationV2Raw + } + public func didUpdate(at networkDate: Date) { self.updatedAt = networkDate } diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift index 0c7291913..3bd1df794 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift @@ -99,6 +99,9 @@ public final class Status: NSManagedObject { @NSManaged public private(set) var deletedAt: Date? // sourcery: autoUpdatableObject @NSManaged public private(set) var revealedAt: Date? + + // sourcery: autoUpdatableObject + @NSManaged public private(set) var translatedContent: String? } extension Status { @@ -495,6 +498,11 @@ extension Status: AutoUpdatableObject { self.revealedAt = revealedAt } } + public func update(translatedContent: String?) { + if self.translatedContent != translatedContent { + self.translatedContent = translatedContent + } + } public func update(attachments: [MastodonAttachment]) { if self.attachments != attachments { self.attachments = attachments diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift index 7e925b665..eebb16be4 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift @@ -25,8 +25,45 @@ extension Instance { } extension Instance { - public var canFollowTags: Bool { - guard let majorVersionString = version?.split(separator: ".").first else { return false } - return Int(majorVersionString) == 4 // following Tags is support beginning with Mastodon v4.0.0 + public var configurationV2: Mastodon.Entity.V2.Instance.Configuration? { + guard + let configurationRaw = configurationV2Raw, + let configuration = try? JSONDecoder().decode( + Mastodon.Entity.V2.Instance.Configuration.self, + from: configurationRaw + ) + else { + return nil + } + + return configuration + } + + static func encodeV2(configuration: Mastodon.Entity.V2.Instance.Configuration) -> Data? { + return try? JSONEncoder().encode(configuration) + } +} + +extension Instance { + public var canFollowTags: Bool { + version?.majorServerVersion(greaterThanOrEquals: 4) ?? false // following Tags is support beginning with Mastodon v4.0.0 + } + + var isTranslationEnabled: Bool { + if let configuration = configurationV2 { + return configuration.translation?.enabled == true + } + return false + } +} + +extension String { + public func majorServerVersion(greaterThanOrEquals comparedVersion: Int) -> Bool { + guard + let majorVersionString = split(separator: ".").first, + let majorVersionInt = Int(majorVersionString) + else { return false } + + return majorVersionInt >= comparedVersion } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Instance.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Instance.swift index 93bfcf09a..eb39b5585 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Instance.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Instance.swift @@ -20,4 +20,9 @@ extension APIService { return Mastodon.API.Instance.instance(session: session, domain: domain) } + public func instanceV2( + domain: String + ) -> AnyPublisher, Error> { + return Mastodon.API.V2.Instance.instance(session: session, domain: domain) + } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status+Translate.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status+Translate.swift new file mode 100644 index 000000000..a802d6489 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status+Translate.swift @@ -0,0 +1,34 @@ +// +// APIService+Status+Translate.swift +// Mastodon +// +// Created by Marcus Kida on 02.12.2022. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + public func translateStatus( + statusID: Mastodon.Entity.Status.ID, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let response = try await Mastodon.API.Statuses.translate( + session: session, + domain: domain, + statusID: statusID, + authorization: authorization + ).singleOutput() + + return response + } + +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+InstanceV2.swift b/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+InstanceV2.swift new file mode 100644 index 000000000..19e188133 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+InstanceV2.swift @@ -0,0 +1,81 @@ +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + public struct PersistContext { + public let domain: String + public let entity: Mastodon.Entity.V2.Instance + public let networkDate: Date + public let log: Logger + + public init( + domain: String, + entity: Mastodon.Entity.V2.Instance, + networkDate: Date, + log: Logger + ) { + self.domain = domain + self.entity = entity + self.networkDate = networkDate + self.log = log + } + } + + static func createOrMergeInstance( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> (instance: Instance, isCreated: Bool) { + // fetch old mastodon user + let old: Instance? = { + let request = Instance.sortedFetchRequest + request.predicate = Instance.predicate(domain: context.domain) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let old = old { + APIService.CoreData.merge( + instance: old, + context: context + ) + return (old, false) + } else { + let instance = Instance.insert( + into: managedObjectContext, + property: Instance.Property(domain: context.domain, version: context.entity.version) + ) + let configurationRaw = context.entity.configuration.flatMap { Instance.encodeV2(configuration: $0) } + instance.update(configurationV2Raw: configurationRaw) + + return (instance, true) + } + } + +} + +extension APIService.CoreData { + + static func merge( + instance: Instance, + context: PersistContext + ) { + guard context.networkDate > instance.updatedAt else { return } + + let configurationRaw = context.entity.configuration.flatMap { Instance.encodeV2(configuration: $0) } + instance.update(configurationV2Raw: configurationRaw) + instance.version = context.entity.version + + instance.didUpdate(at: context.networkDate) + } + +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift index 99ad6d0a2..0745e2f37 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift @@ -50,42 +50,18 @@ extension InstanceService { func updateInstance(domain: String) { guard let apiService = self.apiService else { return } apiService.instance(domain: domain) - .flatMap { response -> AnyPublisher, Error> in - let managedObjectContext = self.backgroundManagedObjectContext - return managedObjectContext.performChanges { - // get instance - let (instance, _) = APIService.CoreData.createOrMergeInstance( - into: managedObjectContext, - domain: domain, - entity: response.value, - networkDate: response.networkDate, - log: Logger(subsystem: "Update", category: "InstanceService") - ) - - // update relationship - let request = MastodonAuthentication.sortedFetchRequest - request.predicate = MastodonAuthentication.predicate(domain: domain) - request.returnsObjectsAsFaults = false - do { - let authentications = try managedObjectContext.fetch(request) - for authentication in authentications { - authentication.update(instance: instance) - } - } catch { - assertionFailure(error.localizedDescription) - } + .flatMap { [unowned self] response -> AnyPublisher in + if response.value.version?.majorServerVersion(greaterThanOrEquals: 4) == true { + return apiService.instanceV2(domain: domain) + .flatMap { return self.updateInstanceV2(domain: domain, response: $0) } + .eraseToAnyPublisher() + } else { + return self.updateInstance(domain: domain, response: response) } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() } +// .flatMap { [unowned self] response -> AnyPublisher in +// return +// } .sink { [weak self] completion in guard let self = self else { return } switch completion { @@ -100,6 +76,82 @@ extension InstanceService { } .store(in: &disposeBag) } + + private func updateInstance(domain: String, response: Mastodon.Response.Content) -> AnyPublisher { + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + // get instance + let (instance, _) = APIService.CoreData.createOrMergeInstance( + into: managedObjectContext, + domain: domain, + entity: response.value, + networkDate: response.networkDate, + log: Logger(subsystem: "Update", category: "InstanceService") + ) + + // update relationship + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(domain: domain) + request.returnsObjectsAsFaults = false + do { + let authentications = try managedObjectContext.fetch(request) + for authentication in authentications { + authentication.update(instance: instance) + } + } catch { + assertionFailure(error.localizedDescription) + } + } + .setFailureType(to: Error.self) + .tryMap { result in + switch result { + case .success: + break + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + + private func updateInstanceV2(domain: String, response: Mastodon.Response.Content) -> AnyPublisher { + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + // get instance + let (instance, _) = APIService.CoreData.createOrMergeInstance( + in: managedObjectContext, + context: .init( + domain: domain, + entity: response.value, + networkDate: response.networkDate, + log: Logger(subsystem: "Update", category: "InstanceService") + ) + ) + + // update relationship + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(domain: domain) + request.returnsObjectsAsFaults = false + do { + let authentications = try managedObjectContext.fetch(request) + for authentication in authentications { + authentication.update(instance: instance) + } + } catch { + assertionFailure(error.localizedDescription) + } + } + .setFailureType(to: Error.self) + .tryMap { result in + switch result { + case .success: + break + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } } public extension InstanceService { diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index bafed05f6..69ff5e97f 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -87,6 +87,14 @@ public enum L10n { /// Sign Up Failure public static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title", fallback: "Sign Up Failure") } + public enum TranslationFailed { + /// OK + public static let button = L10n.tr("Localizable", "Common.Alerts.TranslationFailed.Button", fallback: "OK") + /// Translation failed. Maybe the administrator has not enabled translations on this server or this server is running an older version of Mastodon where translations are not yet supported. + public static let message = L10n.tr("Localizable", "Common.Alerts.TranslationFailed.Message", fallback: "Translation failed. Maybe the administrator has not enabled translations on this server or this server is running an older version of Mastodon where translations are not yet supported.") + /// Note + public static let title = L10n.tr("Localizable", "Common.Alerts.TranslationFailed.Title", fallback: "Note") + } public enum VoteFailure { /// The poll has ended public static let pollEnded = L10n.tr("Localizable", "Common.Alerts.VoteFailure.PollEnded", fallback: "The poll has ended") @@ -178,6 +186,14 @@ public enum L10n { public static func unblockDomain(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Actions.UnblockDomain", String(describing: p1), fallback: "Unblock %@") } + public enum TranslatePost { + /// Translate from %@ + public static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Actions.TranslatePost.Title", String(describing: p1), fallback: "Translate from %@") + } + /// Unknown + public static let unknownLanguage = L10n.tr("Localizable", "Common.Controls.Actions.TranslatePost.UnknownLanguage", fallback: "Unknown") + } } public enum Friendship { /// Block @@ -352,6 +368,16 @@ public enum L10n { /// URL public static let url = L10n.tr("Localizable", "Common.Controls.Status.Tag.Url", fallback: "URL") } + public enum Translation { + /// Show Original + public static let showOriginal = L10n.tr("Localizable", "Common.Controls.Status.Translation.ShowOriginal", fallback: "Show Original") + /// Translated from %@ + public static func translatedFrom(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Translation.TranslatedFrom", String(describing: p1), fallback: "Translated from %@") + } + /// Unknown + public static let unknownLanguage = L10n.tr("Localizable", "Common.Controls.Status.Translation.UnknownLanguage", fallback: "Unknown") + } public enum Visibility { /// Only mentioned user can see this post. public static let direct = L10n.tr("Localizable", "Common.Controls.Status.Visibility.Direct", fallback: "Only mentioned user can see this post.") diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index 5204a1176..2ec8830b0 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -22,6 +22,9 @@ Please check your internet connection."; "Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?"; "Common.Alerts.SignOut.Title" = "Sign Out"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; +"Common.Alerts.TranslationFailed.Title" = "Note"; +"Common.Alerts.TranslationFailed.Message" = "Translation failed. Maybe the administrator has not enabled translations on this server or this server is running an older version of Mastodon where translations are not yet supported."; +"Common.Alerts.TranslationFailed.Button" = "OK"; "Common.Alerts.VoteFailure.PollEnded" = "The poll has ended"; "Common.Alerts.VoteFailure.Title" = "Vote Failure"; "Common.Controls.Actions.Add" = "Add"; @@ -59,6 +62,8 @@ Please check your internet connection."; "Common.Controls.Actions.SignUp" = "Create account"; "Common.Controls.Actions.Skip" = "Skip"; "Common.Controls.Actions.TakePhoto" = "Take Photo"; +"Common.Controls.Actions.TranslatePost.Title" = "Translate from %@"; +"Common.Controls.Actions.TranslatePost.UnknownLanguage" = "Unknown"; "Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Actions.UnblockDomain" = "Unblock %@"; "Common.Controls.Friendship.Block" = "Block"; @@ -124,6 +129,9 @@ Please check your internet connection."; "Common.Controls.Status.Tag.Mention" = "Mention"; "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.TapToReveal" = "Tap to reveal"; +"Common.Controls.Status.Translation.ShowOriginal" = "Show Original"; +"Common.Controls.Status.Translation.TranslatedFrom" = "Translated from %@"; +"Common.Controls.Status.Translation.UnknownLanguage" = "Unknown"; "Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Status.UserRepliedTo" = "Replied to %@"; "Common.Controls.Status.Visibility.Direct" = "Only mentioned user can see this post."; @@ -467,4 +475,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"; \ No newline at end of file +"Scene.Wizard.NewInMastodon" = "New in Mastodon"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings index 2a3f1efbf..9afcd60d9 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings @@ -22,6 +22,9 @@ Please check your internet connection."; "Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?"; "Common.Alerts.SignOut.Title" = "Sign Out"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; +"Common.Alerts.TranslationFailed.Title" = "Note"; +"Common.Alerts.TranslationFailed.Message" = "Translation failed. Maybe the administrator has not enabled translations on this server or this server is running an older version of Mastodon where translations are not yet supported."; +"Common.Alerts.TranslationFailed.Button" = "OK"; "Common.Alerts.VoteFailure.PollEnded" = "The poll has ended"; "Common.Alerts.VoteFailure.Title" = "Vote Failure"; "Common.Controls.Actions.Add" = "Add"; @@ -59,6 +62,8 @@ Please check your internet connection."; "Common.Controls.Actions.SignUp" = "Create account"; "Common.Controls.Actions.Skip" = "Skip"; "Common.Controls.Actions.TakePhoto" = "Take Photo"; +"Common.Controls.Actions.TranslatePost.Title" = "Translate from %@"; +"Common.Controls.Actions.TranslatePost.UnknownLanguage" = "Unknown"; "Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Actions.UnblockDomain" = "Unblock %@"; "Common.Controls.Friendship.Block" = "Block"; @@ -124,6 +129,9 @@ Please check your internet connection."; "Common.Controls.Status.Tag.Mention" = "Mention"; "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.TapToReveal" = "Tap to reveal"; +"Common.Controls.Status.Translation.ShowOriginal" = "Show Original"; +"Common.Controls.Status.Translation.TranslatedFrom" = "Translated from %@"; +"Common.Controls.Status.Translation.UnknownLanguage" = "Unknown"; "Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Status.UserRepliedTo" = "Replied to %@"; "Common.Controls.Status.Visibility.Direct" = "Only mentioned user can see this post."; @@ -461,4 +469,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"; \ No newline at end of file +"Scene.Wizard.NewInMastodon" = "New in Mastodon"; diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+Translate.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+Translate.swift new file mode 100644 index 000000000..c3e790b67 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+Translate.swift @@ -0,0 +1,48 @@ +// +// Mastodon+API+Statuses+Translate.swift +// +// +// Created by Marcus Kida on 02.12.2022. +// + +import Foundation +import Combine + +extension Mastodon.API.Statuses { + + private static func translateEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("statuses") + .appendingPathComponent(statusID) + .appendingPathComponent("translate") + } + + /// Translate Status + /// + /// Translate a given Status. + /// + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func translate( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: translateEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Translation.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Instance.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Instance.swift new file mode 100644 index 000000000..e276fddba --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Instance.swift @@ -0,0 +1,50 @@ +import Foundation +import Combine + +extension Mastodon.API.V2.Instance { + + private static func instanceEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("instance") + } + + /// Information about the server + /// + /// - Since: 4.0.0 + /// - Version: 4.0.0 + /// # Last Update + /// 2022/12/09 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/instance/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - Returns: `AnyPublisher` contains `Instance` nested in the response + public static func instance( + session: URLSession, + domain: String + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: instanceEndpointURL(domain: domain), + query: nil, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value: Mastodon.Entity.V2.Instance + + do { + value = try Mastodon.API.decode(type: Mastodon.Entity.V2.Instance.self, from: data, response: response) + } catch { + if let response = response as? HTTPURLResponse, 400 ..< 500 ~= response.statusCode { + // For example, AUTHORIZED_FETCH may result in authentication errors + value = Mastodon.Entity.V2.Instance(domain: domain) + } else { + throw error + } + } + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index a1eb47873..f85d50bd0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -126,6 +126,7 @@ extension Mastodon.API.V2 { public enum Search { } public enum Suggestions { } public enum Media { } + public enum Instance { } } extension Mastodon.API { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift new file mode 100644 index 000000000..d662c24c2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift @@ -0,0 +1,108 @@ +import Foundation + +extension Mastodon.Entity.V2 { + /// Instance + /// + /// - Since: 4.0.0 + /// - Version: 4.0.3 + /// # Last Update + /// 2022/12/09 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/instance/) + public struct Instance: Codable { + + public let domain: String? + public let title: String + public let description: String + public let shortDescription: String? + public let email: String? + public let version: String? + public let languages: [String]? // (ISO 639 Part 1-5 language codes) + public let registrations: Mastodon.Entity.V2.Instance.Registrations? + public let approvalRequired: Bool? + public let invitesEnabled: Bool? + public let urls: Mastodon.Entity.Instance.InstanceURL? + public let statistics: Mastodon.Entity.Instance.Statistics? + + public let thumbnail: Thumbnail? + public let contactAccount: Mastodon.Entity.Account? + public let rules: [Mastodon.Entity.Instance.Rule]? + + // https://github.com/mastodon/mastodon/pull/16485 + public let configuration: Configuration? + + public init(domain: String, approvalRequired: Bool? = nil) { + self.domain = domain + self.title = domain + self.description = "" + self.shortDescription = nil + self.email = "" + self.version = nil + self.languages = nil + self.registrations = nil + self.approvalRequired = approvalRequired + self.invitesEnabled = nil + self.urls = nil + self.statistics = nil + self.thumbnail = nil + self.contactAccount = nil + self.rules = nil + self.configuration = nil + } + + enum CodingKeys: String, CodingKey { + case domain + case title + case description + case shortDescription = "short_description" + case email + case version + case languages + case registrations + case approvalRequired = "approval_required" + case invitesEnabled = "invites_enabled" + case urls + case statistics = "stats" + + case thumbnail + case contactAccount = "contact_account" + case rules + + case configuration + } + } +} + +extension Mastodon.Entity.V2.Instance { + public struct Configuration: Codable { + public let statuses: Mastodon.Entity.Instance.Configuration.Statuses? + public let mediaAttachments: Mastodon.Entity.Instance.Configuration.MediaAttachments? + public let polls: Mastodon.Entity.Instance.Configuration.Polls? + public let translation: Mastodon.Entity.V2.Instance.Configuration.Translation? + + enum CodingKeys: String, CodingKey { + case statuses + case mediaAttachments = "media_attachments" + case polls + case translation + } + } +} + +extension Mastodon.Entity.V2.Instance { + public struct Registrations: Codable { + public let enabled: Bool + } +} + +extension Mastodon.Entity.V2.Instance.Configuration { + public struct Translation: Codable { + public let enabled: Bool + } +} + +extension Mastodon.Entity.V2.Instance { + public struct Thumbnail: Codable { + public let url: String? + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Translation.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Translation.swift new file mode 100644 index 000000000..f500453f1 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Translation.swift @@ -0,0 +1,22 @@ +// +// Mastodon+Entity+Translation.swift +// +// +// Created by Marcus Kida on 02.12.22. +// + +import Foundation + +extension Mastodon.Entity { + public struct Translation: Codable { + public let content: String? + public let sourceLanguage: String? + public let provider: String? + + enum CodingKeys: String, CodingKey { + case content + case sourceLanguage = "detected_source_language" + case provider + } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift index 714cf676d..ed038f47f 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift @@ -23,7 +23,8 @@ extension NotificationView { public var objects = Set() let logger = Logger(subsystem: "NotificationView", category: "ViewModel") - + + @Published public var context: AppContext? @Published public var authContext: AuthContext? @Published public var type: MastodonNotificationType? @@ -37,6 +38,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? @@ -56,6 +58,9 @@ extension NotificationView.ViewModel { bindAuthorMenu(notificationView: notificationView) bindFollowRequest(notificationView: notificationView) + $context + .assign(to: \.context, on: notificationView.statusView.viewModel) + .store(in: &disposeBag) $authContext .assign(to: \.authContext, on: notificationView.statusView.viewModel) .store(in: &disposeBag) @@ -203,20 +208,44 @@ extension NotificationView.ViewModel { $authorName, $isMuting, $isBlocking, - $isMyself + Publishers.CombineLatest( + $isMyself, + $isTranslated + ) ) - .sink { authorName, isMuting, isBlocking, isMyself in + .sink { [weak self] authorName, isMuting, isBlocking, isMyselfIsTranslated in guard let name = authorName?.string else { notificationView.menuButton.menu = nil return } + let (isMyself, isTranslated) = isMyselfIsTranslated + + lazy var instanceConfigurationV2: Mastodon.Entity.V2.Instance.Configuration? = { + guard + let self = self, + let context = self.context, + let authContext = self.authContext + else { return nil } + + var configuration: Mastodon.Entity.V2.Instance.Configuration? = nil + context.managedObjectContext.performAndWait { + guard let authentication = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext) + else { return } + configuration = authentication.instance?.configurationV2 + } + return configuration + }() + 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 + isTranslationEnabled: instanceConfigurationV2?.translation?.enabled == true, + 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..ef40ab7fc 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift @@ -149,12 +149,22 @@ extension StatusAuthorView { public let isBlocking: Bool public let isMyself: Bool public let isBookmarking: Bool + + public let isTranslationEnabled: 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.isTranslationEnabled, !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..cc369f972 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -55,6 +55,17 @@ extension StatusView { configurePoll(status: status) configureToolbar(status: status) configureFilter(status: status) + viewModel.originalStatus = status + [ + status.publisher(for: \.translatedContent), + status.reblog?.publisher(for: \.translatedContent) + ].compactMap { $0 } + .last? + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.configureTranslated(status: status) + } + .store(in: &disposeBag) } } @@ -231,7 +242,48 @@ extension StatusView { .store(in: &disposeBag) } + func revertTranslation() { + guard let originalStatus = viewModel.originalStatus else { return } + viewModel.translatedFromLanguage = nil + originalStatus.reblog?.update(translatedContent: nil) + originalStatus.update(translatedContent: nil) + configure(status: originalStatus) + } + + func configureTranslated(status: Status) { + let translatedContent: String? = { + if let translatedContent = status.reblog?.translatedContent { + return translatedContent + } + return status.translatedContent + + }() + + guard + let translatedContent = translatedContent + else { + viewModel.isCurrentlyTranslating = false + return + } + + // content + do { + let content = MastodonContent(content: translatedContent, emojis: status.emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + viewModel.content = metaContent + viewModel.translatedFromLanguage = status.reblog?.language ?? status.language + viewModel.isCurrentlyTranslating = false + } 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 +306,8 @@ extension StatusView { let content = MastodonContent(content: status.content, emojis: status.emojis.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.content = metaContent + viewModel.translatedFromLanguage = nil + viewModel.isCurrentlyTranslating = 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..09995677c 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 { @@ -26,8 +27,10 @@ extension StatusView { let logger = Logger(subsystem: "StatusView", category: "ViewModel") + public var context: AppContext? public var authContext: AuthContext? - + public var originalStatus: Status? + // Header @Published public var header: Header = .none @@ -43,6 +46,10 @@ extension StatusView { @Published public var isMuting = false @Published public var isBlocking = false + // Translation + @Published public var isCurrentlyTranslating = false + @Published public var translatedFromLanguage: String? + @Published public var timestamp: Date? public var timestampFormatter: ((_ date: Date) -> String)? @Published public var timestampText = "" @@ -134,6 +141,8 @@ extension StatusView { isContentSensitive = false isMediaSensitive = false isSensitiveToggled = false + translatedFromLanguage = nil + isCurrentlyTranslating = false activeFilters = [] filterContext = nil @@ -581,26 +590,50 @@ extension StatusView.ViewModel { $isBlocking, $isBookmark ) + let publishersThree = Publishers.CombineLatest( + $translatedFromLanguage, + $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 (translatedFromLanguage, language) = tupleThree + guard let name = authorName?.string else { statusView.authorView.menuButton.menu = nil return } + lazy var instanceConfigurationV2: Mastodon.Entity.V2.Instance.Configuration? = { + guard + let context = self.context, + let authContext = self.authContext + else { return nil } + + var configuration: Mastodon.Entity.V2.Instance.Configuration? = nil + context.managedObjectContext.performAndWait { + guard let authentication = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext) + else { return } + configuration = authentication.instance?.configurationV2 + } + return configuration + }() + let menuContext = StatusAuthorView.AuthorMenuContext( name: name, isMuting: isMuting, isBlocking: isBlocking, isMyself: isMyself, - isBookmarking: isBookmark + isBookmarking: isBookmark, + isTranslationEnabled: instanceConfigurationV2?.translation?.enabled == true, + isTranslated: translatedFromLanguage != nil, + 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..751e7929d 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -176,6 +176,50 @@ public final class StatusView: UIView { indicatorView.stopAnimating() return indicatorView }() + let isTranslatingLoadingView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.stopAnimating() + return activityIndicatorView + }() + private let translatedInfoLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + return label + }() + lazy var translatedInfoView: UIView = { + let containerView = UIView() + + let revertButton = UIButton() + revertButton.titleLabel?.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .bold)) + revertButton.setTitle(L10n.Common.Controls.Status.Translation.showOriginal, for: .normal) + revertButton.setTitleColor(Asset.Colors.brand.color, for: .normal) + revertButton.addAction(UIAction { [weak self] _ in + self?.revertTranslation() + }, for: .touchUpInside) + + [containerView, translatedInfoLabel, revertButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + } + + [translatedInfoLabel, revertButton].forEach { + containerView.addSubview($0) + } + + NSLayoutConstraint.activate([ + containerView.heightAnchor.constraint(equalToConstant: 24), + translatedInfoLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + translatedInfoLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), + revertButton.topAnchor.constraint(equalTo: containerView.topAnchor), + revertButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16), + revertButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) + ]) + + containerView.isHidden = true + + return containerView + }() // toolbar let actionToolbarAdaptiveMarginContainerView = AdaptiveMarginContainerView() @@ -217,6 +261,7 @@ public final class StatusView: UIView { setMediaDisplay(isDisplay: false) setPollDisplay(isDisplay: false) setFilterHintLabelDisplay(isDisplay: false) + setupTranslationIndicator() } public override init(frame: CGRect) { @@ -386,6 +431,10 @@ extension StatusView.Style { statusView.contentContainer.addArrangedSubview(statusView.contentMetaText.textView) statusView.containerStackView.setCustomSpacing(16, after: statusView.contentMetaText.textView) + // translated info + statusView.containerStackView.addArrangedSubview(statusView.isTranslatingLoadingView) + statusView.containerStackView.addArrangedSubview(statusView.translatedInfoView) + statusView.spoilerOverlayView.translatesAutoresizingMaskIntoConstraints = false statusView.containerStackView.addSubview(statusView.spoilerOverlayView) statusView.contentContainer.pinTo(to: statusView.spoilerOverlayView) @@ -424,7 +473,7 @@ extension StatusView.Style { statusView.pollStatusDotLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) statusView.pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) statusView.pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) - + // action toolbar statusView.actionToolbarAdaptiveMarginContainerView.contentView = statusView.actionToolbarContainer statusView.actionToolbarAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin @@ -650,6 +699,35 @@ extension StatusView: MastodonMenuDelegate { } } +extension StatusView { + func setupTranslationIndicator() { + viewModel.$isCurrentlyTranslating + .receive(on: DispatchQueue.main) + .sink { [weak self] isTranslating in + switch isTranslating { + case true: + self?.isTranslatingLoadingView.startAnimating() + case false: + self?.isTranslatingLoadingView.stopAnimating() + } + } + .store(in: &disposeBag) + + viewModel.$translatedFromLanguage + .receive(on: DispatchQueue.main) + .sink { [weak self] translatedFromLanguage in + guard let self = self else { return } + if let translatedFromLanguage = translatedFromLanguage { + self.translatedInfoLabel.text = L10n.Common.Controls.Status.Translation.translatedFrom(Locale.current.localizedString(forIdentifier: translatedFromLanguage) ?? L10n.Common.Controls.Status.Translation.unknownLanguage) + self.translatedInfoView.isHidden = false + } else { + self.translatedInfoView.isHidden = true + } + } + .store(in: &disposeBag) + } +} + #if DEBUG import SwiftUI diff --git a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift index 422494328..6fd5df772 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: L10n.Common.Controls.Actions.TranslatePost.title(Locale.current.localizedString(forIdentifier: context.language) ?? L10n.Common.Controls.Actions.TranslatePost.unknownLanguage), + 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 + } + } }