From 9affb0f6371fdca37954ef4af389726cb3b23df6 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 30 Nov 2022 16:38:31 +0100 Subject: [PATCH 01/21] feat: Implement `translatedContent` which can be used to replace the StatusView content --- Mastodon.xcodeproj/project.pbxproj | 4 +++ .../Provider/DataSourceFacade+Status.swift | 6 ++++ .../Provider/DataSourceFacade+Translate.swift | 24 +++++++++++++++ .../TableviewCell/StatusTableViewCell.swift | 8 +++++ .../StatusThreadRootTableViewCell.swift | 8 +++++ .../Entity/Mastodon/Status.swift | 2 ++ .../Content/NotificationView+ViewModel.swift | 14 +++++++-- .../View/Content/StatusAuthorView.swift | 9 ++++++ .../Content/StatusView+Configuration.swift | 30 +++++++++++++++++++ .../View/Content/StatusView+ViewModel.swift | 26 +++++++++++----- .../MastodonUI/View/Content/StatusView.swift | 10 +++++++ .../MastodonUI/View/Menu/MastodonMenu.swift | 18 +++++++++++ 12 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift 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 + } + } } From d5be87992df0c176335562a5ae1985e466c67f8b Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Fri, 2 Dec 2022 12:12:46 +0100 Subject: [PATCH 02/21] feat: Implement /translate endpoint --- .../Provider/DataSourceFacade+Translate.swift | 16 ++++++- .../API/APIService+Status+Translate.swift | 34 +++++++++++++ .../API/Mastodon+API+Statuses+Translate.swift | 48 +++++++++++++++++++ .../Entity/Mastodon+Entity+Translation.swift | 22 +++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status+Translate.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+Translate.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Translation.swift diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift index 61e836ed2..f2d439cf6 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift @@ -18,7 +18,19 @@ extension DataSourceFacade { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() - let status = status.object(in: provider.context.managedObjectContext) - status?.translatedContent = "LOREM IPSUM TRANSLATED TEXT" + guard + let status = status.object(in: provider.context.managedObjectContext) + else { + return + } + + let result = try await provider.context + .apiService + .translateStatus( + statusID: status.id, + authenticationBox: provider.authContext.mastodonAuthenticationBox + ).value + + status.translatedContent = result.content } } 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/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/Entity/Mastodon+Entity+Translation.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Translation.swift new file mode 100644 index 000000000..b8993de9e --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Translation.swift @@ -0,0 +1,22 @@ +// +// File.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 + } + } +} From ac76e7f4355d20e6b9707955a0ee34ac9a2c5481 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Fri, 2 Dec 2022 14:42:50 +0100 Subject: [PATCH 03/21] feat: Implement translation of reposts --- .../Provider/DataSourceFacade+Translate.swift | 21 ++++++++++++------ .../Content/StatusView+Configuration.swift | 22 +++++++++++++++++-- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift index f2d439cf6..08557a4bf 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift @@ -24,13 +24,20 @@ extension DataSourceFacade { return } - let result = try await provider.context - .apiService - .translateStatus( - statusID: status.id, - authenticationBox: provider.authContext.mastodonAuthenticationBox - ).value + func translate(status: Status) async throws -> String? { + let value = try await provider.context + .apiService + .translateStatus( + statusID: status.id, + authenticationBox: provider.authContext.mastodonAuthenticationBox + ).value + return value.content + } - status.translatedContent = result.content + if let reblog = status.reblog { + reblog.translatedContent = try await translate(status: reblog) + } else { + status.translatedContent = try await translate(status: status) + } } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index b562e40dc..498b77a5f 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -63,6 +63,14 @@ extension StatusView { self?.configureTranslated(status: status) } .store(in: &disposeBag) + + status.reblog?.$translatedContent + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] _ in + self?.configureTranslated(status: status) + } + .store(in: &disposeBag) } } @@ -240,9 +248,19 @@ extension StatusView { } func configureTranslated(status: Status) { + let translatedContent: String? = { + if let translatedContent = status.reblog?.translatedContent { + return translatedContent + } + return status.translatedContent + + }() + guard - let translatedContent = status.translatedContent - else { return } + let translatedContent = translatedContent + else { + return + } // content do { From 1020ca531a6766a73d19bb88c1a57074469a6e4a Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 7 Dec 2022 15:41:33 +0100 Subject: [PATCH 04/21] feat: Implement status translation info footer and reversion --- .../TableviewCell/StatusTableViewCell.swift | 2 +- .../StatusThreadRootTableViewCell.swift | 2 +- .../Content/StatusView+Configuration.swift | 29 +++++---- .../View/Content/StatusView+ViewModel.swift | 10 +-- .../MastodonUI/View/Content/StatusView.swift | 62 ++++++++++++++++--- .../MastodonUI/View/Menu/MastodonMenu.swift | 2 +- 6 files changed, 76 insertions(+), 31 deletions(-) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 3bb57302a..b8482c564 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -88,7 +88,7 @@ extension StatusTableViewCell { .store(in: &_disposeBag) statusView.viewModel - .$isTranslated + .$translatedFromLanguage .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] _ in self?.invalidateIntrinsicContentSize() diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift index 5cc6f6596..73b700e27 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift @@ -83,7 +83,7 @@ extension StatusThreadRootTableViewCell { statusView.contentMetaText.textView.isSelectable = true statusView.viewModel - .$isTranslated + .$translatedFromLanguage .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] _ in self?.invalidateIntrinsicContentSize() diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index 498b77a5f..7b40b9609 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -55,18 +55,13 @@ extension StatusView { configurePoll(status: status) configureToolbar(status: status) configureFilter(status: status) - - status.$translatedContent + viewModel.originalStatus = status + [ + status.$translatedContent, + status.reblog?.$translatedContent + ].compactMap { $0 } + .last? .receive(on: DispatchQueue.main) - .compactMap { $0 } - .sink { [weak self] _ in - self?.configureTranslated(status: status) - } - .store(in: &disposeBag) - - status.reblog?.$translatedContent - .receive(on: DispatchQueue.main) - .compactMap { $0 } .sink { [weak self] _ in self?.configureTranslated(status: status) } @@ -247,6 +242,14 @@ extension StatusView { .store(in: &disposeBag) } + func revertTranslation() { + guard let originalStatus = viewModel.originalStatus else { return } + viewModel.translatedFromLanguage = nil + originalStatus.reblog?.translatedContent = nil + originalStatus.translatedContent = nil + configure(status: originalStatus) + } + func configureTranslated(status: Status) { let translatedContent: String? = { if let translatedContent = status.reblog?.translatedContent { @@ -267,7 +270,7 @@ extension StatusView { let content = MastodonContent(content: translatedContent, emojis: status.emojis.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.content = metaContent - viewModel.isTranslated = true + viewModel.translatedFromLanguage = status.reblog?.language ?? status.language } catch { assertionFailure(error.localizedDescription) viewModel.content = PlaintextMetaContent(string: "") @@ -301,7 +304,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 + viewModel.translatedFromLanguage = nil } 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 b56bba2e1..15919120c 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -44,7 +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 translatedFromLanguage: String? @Published public var timestamp: Date? public var timestampFormatter: ((_ date: Date) -> String)? @@ -137,7 +137,7 @@ extension StatusView { isContentSensitive = false isMediaSensitive = false isSensitiveToggled = false - isTranslated = false + translatedFromLanguage = nil activeFilters = [] filterContext = nil @@ -586,7 +586,7 @@ extension StatusView.ViewModel { $isBookmark ) let publishersThree = Publishers.CombineLatest( - $isTranslated, + $translatedFromLanguage, $language ) @@ -598,7 +598,7 @@ extension StatusView.ViewModel { .sink { tupleOne, tupleTwo, tupleThree in let (authorName, isMyself) = tupleOne let (isMuting, isBlocking, isBookmark) = tupleTwo - let (isTranslated, language) = tupleThree + let (translatedFromLanguage, language) = tupleThree guard let name = authorName?.string else { statusView.authorView.menuButton.menu = nil @@ -611,7 +611,7 @@ extension StatusView.ViewModel { isBlocking: isBlocking, isMyself: isMyself, isBookmarking: isBookmark, - isTranslated: isTranslated, + isTranslated: translatedFromLanguage != nil, statusLanguage: language ) let (menu, actions) = authorView.setupAuthorMenu(menuContext: menuContext) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index bd901b08a..2838bbd85 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -176,6 +176,37 @@ public final class StatusView: UIView { indicatorView.stopAnimating() return indicatorView }() + private let translatedInfoLabel = UILabel() + lazy var translatedInfoView: UIView = { + let containerView = UIView() + + let revertButton = UIButton() + revertButton.setTitle("Show Original", 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: 20), + translatedInfoLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + translatedInfoLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), + revertButton.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + revertButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16) + ]) + + containerView.isHidden = true + + return containerView + }() // toolbar let actionToolbarAdaptiveMarginContainerView = AdaptiveMarginContainerView() @@ -217,6 +248,7 @@ public final class StatusView: UIView { setMediaDisplay(isDisplay: false) setPollDisplay(isDisplay: false) setFilterHintLabelDisplay(isDisplay: false) + setupTranslationIndicator() } public override init(frame: CGRect) { @@ -275,16 +307,6 @@ 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) } } @@ -448,6 +470,9 @@ extension StatusView.Style { statusView.filterHintLabel.centerXAnchor.constraint(equalTo: statusView.containerStackView.centerXAnchor), statusView.filterHintLabel.centerYAnchor.constraint(equalTo: statusView.containerStackView.centerYAnchor), ]) + + // translated info + statusView.containerStackView.addArrangedSubview(statusView.translatedInfoView) } func inline(statusView: StatusView) { @@ -660,6 +685,23 @@ extension StatusView: MastodonMenuDelegate { } } +extension StatusView { + func setupTranslationIndicator() { + viewModel.$translatedFromLanguage + .receive(on: DispatchQueue.main) + .sink { [weak self] translatedFromLanguage in + guard let self = self else { return } + if let translatedFromLanguage = translatedFromLanguage { + self.translatedInfoLabel.text = String(format: "Translated from %@", Locale.current.localizedString(forIdentifier: translatedFromLanguage) ?? "Unknown") + 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 e2e887db3..a09067adf 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift @@ -129,7 +129,7 @@ extension MastodonMenu { return deleteAction case let .translateStatus(context): let translateAction = BuiltAction( - title: String(format: "Translate from %@", context.language), + title: String(format: "Translate from %@", Locale.current.localizedString(forIdentifier: context.language) ?? "Unknown"), image: UIImage(systemName: "character.book.closed") ) { [weak delegate] in guard let delegate = delegate else { return } From dc174b4b6def76da44ac2f18863f6fc42e2f486e Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 7 Dec 2022 15:52:38 +0100 Subject: [PATCH 05/21] feat: Implement translation progress indicator on status --- ...Provider+StatusTableViewCellDelegate.swift | 6 +++++ .../View/Content/StatusView+ViewModel.swift | 7 +++++- .../MastodonUI/View/Content/StatusView.swift | 24 +++++++++++++++++-- 3 files changed, 34 insertions(+), 3 deletions(-) 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/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 15919120c..eee318170 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -44,7 +44,12 @@ extension StatusView { @Published public var isMyself = false @Published public var isMuting = false @Published public var isBlocking = false - @Published public var translatedFromLanguage: String? + + // Translation + @Published public var isCurrentlyTranslating = false + @Published public var translatedFromLanguage: String? { + didSet { isCurrentlyTranslating = false } + } @Published public var timestamp: Date? public var timestampFormatter: ((_ date: Date) -> String)? diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 2838bbd85..95ec5ff97 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -176,6 +176,12 @@ 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() lazy var translatedInfoView: UIView = { let containerView = UIView() @@ -199,8 +205,9 @@ public final class StatusView: UIView { containerView.heightAnchor.constraint(equalToConstant: 20), translatedInfoLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), translatedInfoLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), - revertButton.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), - revertButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, 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 @@ -472,6 +479,7 @@ extension StatusView.Style { ]) // translated info + statusView.containerStackView.addArrangedSubview(statusView.isTranslatingLoadingView) statusView.containerStackView.addArrangedSubview(statusView.translatedInfoView) } @@ -687,6 +695,18 @@ 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 From bfdb2e2de9a4ef7d32d20fe650aefe78fe845599 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 7 Dec 2022 16:00:18 +0100 Subject: [PATCH 06/21] chore: Update translated status footer style --- .../Sources/MastodonUI/View/Content/StatusView.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 95ec5ff97..4ac1735c6 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -182,11 +182,17 @@ public final class StatusView: UIView { activityIndicatorView.stopAnimating() return activityIndicatorView }() - private let translatedInfoLabel = UILabel() + 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("Show Original", for: .normal) revertButton.setTitleColor(Asset.Colors.brand.color, for: .normal) revertButton.addAction(UIAction { [weak self] _ in From 671b0e33f4d5c6065ee6696b103f19915d6b22dc Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 7 Dec 2022 16:03:52 +0100 Subject: [PATCH 07/21] chore: Code-style adjustments --- .../MastodonUI/View/Content/NotificationView+ViewModel.swift | 4 ++-- .../MastodonUI/View/Content/StatusView+ViewModel.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift index 11039875a..40c4f2870 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift @@ -209,13 +209,13 @@ extension NotificationView.ViewModel { $isTranslated ) ) - .sink { authorName, isMuting, isBlocking, comb2 in + .sink { authorName, isMuting, isBlocking, isMyselfIsTranslated in guard let name = authorName?.string else { notificationView.menuButton.menu = nil return } - let (isMyself, isTranslated) = comb2 + let (isMyself, isTranslated) = isMyselfIsTranslated let menuContext = NotificationView.AuthorMenuContext( name: name, diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index eee318170..6432826c8 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -148,7 +148,7 @@ extension StatusView { filterContext = nil } - init() { + init() { // isReblogEnabled Publishers.CombineLatest( $visibility, From 7368e487af57a15707565ea63158f6634b80cb96 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 7 Dec 2022 16:32:51 +0100 Subject: [PATCH 08/21] chore: Add L10n for status translation --- Localization/app.json | 11 ++++++++++- .../Generated/Strings.swift | 18 ++++++++++++++++++ .../Resources/Base.lproj/Localizable.strings | 7 ++++++- .../Resources/en.lproj/Localizable.strings | 7 ++++++- .../MastodonUI/View/Content/StatusView.swift | 4 ++-- .../MastodonUI/View/Menu/MastodonMenu.swift | 2 +- 6 files changed, 43 insertions(+), 6 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index c4a701948..63db5888b 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -91,7 +91,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 +172,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/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index bafed05f6..38c21394a 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -178,6 +178,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 +360,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..8c4d03f6a 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -59,6 +59,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 +126,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 +472,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..e5f096962 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings @@ -59,6 +59,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 +126,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 +466,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/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 4ac1735c6..ba414638d 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -193,7 +193,7 @@ public final class StatusView: UIView { let revertButton = UIButton() revertButton.titleLabel?.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .bold)) - revertButton.setTitle("Show Original", for: .normal) + 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() @@ -718,7 +718,7 @@ extension StatusView { .sink { [weak self] translatedFromLanguage in guard let self = self else { return } if let translatedFromLanguage = translatedFromLanguage { - self.translatedInfoLabel.text = String(format: "Translated from %@", Locale.current.localizedString(forIdentifier: translatedFromLanguage) ?? "Unknown") + 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 diff --git a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift index a09067adf..6fd5df772 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift @@ -129,7 +129,7 @@ extension MastodonMenu { return deleteAction case let .translateStatus(context): let translateAction = BuiltAction( - title: String(format: "Translate from %@", Locale.current.localizedString(forIdentifier: context.language) ?? "Unknown"), + 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 } From da3c9a42ecb0cd542c9825ba517c2f68a885d8b3 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 7 Dec 2022 17:03:09 +0100 Subject: [PATCH 09/21] fix: Don't make isCurrentlyTranslating depend on translatedFromLanguage --- .../MastodonUI/View/Content/StatusView+Configuration.swift | 3 +++ .../MastodonUI/View/Content/StatusView+ViewModel.swift | 5 ++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index 7b40b9609..f31430fbb 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -262,6 +262,7 @@ extension StatusView { guard let translatedContent = translatedContent else { + viewModel.isCurrentlyTranslating = false return } @@ -271,6 +272,7 @@ extension StatusView { 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: "") @@ -305,6 +307,7 @@ extension StatusView { 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 6432826c8..f45c07ea6 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -47,9 +47,7 @@ extension StatusView { // Translation @Published public var isCurrentlyTranslating = false - @Published public var translatedFromLanguage: String? { - didSet { isCurrentlyTranslating = false } - } + @Published public var translatedFromLanguage: String? @Published public var timestamp: Date? public var timestampFormatter: ((_ date: Date) -> String)? @@ -143,6 +141,7 @@ extension StatusView { isMediaSensitive = false isSensitiveToggled = false translatedFromLanguage = nil + isCurrentlyTranslating = false activeFilters = [] filterContext = nil From fda3ae1516f5bb699941f1d5fcbba7e79281dfaf Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 7 Dec 2022 17:10:32 +0100 Subject: [PATCH 10/21] chore: Update translated footer alignment --- .../Sources/MastodonUI/View/Content/StatusView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index ba414638d..7d7f41aa9 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -208,7 +208,7 @@ public final class StatusView: UIView { } NSLayoutConstraint.activate([ - containerView.heightAnchor.constraint(equalToConstant: 20), + 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), @@ -469,6 +469,10 @@ extension StatusView.Style { statusView.pollStatusDotLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) statusView.pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) statusView.pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) + + // translated info + statusView.containerStackView.addArrangedSubview(statusView.isTranslatingLoadingView) + statusView.containerStackView.addArrangedSubview(statusView.translatedInfoView) // action toolbar statusView.actionToolbarAdaptiveMarginContainerView.contentView = statusView.actionToolbarContainer @@ -483,10 +487,6 @@ extension StatusView.Style { statusView.filterHintLabel.centerXAnchor.constraint(equalTo: statusView.containerStackView.centerXAnchor), statusView.filterHintLabel.centerYAnchor.constraint(equalTo: statusView.containerStackView.centerYAnchor), ]) - - // translated info - statusView.containerStackView.addArrangedSubview(statusView.isTranslatingLoadingView) - statusView.containerStackView.addArrangedSubview(statusView.translatedInfoView) } func inline(statusView: StatusView) { From 58dcadf6422bb91bbc4fd625d0a2de956a937090 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Fri, 9 Dec 2022 16:12:13 +0100 Subject: [PATCH 11/21] feat: Implement error-handling for translation --- Localization/app.json | 5 +++ .../Provider/DataSourceFacade+Status.swift | 16 ++++--- .../Provider/DataSourceFacade+Translate.swift | 42 ++++++++++++++----- .../Generated/Strings.swift | 8 ++++ .../Resources/Base.lproj/Localizable.strings | 3 ++ .../Resources/en.lproj/Localizable.strings | 3 ++ 6 files changed, 62 insertions(+), 15 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 63db5888b..d44e76e26 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 instance or this instance is running an older version of Mastodon where translations are not yet supported.", + "button": "OK" } }, "controls": { diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index 9d83d1073..b28daf1b5 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -393,12 +393,18 @@ extension DataSourceFacade { alertController.addAction(cancelAction) dependency.present(alertController, animated: true) - case let .translateStatus(translationContext): + case .translateStatus: guard let status = menuContext.status else { return } - try await DataSourceFacade.translateStatus( - provider: dependency, - status: status - ) + 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 index 08557a4bf..ba2b1b86d 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift @@ -11,6 +11,10 @@ import CoreDataStack import MastodonCore extension DataSourceFacade { + enum TranslationFailure: Error { + case emptyOrInvalidResponse + } + public static func translateStatus( provider: UIViewController & NeedsDependency & AuthContextProvider, status: ManagedObjectRecord @@ -25,19 +29,37 @@ extension DataSourceFacade { } func translate(status: Status) async throws -> String? { - let value = try await provider.context - .apiService - .translateStatus( - statusID: status.id, - authenticationBox: provider.authContext.mastodonAuthenticationBox - ).value - return value.content - } + 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 + } + } + + func translateAndApply(to status: Status) async throws { + do { + status.translatedContent = try await translate(status: status) + } catch { + status.translatedContent = nil + throw TranslationFailure.emptyOrInvalidResponse + } + } if let reblog = status.reblog { - reblog.translatedContent = try await translate(status: reblog) + try await translateAndApply(to: reblog) } else { - status.translatedContent = try await translate(status: status) + try await translateAndApply(to: status) } } } diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 38c21394a..504fee233 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 instance or this instance 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 instance or this instance 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") diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index 8c4d03f6a..3bf85eb4f 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 instance or this instance 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"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings index e5f096962..eb8a76868 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 instance or this instance 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"; From 2d9e9802156ebcc26c32a22a506b7d1afc2d989d Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Mon, 12 Dec 2022 16:41:13 +0100 Subject: [PATCH 12/21] feat: Implement /api/v2/instance to conditionally show Translation option --- Mastodon/Diffable/Status/StatusSection.swift | 2 + ...DiscoveryCommunityViewModel+Diffable.swift | 1 + .../DiscoveryPostsViewModel+Diffable.swift | 1 + .../HashtagTimelineViewModel+Diffable.swift | 1 + .../HomeTimelineViewModel+Diffable.swift | 1 + .../Bookmark/BookmarkViewModel+Diffable.swift | 1 + .../Favorite/FavoriteViewModel+Diffable.swift | 1 + .../UserTimelineViewModel+Diffable.swift | 1 + .../Thread/ThreadViewModel+Diffable.swift | 1 + .../CoreData.xcdatamodeld/.xccurrentversion | 2 +- .../CoreData 6.xcdatamodel/contents | 259 ++++++++++++++++++ .../Entity/Mastodon/Instance.swift | 7 +- .../Extension/CoreDataStack/Instance.swift | 40 ++- .../Service/API/APIService+Instance.swift | 5 + .../APIService+CoreData+InstanceV2.swift | 69 +++++ .../Service/InstanceService.swift | 118 +++++--- .../API/Mastodon+API+V2+Instance.swift | 50 ++++ .../MastodonSDK/API/Mastodon+API.swift | 1 + .../Entity/Mastodon+Entity+InstanceV2.swift | 158 +++++++++++ .../Content/NotificationView+ViewModel.swift | 25 +- .../View/Content/StatusAuthorView.swift | 3 +- .../View/Content/StatusView+ViewModel.swift | 17 ++ 22 files changed, 722 insertions(+), 42 deletions(-) create mode 100644 MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 6.xcdatamodel/contents create mode 100644 MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+InstanceV2.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Instance.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift 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/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/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..3249c5510 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 6.xcdatamodel/contents @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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/MastodonCore/Extension/CoreDataStack/Instance.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift index 7e925b665..619abc91e 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift @@ -25,8 +25,42 @@ 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 else { return nil } + guard 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 + } +} + +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 + } +} + +extension Instance { + var isTranslationEnabled: Bool { + if let configuration = configurationV2 { + return configuration.translation?.enabled == true + } + return false } } 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/CoreData/APIService+CoreData+InstanceV2.swift b/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+InstanceV2.swift new file mode 100644 index 000000000..17ebb5f55 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+InstanceV2.swift @@ -0,0 +1,69 @@ +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeInstanceV2( + into managedObjectContext: NSManagedObjectContext, + domain: String, + entity: Mastodon.Entity.V2.Instance, + networkDate: Date, + log: Logger + ) -> (instance: Instance, isCreated: Bool) { + // fetch old mastodon user + let old: Instance? = { + let request = Instance.sortedFetchRequest + request.predicate = Instance.predicate(domain: 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.mergeV2( + instance: old, + entity: entity, + domain: domain, + networkDate: networkDate + ) + return (old, false) + } else { + let instance = Instance.insert( + into: managedObjectContext, + property: Instance.Property(domain: domain, version: entity.version) + ) + let configurationRaw = entity.configuration.flatMap { Instance.encodeV2(configuration: $0) } + instance.update(configurationV2Raw: configurationRaw) + + return (instance, true) + } + } + +} + +extension APIService.CoreData { + + static func mergeV2( + instance: Instance, + entity: Mastodon.Entity.V2.Instance, + domain: String, + networkDate: Date + ) { + guard networkDate > instance.updatedAt else { return } + + let configurationRaw = entity.configuration.flatMap { Instance.encodeV2(configuration: $0) } + instance.update(configurationV2Raw: configurationRaw) + instance.version = entity.version + + instance.didUpdate(at: networkDate) + } + +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift index 99ad6d0a2..02946ca6c 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,80 @@ 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.createOrMergeInstanceV2( + 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() + } } public extension InstanceService { 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..05913ebe9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift @@ -0,0 +1,158 @@ +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? + } +} + +//extension Mastodon.Entity.V2.Instance { +// public struct Statuses: Codable { +// public let maxCharacters: Int +// public let maxMediaAttachments: Int +// public let charactersReservedPerURL: Int +// +// enum CodingKeys: String, CodingKey { +// case maxCharacters = "max_characters" +// case maxMediaAttachments = "max_media_attachments" +// case charactersReservedPerURL = "characters_reserved_per_url" +// } +// } +// +// public struct MediaAttachments: Codable { +// public let supportedMIMETypes: [String] +// public let imageSizeLimit: Int +// public let imageMatrixLimit: Int +// public let videoSizeLimit: Int +// public let videoFrameRateLimit: Int +// public let videoMatrixLimit: Int +// +// enum CodingKeys: String, CodingKey { +// case supportedMIMETypes = "supported_mime_types" +// case imageSizeLimit = "image_size_limit" +// case imageMatrixLimit = "image_matrix_limit" +// case videoSizeLimit = "video_size_limit" +// case videoFrameRateLimit = "video_frame_rate_limit" +// case videoMatrixLimit = "video_matrix_limit" +// } +// } +// +// public struct Polls: Codable { +// public let maxOptions: Int +// public let maxCharactersPerOption: Int +// public let minExpiration: Int +// public let maxExpiration: Int +// +// enum CodingKeys: String, CodingKey { +// case maxOptions = "max_options" +// case maxCharactersPerOption = "max_characters_per_option" +// case minExpiration = "min_expiration" +// case maxExpiration = "max_expiration" +// } +// } +// +// public struct Translation: Codable { +// public let enabled: Bool +// } +//} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift index 40c4f2870..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? @@ -57,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) @@ -209,7 +213,7 @@ extension NotificationView.ViewModel { $isTranslated ) ) - .sink { authorName, isMuting, isBlocking, isMyselfIsTranslated in + .sink { [weak self] authorName, isMuting, isBlocking, isMyselfIsTranslated in guard let name = authorName?.string else { notificationView.menuButton.menu = nil return @@ -217,12 +221,29 @@ extension NotificationView.ViewModel { 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 + isTranslationEnabled: instanceConfigurationV2?.translation?.enabled == true, isTranslated: isTranslated, statusLanguage: "" ) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift index c930a7b66..ef40ab7fc 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift @@ -150,6 +150,7 @@ extension StatusAuthorView { public let isMyself: Bool public let isBookmarking: Bool + public let isTranslationEnabled: Bool public let isTranslated: Bool public let statusLanguage: String? } @@ -158,7 +159,7 @@ extension StatusAuthorView { var actions = [MastodonMenu.Action]() if !menuContext.isMyself { - if let statusLanguage = menuContext.statusLanguage, !menuContext.isTranslated { + if let statusLanguage = menuContext.statusLanguage, menuContext.isTranslationEnabled, !menuContext.isTranslated { actions.append( .translateStatus(.init(language: statusLanguage)) ) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index f45c07ea6..09995677c 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -27,6 +27,7 @@ extension StatusView { let logger = Logger(subsystem: "StatusView", category: "ViewModel") + public var context: AppContext? public var authContext: AuthContext? public var originalStatus: Status? @@ -609,12 +610,28 @@ extension StatusView.ViewModel { 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, + isTranslationEnabled: instanceConfigurationV2?.translation?.enabled == true, isTranslated: translatedFromLanguage != nil, statusLanguage: language ) From cb2765fd0f9a0a9093d82517971dda92d6cdc62c Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Mon, 12 Dec 2022 16:43:54 +0100 Subject: [PATCH 13/21] chore: Replace instance by server in strings --- Localization/app.json | 2 +- .../Sources/MastodonLocalization/Generated/Strings.swift | 4 ++-- .../Resources/Base.lproj/Localizable.strings | 2 +- .../Resources/en.lproj/Localizable.strings | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index d44e76e26..82f1f7516 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -54,7 +54,7 @@ }, "translation_failed": { "title": "Note", - "message": "Translation failed. Maybe the administrator has not enabled translations on this instance or this instance is running an older version of Mastodon where translations are not yet supported.", + "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" } }, diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 504fee233..69ff5e97f 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -90,8 +90,8 @@ public enum L10n { 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 instance or this instance 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 instance or this instance is running an older version of Mastodon where translations are not yet supported.") + /// 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") } diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index 3bf85eb4f..2ec8830b0 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -23,7 +23,7 @@ Please check your internet connection."; "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 instance or this instance is running an older version of Mastodon where translations are not yet supported."; +"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"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings index eb8a76868..9afcd60d9 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings @@ -23,7 +23,7 @@ Please check your internet connection."; "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 instance or this instance is running an older version of Mastodon where translations are not yet supported."; +"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"; From 8f32e1a80effb21968dc63c2cbe6452a5918a019 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Mon, 12 Dec 2022 16:53:41 +0100 Subject: [PATCH 14/21] Update MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Translation.swift Co-authored-by: Jed Fox --- .../MastodonSDK/Entity/Mastodon+Entity+Translation.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Translation.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Translation.swift index b8993de9e..f500453f1 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Translation.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Translation.swift @@ -1,5 +1,5 @@ // -// File.swift +// Mastodon+Entity+Translation.swift // // // Created by Marcus Kida on 02.12.22. From 85f4c454a329f1b8962e6ddca9a39d6350c27df5 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Tue, 13 Dec 2022 06:54:58 +0100 Subject: [PATCH 15/21] chore: Refactor code and apply PR improvements --- .../Provider/DataSourceFacade+Translate.swift | 66 ++++++++++--------- .../Extension/CoreDataStack/Instance.swift | 9 ++- 2 files changed, 42 insertions(+), 33 deletions(-) diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift index ba2b1b86d..fc95cb018 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift @@ -10,13 +10,15 @@ import CoreData import CoreDataStack import MastodonCore +typealias Provider = UIViewController & NeedsDependency & AuthContextProvider + extension DataSourceFacade { enum TranslationFailure: Error { case emptyOrInvalidResponse } public static func translateStatus( - provider: UIViewController & NeedsDependency & AuthContextProvider, + provider: Provider, status: ManagedObjectRecord ) async throws { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() @@ -28,38 +30,40 @@ extension DataSourceFacade { return } - func translate(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 - } - } - - func translateAndApply(to status: Status) async throws { - do { - status.translatedContent = try await translate(status: status) - } catch { - status.translatedContent = nil - throw TranslationFailure.emptyOrInvalidResponse - } - } - if let reblog = status.reblog { - try await translateAndApply(to: reblog) + try await translateAndApply(provider: provider, status: reblog) } else { - try await translateAndApply(to: status) + 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 { + status.translatedContent = try await translateStatus(provider: provider, status: status) + } catch { + status.translatedContent = nil + throw TranslationFailure.emptyOrInvalidResponse } } } diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift index 619abc91e..f54f4514d 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift @@ -26,8 +26,13 @@ extension Instance { extension Instance { public var configurationV2: Mastodon.Entity.V2.Instance.Configuration? { - guard let configurationRaw = configurationV2Raw else { return nil } - guard let configuration = try? JSONDecoder().decode(Mastodon.Entity.V2.Instance.Configuration.self, from: configurationRaw) else { + guard + let configurationRaw = configurationV2Raw, + let configuration = try? JSONDecoder().decode( + Mastodon.Entity.V2.Instance.Configuration.self, + from: configurationRaw + ) + else { return nil } From 110a89f4993b04d79c22ebfb94d1253fe359f061 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Tue, 13 Dec 2022 07:07:08 +0100 Subject: [PATCH 16/21] fix: AppContext is missing for some StatusView.ViewModels --- Mastodon/Diffable/Notification/NotificationSection.swift | 1 + Mastodon/Diffable/Report/ReportSection.swift | 1 + Mastodon/Diffable/Search/SearchResultSection.swift | 1 + 3 files changed, 3 insertions(+) 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( From 903d789b53d5c344f1a6366b048f780a73ff4534 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Tue, 13 Dec 2022 07:07:56 +0100 Subject: [PATCH 17/21] chore: Remove unused code --- .../Entity/Mastodon+Entity+InstanceV2.swift | 50 ------------------- 1 file changed, 50 deletions(-) diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift index 05913ebe9..d662c24c2 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift @@ -106,53 +106,3 @@ extension Mastodon.Entity.V2.Instance { public let url: String? } } - -//extension Mastodon.Entity.V2.Instance { -// public struct Statuses: Codable { -// public let maxCharacters: Int -// public let maxMediaAttachments: Int -// public let charactersReservedPerURL: Int -// -// enum CodingKeys: String, CodingKey { -// case maxCharacters = "max_characters" -// case maxMediaAttachments = "max_media_attachments" -// case charactersReservedPerURL = "characters_reserved_per_url" -// } -// } -// -// public struct MediaAttachments: Codable { -// public let supportedMIMETypes: [String] -// public let imageSizeLimit: Int -// public let imageMatrixLimit: Int -// public let videoSizeLimit: Int -// public let videoFrameRateLimit: Int -// public let videoMatrixLimit: Int -// -// enum CodingKeys: String, CodingKey { -// case supportedMIMETypes = "supported_mime_types" -// case imageSizeLimit = "image_size_limit" -// case imageMatrixLimit = "image_matrix_limit" -// case videoSizeLimit = "video_size_limit" -// case videoFrameRateLimit = "video_frame_rate_limit" -// case videoMatrixLimit = "video_matrix_limit" -// } -// } -// -// public struct Polls: Codable { -// public let maxOptions: Int -// public let maxCharactersPerOption: Int -// public let minExpiration: Int -// public let maxExpiration: Int -// -// enum CodingKeys: String, CodingKey { -// case maxOptions = "max_options" -// case maxCharactersPerOption = "max_characters_per_option" -// case minExpiration = "min_expiration" -// case maxExpiration = "max_expiration" -// } -// } -// -// public struct Translation: Codable { -// public let enabled: Bool -// } -//} From 518941b10cfbd969b712ebc253575bc5f0b600f2 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Tue, 13 Dec 2022 07:10:08 +0100 Subject: [PATCH 18/21] chore: Codestyle changes --- .../Extension/CoreDataStack/Instance.swift | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift index f54f4514d..eebb16be4 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift @@ -48,6 +48,13 @@ 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 { @@ -60,12 +67,3 @@ extension String { return majorVersionInt >= comparedVersion } } - -extension Instance { - var isTranslationEnabled: Bool { - if let configuration = configurationV2 { - return configuration.translation?.enabled == true - } - return false - } -} From 855f626c42fbb843090fc821a1b41830c6b63a9b Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 14 Dec 2022 09:35:45 +0100 Subject: [PATCH 19/21] fix: Make translatedContent a transient CoreData property --- .../Protocol/Provider/DataSourceFacade+Translate.swift | 5 +++-- .../CoreData.xcdatamodeld/CoreData 6.xcdatamodel/contents | 3 ++- .../Sources/CoreDataStack/Entity/Mastodon/Status.swift | 8 +++++++- .../View/Content/StatusView+Configuration.swift | 8 ++++---- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift index fc95cb018..cd4cec4b0 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift @@ -60,9 +60,10 @@ private extension DataSourceFacade { static func translateAndApply(provider: Provider, status: Status) async throws { do { - status.translatedContent = try await translateStatus(provider: provider, status: status) + let translated = try await translateStatus(provider: provider, status: status) + status.update(translatedContent: translated) } catch { - status.translatedContent = nil + status.update(translatedContent: nil) throw TranslationFailure.emptyOrInvalidResponse } } diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 6.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 6.xcdatamodel/contents index 3249c5510..30e89ccb4 100644 --- a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 6.xcdatamodel/contents +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 6.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -201,6 +201,7 @@ + diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift index 1f46a6ce1..3bd1df794 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift @@ -100,7 +100,8 @@ public final class Status: NSManagedObject { // sourcery: autoUpdatableObject @NSManaged public private(set) var revealedAt: Date? - @Published public var translatedContent: String? + // sourcery: autoUpdatableObject + @NSManaged public private(set) var translatedContent: String? } extension Status { @@ -497,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/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index f31430fbb..cc369f972 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -57,8 +57,8 @@ extension StatusView { configureFilter(status: status) viewModel.originalStatus = status [ - status.$translatedContent, - status.reblog?.$translatedContent + status.publisher(for: \.translatedContent), + status.reblog?.publisher(for: \.translatedContent) ].compactMap { $0 } .last? .receive(on: DispatchQueue.main) @@ -245,8 +245,8 @@ extension StatusView { func revertTranslation() { guard let originalStatus = viewModel.originalStatus else { return } viewModel.translatedFromLanguage = nil - originalStatus.reblog?.translatedContent = nil - originalStatus.translatedContent = nil + originalStatus.reblog?.update(translatedContent: nil) + originalStatus.update(translatedContent: nil) configure(status: originalStatus) } From f530d109d33c351643273f96e6dc3bfc5fa8bf30 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 14 Dec 2022 09:51:15 +0100 Subject: [PATCH 20/21] chore: Address PR codestyle feedback --- .../APIService+CoreData+InstanceV2.swift | 54 +++++++++++-------- .../Service/InstanceService.swift | 14 ++--- 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+InstanceV2.swift b/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+InstanceV2.swift index 17ebb5f55..19e188133 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+InstanceV2.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+InstanceV2.swift @@ -6,17 +6,33 @@ import MastodonSDK extension APIService.CoreData { - static func createOrMergeInstanceV2( - into managedObjectContext: NSManagedObjectContext, - domain: String, - entity: Mastodon.Entity.V2.Instance, - networkDate: Date, - log: Logger + 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: domain) + request.predicate = Instance.predicate(domain: context.domain) request.fetchLimit = 1 request.returnsObjectsAsFaults = false do { @@ -28,19 +44,17 @@ extension APIService.CoreData { }() if let old = old { - APIService.CoreData.mergeV2( + APIService.CoreData.merge( instance: old, - entity: entity, - domain: domain, - networkDate: networkDate + context: context ) return (old, false) } else { let instance = Instance.insert( into: managedObjectContext, - property: Instance.Property(domain: domain, version: entity.version) + property: Instance.Property(domain: context.domain, version: context.entity.version) ) - let configurationRaw = entity.configuration.flatMap { Instance.encodeV2(configuration: $0) } + let configurationRaw = context.entity.configuration.flatMap { Instance.encodeV2(configuration: $0) } instance.update(configurationV2Raw: configurationRaw) return (instance, true) @@ -51,19 +65,17 @@ extension APIService.CoreData { extension APIService.CoreData { - static func mergeV2( + static func merge( instance: Instance, - entity: Mastodon.Entity.V2.Instance, - domain: String, - networkDate: Date + context: PersistContext ) { - guard networkDate > instance.updatedAt else { return } + guard context.networkDate > instance.updatedAt else { return } - let configurationRaw = entity.configuration.flatMap { Instance.encodeV2(configuration: $0) } + let configurationRaw = context.entity.configuration.flatMap { Instance.encodeV2(configuration: $0) } instance.update(configurationV2Raw: configurationRaw) - instance.version = entity.version + instance.version = context.entity.version - instance.didUpdate(at: networkDate) + instance.didUpdate(at: context.networkDate) } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift index 02946ca6c..0745e2f37 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift @@ -118,12 +118,14 @@ extension InstanceService { let managedObjectContext = self.backgroundManagedObjectContext return managedObjectContext.performChanges { // get instance - let (instance, _) = APIService.CoreData.createOrMergeInstanceV2( - into: managedObjectContext, - domain: domain, - entity: response.value, - networkDate: response.networkDate, - log: Logger(subsystem: "Update", category: "InstanceService") + 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 From 6329f100461d26719cf58eca26c16e2916e2d01f Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 14 Dec 2022 10:33:50 +0100 Subject: [PATCH 21/21] chore: Move translated info directly below translated text --- .../Sources/MastodonUI/View/Content/StatusView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 7d7f41aa9..751e7929d 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -431,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) @@ -470,10 +474,6 @@ extension StatusView.Style { statusView.pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) statusView.pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) - // translated info - statusView.containerStackView.addArrangedSubview(statusView.isTranslatingLoadingView) - statusView.containerStackView.addArrangedSubview(statusView.translatedInfoView) - // action toolbar statusView.actionToolbarAdaptiveMarginContainerView.contentView = statusView.actionToolbarContainer statusView.actionToolbarAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin