Merge pull request #741 from mastodon/translate_status

[Feature] Translate Posts
This commit is contained in:
Marcus Kida 2022-12-15 10:49:10 +01:00 committed by GitHub
commit 367b52bf64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1163 additions and 53 deletions

View File

@ -51,6 +51,11 @@
"clean_cache": {
"title": "Clean Cache",
"message": "Successfully cleaned %s cache."
},
"translation_failed": {
"title": "Note",
"message": "Translation failed. Maybe the administrator has not enabled translations on this server or this server is running an older version of Mastodon where translations are not yet supported.",
"button": "OK"
}
},
"controls": {
@ -91,7 +96,11 @@
"block_domain": "Block %s",
"unblock_domain": "Unblock %s",
"settings": "Settings",
"delete": "Delete"
"delete": "Delete",
"translate_post": {
"title": "Translate from %s",
"unknown_language": "Unknown"
}
},
"tabs": {
"home": "Home",
@ -168,6 +177,11 @@
"private": "Only their followers can see this post.",
"private_from_me": "Only my followers can see this post.",
"direct": "Only mentioned user can see this post."
},
"translation": {
"translated_from": "Translated from %s",
"unknown_language": "Unknown",
"show_original": "Shown Original"
}
},
"friendship": {

View File

@ -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 = "<group>"; };
2A76F75B2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderViewActionButton.swift; sourceTree = "<group>"; };
2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppContext+NextAccount.swift"; sourceTree = "<group>"; };
2AB12E4529362F27006BC925 /* DataSourceFacade+Translate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Translate.swift"; sourceTree = "<group>"; };
2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = "<group>"; };
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -76,6 +76,7 @@ extension NotificationSection {
viewModel: NotificationTableViewCell.ViewModel,
configuration: Configuration
) {
cell.notificationView.viewModel.context = context
cell.notificationView.viewModel.authContext = configuration.authContext
StatusSection.setupStatusPollDataSource(

View File

@ -107,6 +107,7 @@ extension ReportSection {
statusView: cell.statusView
)
cell.statusView.viewModel.context = context
cell.statusView.viewModel.authContext = configuration.authContext
cell.configure(

View File

@ -104,6 +104,7 @@ extension SearchResultSection {
statusView: cell.statusView
)
cell.statusView.viewModel.context = context
cell.statusView.viewModel.authContext = configuration.authContext
cell.configure(

View File

@ -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(

View File

@ -393,6 +393,18 @@ extension DataSourceFacade {
alertController.addAction(cancelAction)
dependency.present(alertController, animated: true)
case .translateStatus:
guard let status = menuContext.status else { return }
do {
try await DataSourceFacade.translateStatus(
provider: dependency,
status: status
)
} catch TranslationFailure.emptyOrInvalidResponse {
let alertController = UIAlertController(title: L10n.Common.Alerts.TranslationFailed.title, message: L10n.Common.Alerts.TranslationFailed.message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: L10n.Common.Alerts.TranslationFailed.button, style: .default))
dependency.present(alertController, animated: true)
}
}
} // end func
}

View File

@ -0,0 +1,70 @@
//
// DataSourceFacade+Translate.swift
// Mastodon
//
// Created by Marcus Kida on 29.11.22.
//
import UIKit
import CoreData
import CoreDataStack
import MastodonCore
typealias Provider = UIViewController & NeedsDependency & AuthContextProvider
extension DataSourceFacade {
enum TranslationFailure: Error {
case emptyOrInvalidResponse
}
public static func translateStatus(
provider: Provider,
status: ManagedObjectRecord<Status>
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
guard
let status = status.object(in: provider.context.managedObjectContext)
else {
return
}
if let reblog = status.reblog {
try await translateAndApply(provider: provider, status: reblog)
} else {
try await translateAndApply(provider: provider, status: status)
}
}
}
private extension DataSourceFacade {
static func translateStatus(provider: Provider, status: Status) async throws -> String? {
do {
let value = try await provider.context
.apiService
.translateStatus(
statusID: status.id,
authenticationBox: provider.authContext.mastodonAuthenticationBox
).value
guard let content = value.content else {
throw TranslationFailure.emptyOrInvalidResponse
}
return content
} catch {
throw TranslationFailure.emptyOrInvalidResponse
}
}
static func translateAndApply(provider: Provider, status: Status) async throws {
do {
let translated = try await translateStatus(provider: provider, status: status)
status.update(translatedContent: translated)
} catch {
status.update(translatedContent: nil)
throw TranslationFailure.emptyOrInvalidResponse
}
}
}

View File

@ -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,

View File

@ -18,6 +18,7 @@ extension DiscoveryCommunityViewModel {
tableView: tableView,
context: context,
configuration: StatusSection.Configuration(
context: context,
authContext: authContext,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,

View File

@ -18,6 +18,7 @@ extension DiscoveryPostsViewModel {
tableView: tableView,
context: context,
configuration: StatusSection.Configuration(
context: context,
authContext: authContext,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,

View File

@ -20,6 +20,7 @@ extension HashtagTimelineViewModel {
tableView: tableView,
context: context,
configuration: StatusSection.Configuration(
context: context,
authContext: authContext,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,

View File

@ -22,6 +22,7 @@ extension HomeTimelineViewModel {
tableView: tableView,
context: context,
configuration: StatusSection.Configuration(
context: context,
authContext: authContext,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate,

View File

@ -17,6 +17,7 @@ extension BookmarkViewModel {
tableView: tableView,
context: context,
configuration: StatusSection.Configuration(
context: context,
authContext: authContext,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,

View File

@ -17,6 +17,7 @@ extension FavoriteViewModel {
tableView: tableView,
context: context,
configuration: StatusSection.Configuration(
context: context,
authContext: authContext,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,

View File

@ -18,6 +18,7 @@ extension UserTimelineViewModel {
tableView: tableView,
context: context,
configuration: StatusSection.Configuration(
context: context,
authContext: authContext,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,

View File

@ -86,6 +86,14 @@ extension StatusTableViewCell {
self.accessibilityLabel = accessibilityLabel
}
.store(in: &_disposeBag)
statusView.viewModel
.$translatedFromLanguage
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] _ in
self?.invalidateIntrinsicContentSize()
})
.store(in: &_disposeBag)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {

View File

@ -81,6 +81,14 @@ extension StatusThreadRootTableViewCell {
// a11y
statusView.contentMetaText.textView.isAccessibilityElement = true
statusView.contentMetaText.textView.isSelectable = true
statusView.viewModel
.$translatedFromLanguage
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] _ in
self?.invalidateIntrinsicContentSize()
})
.store(in: &disposeBag)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {

View File

@ -24,6 +24,7 @@ extension ThreadViewModel {
tableView: tableView,
context: context,
configuration: StatusSection.Configuration(
context: context,
authContext: authContext,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,

View File

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>CoreData 5.xcdatamodel</string>
<string>CoreData 6.xcdatamodel</string>
</dict>
</plist>

View File

@ -0,0 +1,260 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21512" systemVersion="22C65" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName="CoreDataStack.Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="vapidKey" optional="YES" attributeType="String"/>
<attribute name="website" optional="YES" attributeType="String"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="application" inverseEntity="Status"/>
</entity>
<entity name="DomainBlock" representedClassName="CoreDataStack.DomainBlock" syncable="YES">
<attribute name="blockedDomain" attributeType="String"/>
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="userID"/>
<constraint value="domain"/>
<constraint value="blockedDomain"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Emoji" representedClassName="CoreDataStack.Emoji" syncable="YES">
<attribute name="category" optional="YES" attributeType="String"/>
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="shortcode" attributeType="String"/>
<attribute name="staticURL" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<attribute name="visibleInPicker" attributeType="Boolean" usesScalarValueType="YES"/>
</entity>
<entity name="Feed" representedClassName="CoreDataStack.Feed" syncable="YES">
<attribute name="acctRaw" optional="YES" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="hasMore" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="isLoadingMore" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="kindRaw" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="notification" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Notification" inverseName="feeds" inverseEntity="Notification"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="feeds" inverseEntity="Status"/>
</entity>
<entity name="Instance" representedClassName="CoreDataStack.Instance" syncable="YES">
<attribute name="configurationRaw" optional="YES" attributeType="Binary"/>
<attribute name="configurationV2Raw" optional="YES" attributeType="Binary"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="version" optional="YES" attributeType="String"/>
<relationship name="authentications" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="instance" inverseEntity="MastodonAuthentication"/>
</entity>
<entity name="MastodonAuthentication" representedClassName="CoreDataStack.MastodonAuthentication" syncable="YES">
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="appAccessToken" attributeType="String"/>
<attribute name="clientID" attributeType="String"/>
<attribute name="clientSecret" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userAccessToken" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="instance" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Instance" inverseName="authentications" inverseEntity="Instance"/>
<relationship name="user" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mastodonAuthentication" inverseEntity="MastodonUser"/>
</entity>
<entity name="MastodonUser" representedClassName="CoreDataStack.MastodonUser" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="String"/>
<attribute name="avatarStatic" optional="YES" attributeType="String"/>
<attribute name="bot" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="displayName" attributeType="String"/>
<attribute name="domain" attributeType="String"/>
<attribute name="emojis" optional="YES" attributeType="Binary"/>
<attribute name="fields" optional="YES" attributeType="Binary"/>
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" attributeType="String"/>
<attribute name="headerStatic" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="suspended" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" optional="YES" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="blocking" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blockingBy" inverseEntity="MastodonUser"/>
<relationship name="blockingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blocking" inverseEntity="MastodonUser"/>
<relationship name="bookmarked" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="bookmarkedBy" inverseEntity="Status"/>
<relationship name="domainBlocking" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlockingBy" inverseEntity="MastodonUser"/>
<relationship name="domainBlockingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlocking" inverseEntity="MastodonUser"/>
<relationship name="endorsed" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsedBy" inverseEntity="MastodonUser"/>
<relationship name="endorsedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsed" inverseEntity="MastodonUser"/>
<relationship name="favourite" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="favouritedBy" inverseEntity="Status"/>
<relationship name="followedTags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="followedBy" inverseEntity="Tag"/>
<relationship name="following" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followingBy" inverseEntity="MastodonUser"/>
<relationship name="followingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="following" inverseEntity="MastodonUser"/>
<relationship name="followRequested" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequestedBy" inverseEntity="MastodonUser"/>
<relationship name="followRequestedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequested" inverseEntity="MastodonUser"/>
<relationship name="mastodonAuthentication" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="user" inverseEntity="MastodonAuthentication"/>
<relationship name="muted" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="mutedBy" inverseEntity="Status"/>
<relationship name="muting" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mutingBy" inverseEntity="MastodonUser"/>
<relationship name="mutingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muting" inverseEntity="MastodonUser"/>
<relationship name="notifications" toMany="YES" deletionRule="Nullify" destinationEntity="Notification" inverseName="account" inverseEntity="Notification"/>
<relationship name="pinnedStatus" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="pinnedBy" inverseEntity="Status"/>
<relationship name="privateNotes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="to" inverseEntity="PrivateNote"/>
<relationship name="privateNotesTo" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="from" inverseEntity="PrivateNote"/>
<relationship name="reblogged" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="rebloggedBy" inverseEntity="Status"/>
<relationship name="searchHistories" toMany="YES" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="account" inverseEntity="SearchHistory"/>
<relationship name="showingReblogs" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="showingReblogsBy" inverseEntity="MastodonUser"/>
<relationship name="showingReblogsBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="showingReblogs" inverseEntity="MastodonUser"/>
<relationship name="statuses" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="author" inverseEntity="Status"/>
<relationship name="votePollOptions" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="votedBy" inverseEntity="PollOption"/>
<relationship name="votePolls" toMany="YES" deletionRule="Nullify" destinationEntity="Poll" inverseName="votedBy" inverseEntity="Poll"/>
</entity>
<entity name="Notification" representedClassName="CoreDataStack.Notification" syncable="YES">
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="followRequestState" optional="YES" attributeType="Binary"/>
<attribute name="id" attributeType="String"/>
<attribute name="transientFollowRequestState" optional="YES" transient="YES" attributeType="Binary"/>
<attribute name="typeRaw" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String"/>
<relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="notifications" inverseEntity="MastodonUser"/>
<relationship name="feeds" toMany="YES" deletionRule="Cascade" destinationEntity="Feed" inverseName="notification" inverseEntity="Feed"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="notifications" inverseEntity="Status"/>
</entity>
<entity name="Poll" representedClassName="CoreDataStack.Poll" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String" defaultValueString=""/>
<attribute name="expired" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="expiresAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" attributeType="String"/>
<attribute name="isVoting" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="votersCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="votesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="options" toMany="YES" deletionRule="Cascade" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
<relationship name="status" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="poll" inverseEntity="Status"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePolls" inverseEntity="MastodonUser"/>
</entity>
<entity name="PollOption" representedClassName="CoreDataStack.PollOption" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isSelected" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="title" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="votesCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="poll" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePollOptions" inverseEntity="MastodonUser"/>
</entity>
<entity name="PrivateNote" representedClassName="CoreDataStack.PrivateNote" syncable="YES">
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="from" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotesTo" inverseEntity="MastodonUser"/>
<relationship name="to" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotes" inverseEntity="MastodonUser"/>
</entity>
<entity name="SearchHistory" representedClassName="CoreDataStack.SearchHistory" syncable="YES">
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String" defaultValueString=""/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String" defaultValueString=""/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="searchHistories" inverseEntity="MastodonUser"/>
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag" inverseName="searchHistories" inverseEntity="Tag"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="searchHistories" inverseEntity="Status"/>
</entity>
<entity name="Setting" representedClassName="CoreDataStack.Setting" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="preferredStaticAvatar" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preferredStaticEmoji" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preferredTrueBlackDarkMode" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preferredUsingDefaultBrowser" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String"/>
<relationship name="subscriptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
</entity>
<entity name="Status" representedClassName="CoreDataStack.Status" syncable="YES">
<attribute name="attachments" optional="YES" attributeType="Binary"/>
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="emojis" optional="YES" attributeType="Binary"/>
<attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="isSensitiveToggled" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="mentions" optional="YES" attributeType="Binary"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="repliesCount" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
<attribute name="revealedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="spoilerText" optional="YES" attributeType="String"/>
<attribute name="text" optional="YES" attributeType="String"/>
<attribute name="translatedContent" optional="YES" transient="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uri" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="String"/>
<attribute name="visibilityRaw" optional="YES" attributeType="String" elementID="visibility"/>
<relationship name="application" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Application" inverseName="status" inverseEntity="Application"/>
<relationship name="author" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="statuses" inverseEntity="MastodonUser"/>
<relationship name="bookmarkedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="bookmarked" inverseEntity="MastodonUser"/>
<relationship name="favouritedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="favourite" inverseEntity="MastodonUser"/>
<relationship name="feeds" toMany="YES" deletionRule="Cascade" destinationEntity="Feed" inverseName="status" inverseEntity="Feed"/>
<relationship name="mutedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muted" inverseEntity="MastodonUser"/>
<relationship name="notifications" toMany="YES" deletionRule="Cascade" destinationEntity="Notification" inverseName="status" inverseEntity="Notification"/>
<relationship name="pinnedBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="pinnedStatus" inverseEntity="MastodonUser"/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="status" inverseEntity="Poll"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogFrom" inverseEntity="Status"/>
<relationship name="reblogFrom" toMany="YES" deletionRule="Cascade" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
<relationship name="rebloggedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/>
<relationship name="replyFrom" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="replyTo" inverseEntity="Status"/>
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="replyFrom" inverseEntity="Status"/>
<relationship name="searchHistories" toMany="YES" deletionRule="Cascade" destinationEntity="SearchHistory" inverseName="status" inverseEntity="SearchHistory"/>
</entity>
<entity name="Subscription" representedClassName="CoreDataStack.Subscription" syncable="YES">
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="endpoint" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="policyRaw" attributeType="String"/>
<attribute name="serverKey" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userToken" optional="YES" attributeType="String"/>
<relationship name="alert" maxCount="1" deletionRule="Cascade" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/>
<relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscriptions" inverseEntity="Setting"/>
</entity>
<entity name="SubscriptionAlerts" representedClassName="CoreDataStack.SubscriptionAlerts" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="favouriteRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followRequestRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mentionRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pollRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="subscription" maxCount="1" deletionRule="Nullify" destinationEntity="Subscription" inverseName="alert" inverseEntity="Subscription"/>
</entity>
<entity name="Tag" representedClassName="CoreDataStack.Tag" syncable="YES">
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String" defaultValueString=""/>
<attribute name="following" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="histories" optional="YES" attributeType="Binary"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" attributeType="String"/>
<relationship name="followedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followedTags" inverseEntity="MastodonUser"/>
<relationship name="searchHistories" toMany="YES" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="hashtag" inverseEntity="SearchHistory"/>
</entity>
</model>

View File

@ -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<MastodonAuthentication>
}
@ -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
}

View File

@ -99,6 +99,9 @@ public final class Status: NSManagedObject {
@NSManaged public private(set) var deletedAt: Date?
// sourcery: autoUpdatableObject
@NSManaged public private(set) var revealedAt: Date?
// sourcery: autoUpdatableObject
@NSManaged public private(set) var translatedContent: String?
}
extension Status {
@ -495,6 +498,11 @@ extension Status: AutoUpdatableObject {
self.revealedAt = revealedAt
}
}
public func update(translatedContent: String?) {
if self.translatedContent != translatedContent {
self.translatedContent = translatedContent
}
}
public func update(attachments: [MastodonAttachment]) {
if self.attachments != attachments {
self.attachments = attachments

View File

@ -25,8 +25,45 @@ extension Instance {
}
extension Instance {
public var canFollowTags: Bool {
guard let majorVersionString = version?.split(separator: ".").first else { return false }
return Int(majorVersionString) == 4 // following Tags is support beginning with Mastodon v4.0.0
public var configurationV2: Mastodon.Entity.V2.Instance.Configuration? {
guard
let configurationRaw = configurationV2Raw,
let configuration = try? JSONDecoder().decode(
Mastodon.Entity.V2.Instance.Configuration.self,
from: configurationRaw
)
else {
return nil
}
return configuration
}
static func encodeV2(configuration: Mastodon.Entity.V2.Instance.Configuration) -> Data? {
return try? JSONEncoder().encode(configuration)
}
}
extension Instance {
public var canFollowTags: Bool {
version?.majorServerVersion(greaterThanOrEquals: 4) ?? false // following Tags is support beginning with Mastodon v4.0.0
}
var isTranslationEnabled: Bool {
if let configuration = configurationV2 {
return configuration.translation?.enabled == true
}
return false
}
}
extension String {
public func majorServerVersion(greaterThanOrEquals comparedVersion: Int) -> Bool {
guard
let majorVersionString = split(separator: ".").first,
let majorVersionInt = Int(majorVersionString)
else { return false }
return majorVersionInt >= comparedVersion
}
}

View File

@ -20,4 +20,9 @@ extension APIService {
return Mastodon.API.Instance.instance(session: session, domain: domain)
}
public func instanceV2(
domain: String
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.V2.Instance>, Error> {
return Mastodon.API.V2.Instance.instance(session: session, domain: domain)
}
}

View File

@ -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<Mastodon.Entity.Translation> {
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
}
}

View File

@ -0,0 +1,81 @@
import os.log
import Foundation
import CoreData
import CoreDataStack
import MastodonSDK
extension APIService.CoreData {
public struct PersistContext {
public let domain: String
public let entity: Mastodon.Entity.V2.Instance
public let networkDate: Date
public let log: Logger
public init(
domain: String,
entity: Mastodon.Entity.V2.Instance,
networkDate: Date,
log: Logger
) {
self.domain = domain
self.entity = entity
self.networkDate = networkDate
self.log = log
}
}
static func createOrMergeInstance(
in managedObjectContext: NSManagedObjectContext,
context: PersistContext
) -> (instance: Instance, isCreated: Bool) {
// fetch old mastodon user
let old: Instance? = {
let request = Instance.sortedFetchRequest
request.predicate = Instance.predicate(domain: context.domain)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
return try managedObjectContext.fetch(request).first
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
if let old = old {
APIService.CoreData.merge(
instance: old,
context: context
)
return (old, false)
} else {
let instance = Instance.insert(
into: managedObjectContext,
property: Instance.Property(domain: context.domain, version: context.entity.version)
)
let configurationRaw = context.entity.configuration.flatMap { Instance.encodeV2(configuration: $0) }
instance.update(configurationV2Raw: configurationRaw)
return (instance, true)
}
}
}
extension APIService.CoreData {
static func merge(
instance: Instance,
context: PersistContext
) {
guard context.networkDate > instance.updatedAt else { return }
let configurationRaw = context.entity.configuration.flatMap { Instance.encodeV2(configuration: $0) }
instance.update(configurationV2Raw: configurationRaw)
instance.version = context.entity.version
instance.didUpdate(at: context.networkDate)
}
}

View File

@ -50,42 +50,18 @@ extension InstanceService {
func updateInstance(domain: String) {
guard let apiService = self.apiService else { return }
apiService.instance(domain: domain)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Instance>, 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<Void, Error> 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<Mastodon.Entity.Instance> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
// .flatMap { [unowned self] response -> AnyPublisher<Void, Error> in
// return
// }
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
@ -100,6 +76,82 @@ extension InstanceService {
}
.store(in: &disposeBag)
}
private func updateInstance(domain: String, response: Mastodon.Response.Content<Mastodon.Entity.Instance>) -> AnyPublisher<Void, Error> {
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<Mastodon.Entity.V2.Instance>) -> AnyPublisher<Void, Error> {
let managedObjectContext = self.backgroundManagedObjectContext
return managedObjectContext.performChanges {
// get instance
let (instance, _) = APIService.CoreData.createOrMergeInstance(
in: managedObjectContext,
context: .init(
domain: domain,
entity: response.value,
networkDate: response.networkDate,
log: Logger(subsystem: "Update", category: "InstanceService")
)
)
// update relationship
let request = MastodonAuthentication.sortedFetchRequest
request.predicate = MastodonAuthentication.predicate(domain: domain)
request.returnsObjectsAsFaults = false
do {
let authentications = try managedObjectContext.fetch(request)
for authentication in authentications {
authentication.update(instance: instance)
}
} catch {
assertionFailure(error.localizedDescription)
}
}
.setFailureType(to: Error.self)
.tryMap { result in
switch result {
case .success:
break
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
}
public extension InstanceService {

View File

@ -87,6 +87,14 @@ public enum L10n {
/// Sign Up Failure
public static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title", fallback: "Sign Up Failure")
}
public enum TranslationFailed {
/// OK
public static let button = L10n.tr("Localizable", "Common.Alerts.TranslationFailed.Button", fallback: "OK")
/// Translation failed. Maybe the administrator has not enabled translations on this server or this server is running an older version of Mastodon where translations are not yet supported.
public static let message = L10n.tr("Localizable", "Common.Alerts.TranslationFailed.Message", fallback: "Translation failed. Maybe the administrator has not enabled translations on this server or this server is running an older version of Mastodon where translations are not yet supported.")
/// Note
public static let title = L10n.tr("Localizable", "Common.Alerts.TranslationFailed.Title", fallback: "Note")
}
public enum VoteFailure {
/// The poll has ended
public static let pollEnded = L10n.tr("Localizable", "Common.Alerts.VoteFailure.PollEnded", fallback: "The poll has ended")
@ -178,6 +186,14 @@ public enum L10n {
public static func unblockDomain(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Actions.UnblockDomain", String(describing: p1), fallback: "Unblock %@")
}
public enum TranslatePost {
/// Translate from %@
public static func title(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Actions.TranslatePost.Title", String(describing: p1), fallback: "Translate from %@")
}
/// Unknown
public static let unknownLanguage = L10n.tr("Localizable", "Common.Controls.Actions.TranslatePost.UnknownLanguage", fallback: "Unknown")
}
}
public enum Friendship {
/// Block
@ -352,6 +368,16 @@ public enum L10n {
/// URL
public static let url = L10n.tr("Localizable", "Common.Controls.Status.Tag.Url", fallback: "URL")
}
public enum Translation {
/// Show Original
public static let showOriginal = L10n.tr("Localizable", "Common.Controls.Status.Translation.ShowOriginal", fallback: "Show Original")
/// Translated from %@
public static func translatedFrom(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.Translation.TranslatedFrom", String(describing: p1), fallback: "Translated from %@")
}
/// Unknown
public static let unknownLanguage = L10n.tr("Localizable", "Common.Controls.Status.Translation.UnknownLanguage", fallback: "Unknown")
}
public enum Visibility {
/// Only mentioned user can see this post.
public static let direct = L10n.tr("Localizable", "Common.Controls.Status.Visibility.Direct", fallback: "Only mentioned user can see this post.")

View File

@ -22,6 +22,9 @@ Please check your internet connection.";
"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?";
"Common.Alerts.SignOut.Title" = "Sign Out";
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
"Common.Alerts.TranslationFailed.Title" = "Note";
"Common.Alerts.TranslationFailed.Message" = "Translation failed. Maybe the administrator has not enabled translations on this server or this server is running an older version of Mastodon where translations are not yet supported.";
"Common.Alerts.TranslationFailed.Button" = "OK";
"Common.Alerts.VoteFailure.PollEnded" = "The poll has ended";
"Common.Alerts.VoteFailure.Title" = "Vote Failure";
"Common.Controls.Actions.Add" = "Add";
@ -59,6 +62,8 @@ Please check your internet connection.";
"Common.Controls.Actions.SignUp" = "Create account";
"Common.Controls.Actions.Skip" = "Skip";
"Common.Controls.Actions.TakePhoto" = "Take Photo";
"Common.Controls.Actions.TranslatePost.Title" = "Translate from %@";
"Common.Controls.Actions.TranslatePost.UnknownLanguage" = "Unknown";
"Common.Controls.Actions.TryAgain" = "Try Again";
"Common.Controls.Actions.UnblockDomain" = "Unblock %@";
"Common.Controls.Friendship.Block" = "Block";
@ -124,6 +129,9 @@ Please check your internet connection.";
"Common.Controls.Status.Tag.Mention" = "Mention";
"Common.Controls.Status.Tag.Url" = "URL";
"Common.Controls.Status.TapToReveal" = "Tap to reveal";
"Common.Controls.Status.Translation.ShowOriginal" = "Show Original";
"Common.Controls.Status.Translation.TranslatedFrom" = "Translated from %@";
"Common.Controls.Status.Translation.UnknownLanguage" = "Unknown";
"Common.Controls.Status.UserReblogged" = "%@ reblogged";
"Common.Controls.Status.UserRepliedTo" = "Replied to %@";
"Common.Controls.Status.Visibility.Direct" = "Only mentioned user can see this post.";
@ -467,4 +475,4 @@ uploaded to Mastodon.";
back in your hands.";
"Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard";
"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button.";
"Scene.Wizard.NewInMastodon" = "New in Mastodon";
"Scene.Wizard.NewInMastodon" = "New in Mastodon";

View File

@ -22,6 +22,9 @@ Please check your internet connection.";
"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?";
"Common.Alerts.SignOut.Title" = "Sign Out";
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
"Common.Alerts.TranslationFailed.Title" = "Note";
"Common.Alerts.TranslationFailed.Message" = "Translation failed. Maybe the administrator has not enabled translations on this server or this server is running an older version of Mastodon where translations are not yet supported.";
"Common.Alerts.TranslationFailed.Button" = "OK";
"Common.Alerts.VoteFailure.PollEnded" = "The poll has ended";
"Common.Alerts.VoteFailure.Title" = "Vote Failure";
"Common.Controls.Actions.Add" = "Add";
@ -59,6 +62,8 @@ Please check your internet connection.";
"Common.Controls.Actions.SignUp" = "Create account";
"Common.Controls.Actions.Skip" = "Skip";
"Common.Controls.Actions.TakePhoto" = "Take Photo";
"Common.Controls.Actions.TranslatePost.Title" = "Translate from %@";
"Common.Controls.Actions.TranslatePost.UnknownLanguage" = "Unknown";
"Common.Controls.Actions.TryAgain" = "Try Again";
"Common.Controls.Actions.UnblockDomain" = "Unblock %@";
"Common.Controls.Friendship.Block" = "Block";
@ -124,6 +129,9 @@ Please check your internet connection.";
"Common.Controls.Status.Tag.Mention" = "Mention";
"Common.Controls.Status.Tag.Url" = "URL";
"Common.Controls.Status.TapToReveal" = "Tap to reveal";
"Common.Controls.Status.Translation.ShowOriginal" = "Show Original";
"Common.Controls.Status.Translation.TranslatedFrom" = "Translated from %@";
"Common.Controls.Status.Translation.UnknownLanguage" = "Unknown";
"Common.Controls.Status.UserReblogged" = "%@ reblogged";
"Common.Controls.Status.UserRepliedTo" = "Replied to %@";
"Common.Controls.Status.Visibility.Direct" = "Only mentioned user can see this post.";
@ -461,4 +469,4 @@ uploaded to Mastodon.";
back in your hands.";
"Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard";
"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button.";
"Scene.Wizard.NewInMastodon" = "New in Mastodon";
"Scene.Wizard.NewInMastodon" = "New in Mastodon";

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Translation>, 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()
}
}

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.V2.Instance>, 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()
}
}

View File

@ -126,6 +126,7 @@ extension Mastodon.API.V2 {
public enum Search { }
public enum Suggestions { }
public enum Media { }
public enum Instance { }
}
extension Mastodon.API {

View File

@ -0,0 +1,108 @@
import Foundation
extension Mastodon.Entity.V2 {
/// Instance
///
/// - Since: 4.0.0
/// - Version: 4.0.3
/// # Last Update
/// 2022/12/09
/// # Reference
/// [Document](https://docs.joinmastodon.org/entities/instance/)
public struct Instance: Codable {
public let domain: String?
public let title: String
public let description: String
public let shortDescription: String?
public let email: String?
public let version: String?
public let languages: [String]? // (ISO 639 Part 1-5 language codes)
public let registrations: Mastodon.Entity.V2.Instance.Registrations?
public let approvalRequired: Bool?
public let invitesEnabled: Bool?
public let urls: Mastodon.Entity.Instance.InstanceURL?
public let statistics: Mastodon.Entity.Instance.Statistics?
public let thumbnail: Thumbnail?
public let contactAccount: Mastodon.Entity.Account?
public let rules: [Mastodon.Entity.Instance.Rule]?
// https://github.com/mastodon/mastodon/pull/16485
public let configuration: Configuration?
public init(domain: String, approvalRequired: Bool? = nil) {
self.domain = domain
self.title = domain
self.description = ""
self.shortDescription = nil
self.email = ""
self.version = nil
self.languages = nil
self.registrations = nil
self.approvalRequired = approvalRequired
self.invitesEnabled = nil
self.urls = nil
self.statistics = nil
self.thumbnail = nil
self.contactAccount = nil
self.rules = nil
self.configuration = nil
}
enum CodingKeys: String, CodingKey {
case domain
case title
case description
case shortDescription = "short_description"
case email
case version
case languages
case registrations
case approvalRequired = "approval_required"
case invitesEnabled = "invites_enabled"
case urls
case statistics = "stats"
case thumbnail
case contactAccount = "contact_account"
case rules
case configuration
}
}
}
extension Mastodon.Entity.V2.Instance {
public struct Configuration: Codable {
public let statuses: Mastodon.Entity.Instance.Configuration.Statuses?
public let mediaAttachments: Mastodon.Entity.Instance.Configuration.MediaAttachments?
public let polls: Mastodon.Entity.Instance.Configuration.Polls?
public let translation: Mastodon.Entity.V2.Instance.Configuration.Translation?
enum CodingKeys: String, CodingKey {
case statuses
case mediaAttachments = "media_attachments"
case polls
case translation
}
}
}
extension Mastodon.Entity.V2.Instance {
public struct Registrations: Codable {
public let enabled: Bool
}
}
extension Mastodon.Entity.V2.Instance.Configuration {
public struct Translation: Codable {
public let enabled: Bool
}
}
extension Mastodon.Entity.V2.Instance {
public struct Thumbnail: Codable {
public let url: String?
}
}

View File

@ -0,0 +1,22 @@
//
// Mastodon+Entity+Translation.swift
//
//
// Created by Marcus Kida on 02.12.22.
//
import Foundation
extension Mastodon.Entity {
public struct Translation: Codable {
public let content: String?
public let sourceLanguage: String?
public let provider: String?
enum CodingKeys: String, CodingKey {
case content
case sourceLanguage = "detected_source_language"
case provider
}
}
}

View File

@ -23,7 +23,8 @@ extension NotificationView {
public var objects = Set<NSManagedObject>()
let logger = Logger(subsystem: "NotificationView", category: "ViewModel")
@Published public var context: AppContext?
@Published public var authContext: AuthContext?
@Published public var type: MastodonNotificationType?
@ -37,6 +38,7 @@ extension NotificationView {
@Published public var isMyself = false
@Published public var isMuting = false
@Published public var isBlocking = false
@Published public var isTranslated = false
@Published public var timestamp: Date?
@ -56,6 +58,9 @@ extension NotificationView.ViewModel {
bindAuthorMenu(notificationView: notificationView)
bindFollowRequest(notificationView: notificationView)
$context
.assign(to: \.context, on: notificationView.statusView.viewModel)
.store(in: &disposeBag)
$authContext
.assign(to: \.authContext, on: notificationView.statusView.viewModel)
.store(in: &disposeBag)
@ -203,20 +208,44 @@ extension NotificationView.ViewModel {
$authorName,
$isMuting,
$isBlocking,
$isMyself
Publishers.CombineLatest(
$isMyself,
$isTranslated
)
)
.sink { authorName, isMuting, isBlocking, isMyself in
.sink { [weak self] authorName, isMuting, isBlocking, isMyselfIsTranslated in
guard let name = authorName?.string else {
notificationView.menuButton.menu = nil
return
}
let (isMyself, isTranslated) = isMyselfIsTranslated
lazy var instanceConfigurationV2: Mastodon.Entity.V2.Instance.Configuration? = {
guard
let self = self,
let context = self.context,
let authContext = self.authContext
else { return nil }
var configuration: Mastodon.Entity.V2.Instance.Configuration? = nil
context.managedObjectContext.performAndWait {
guard let authentication = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)
else { return }
configuration = authentication.instance?.configurationV2
}
return configuration
}()
let menuContext = NotificationView.AuthorMenuContext(
name: name,
isMuting: isMuting,
isBlocking: isBlocking,
isMyself: isMyself,
isBookmarking: false // no bookmark action display for notification item
isBookmarking: false, // no bookmark action display for notification item
isTranslationEnabled: instanceConfigurationV2?.translation?.enabled == true,
isTranslated: isTranslated,
statusLanguage: ""
)
let (menu, actions) = notificationView.setupAuthorMenu(menuContext: menuContext)
notificationView.menuButton.menu = menu

View File

@ -149,12 +149,22 @@ extension StatusAuthorView {
public let isBlocking: Bool
public let isMyself: Bool
public let isBookmarking: Bool
public let isTranslationEnabled: Bool
public let isTranslated: Bool
public let statusLanguage: String?
}
public func setupAuthorMenu(menuContext: AuthorMenuContext) -> (UIMenu, [UIAccessibilityCustomAction]) {
var actions = [MastodonMenu.Action]()
if !menuContext.isMyself {
if let statusLanguage = menuContext.statusLanguage, menuContext.isTranslationEnabled, !menuContext.isTranslated {
actions.append(
.translateStatus(.init(language: statusLanguage))
)
}
actions.append(contentsOf: [
.muteUser(.init(
name: menuContext.name,

View File

@ -55,6 +55,17 @@ extension StatusView {
configurePoll(status: status)
configureToolbar(status: status)
configureFilter(status: status)
viewModel.originalStatus = status
[
status.publisher(for: \.translatedContent),
status.reblog?.publisher(for: \.translatedContent)
].compactMap { $0 }
.last?
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.configureTranslated(status: status)
}
.store(in: &disposeBag)
}
}
@ -231,7 +242,48 @@ extension StatusView {
.store(in: &disposeBag)
}
func revertTranslation() {
guard let originalStatus = viewModel.originalStatus else { return }
viewModel.translatedFromLanguage = nil
originalStatus.reblog?.update(translatedContent: nil)
originalStatus.update(translatedContent: nil)
configure(status: originalStatus)
}
func configureTranslated(status: Status) {
let translatedContent: String? = {
if let translatedContent = status.reblog?.translatedContent {
return translatedContent
}
return status.translatedContent
}()
guard
let translatedContent = translatedContent
else {
viewModel.isCurrentlyTranslating = false
return
}
// content
do {
let content = MastodonContent(content: translatedContent, emojis: status.emojis.asDictionary)
let metaContent = try MastodonMetaContent.convert(document: content)
viewModel.content = metaContent
viewModel.translatedFromLanguage = status.reblog?.language ?? status.language
viewModel.isCurrentlyTranslating = false
} catch {
assertionFailure(error.localizedDescription)
viewModel.content = PlaintextMetaContent(string: "")
}
}
private func configureContent(status: Status) {
guard status.translatedContent == nil else {
return configureTranslated(status: status)
}
let status = status.reblog ?? status
// spoilerText
@ -254,6 +306,8 @@ extension StatusView {
let content = MastodonContent(content: status.content, emojis: status.emojis.asDictionary)
let metaContent = try MastodonMetaContent.convert(document: content)
viewModel.content = metaContent
viewModel.translatedFromLanguage = nil
viewModel.isCurrentlyTranslating = false
} catch {
assertionFailure(error.localizedDescription)
viewModel.content = PlaintextMetaContent(string: "")

View File

@ -17,6 +17,7 @@ import MastodonCommon
import MastodonExtension
import MastodonLocalization
import MastodonSDK
import MastodonMeta
extension StatusView {
public final class ViewModel: ObservableObject {
@ -26,8 +27,10 @@ extension StatusView {
let logger = Logger(subsystem: "StatusView", category: "ViewModel")
public var context: AppContext?
public var authContext: AuthContext?
public var originalStatus: Status?
// Header
@Published public var header: Header = .none
@ -43,6 +46,10 @@ extension StatusView {
@Published public var isMuting = false
@Published public var isBlocking = false
// Translation
@Published public var isCurrentlyTranslating = false
@Published public var translatedFromLanguage: String?
@Published public var timestamp: Date?
public var timestampFormatter: ((_ date: Date) -> String)?
@Published public var timestampText = ""
@ -134,6 +141,8 @@ extension StatusView {
isContentSensitive = false
isMediaSensitive = false
isSensitiveToggled = false
translatedFromLanguage = nil
isCurrentlyTranslating = false
activeFilters = []
filterContext = nil
@ -581,26 +590,50 @@ extension StatusView.ViewModel {
$isBlocking,
$isBookmark
)
let publishersThree = Publishers.CombineLatest(
$translatedFromLanguage,
$language
)
Publishers.CombineLatest(
Publishers.CombineLatest3(
publisherOne.eraseToAnyPublisher(),
publishersTwo.eraseToAnyPublisher()
publishersTwo.eraseToAnyPublisher(),
publishersThree.eraseToAnyPublisher()
).eraseToAnyPublisher()
.sink { tupleOne, tupleTwo in
.sink { tupleOne, tupleTwo, tupleThree in
let (authorName, isMyself) = tupleOne
let (isMuting, isBlocking, isBookmark) = tupleTwo
let (translatedFromLanguage, language) = tupleThree
guard let name = authorName?.string else {
statusView.authorView.menuButton.menu = nil
return
}
lazy var instanceConfigurationV2: Mastodon.Entity.V2.Instance.Configuration? = {
guard
let context = self.context,
let authContext = self.authContext
else { return nil }
var configuration: Mastodon.Entity.V2.Instance.Configuration? = nil
context.managedObjectContext.performAndWait {
guard let authentication = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)
else { return }
configuration = authentication.instance?.configurationV2
}
return configuration
}()
let menuContext = StatusAuthorView.AuthorMenuContext(
name: name,
isMuting: isMuting,
isBlocking: isBlocking,
isMyself: isMyself,
isBookmarking: isBookmark
isBookmarking: isBookmark,
isTranslationEnabled: instanceConfigurationV2?.translation?.enabled == true,
isTranslated: translatedFromLanguage != nil,
statusLanguage: language
)
let (menu, actions) = authorView.setupAuthorMenu(menuContext: menuContext)
authorView.menuButton.menu = menu

View File

@ -176,6 +176,50 @@ public final class StatusView: UIView {
indicatorView.stopAnimating()
return indicatorView
}()
let isTranslatingLoadingView: UIActivityIndicatorView = {
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
activityIndicatorView.hidesWhenStopped = true
activityIndicatorView.stopAnimating()
return activityIndicatorView
}()
private let translatedInfoLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .regular))
label.textColor = Asset.Colors.Label.secondary.color
return label
}()
lazy var translatedInfoView: UIView = {
let containerView = UIView()
let revertButton = UIButton()
revertButton.titleLabel?.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .bold))
revertButton.setTitle(L10n.Common.Controls.Status.Translation.showOriginal, for: .normal)
revertButton.setTitleColor(Asset.Colors.brand.color, for: .normal)
revertButton.addAction(UIAction { [weak self] _ in
self?.revertTranslation()
}, for: .touchUpInside)
[containerView, translatedInfoLabel, revertButton].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
[translatedInfoLabel, revertButton].forEach {
containerView.addSubview($0)
}
NSLayoutConstraint.activate([
containerView.heightAnchor.constraint(equalToConstant: 24),
translatedInfoLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
translatedInfoLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16),
revertButton.topAnchor.constraint(equalTo: containerView.topAnchor),
revertButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16),
revertButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
])
containerView.isHidden = true
return containerView
}()
// toolbar
let actionToolbarAdaptiveMarginContainerView = AdaptiveMarginContainerView()
@ -217,6 +261,7 @@ public final class StatusView: UIView {
setMediaDisplay(isDisplay: false)
setPollDisplay(isDisplay: false)
setFilterHintLabelDisplay(isDisplay: false)
setupTranslationIndicator()
}
public override init(frame: CGRect) {
@ -386,6 +431,10 @@ extension StatusView.Style {
statusView.contentContainer.addArrangedSubview(statusView.contentMetaText.textView)
statusView.containerStackView.setCustomSpacing(16, after: statusView.contentMetaText.textView)
// translated info
statusView.containerStackView.addArrangedSubview(statusView.isTranslatingLoadingView)
statusView.containerStackView.addArrangedSubview(statusView.translatedInfoView)
statusView.spoilerOverlayView.translatesAutoresizingMaskIntoConstraints = false
statusView.containerStackView.addSubview(statusView.spoilerOverlayView)
statusView.contentContainer.pinTo(to: statusView.spoilerOverlayView)
@ -424,7 +473,7 @@ extension StatusView.Style {
statusView.pollStatusDotLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal)
statusView.pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
statusView.pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal)
// action toolbar
statusView.actionToolbarAdaptiveMarginContainerView.contentView = statusView.actionToolbarContainer
statusView.actionToolbarAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin
@ -650,6 +699,35 @@ extension StatusView: MastodonMenuDelegate {
}
}
extension StatusView {
func setupTranslationIndicator() {
viewModel.$isCurrentlyTranslating
.receive(on: DispatchQueue.main)
.sink { [weak self] isTranslating in
switch isTranslating {
case true:
self?.isTranslatingLoadingView.startAnimating()
case false:
self?.isTranslatingLoadingView.stopAnimating()
}
}
.store(in: &disposeBag)
viewModel.$translatedFromLanguage
.receive(on: DispatchQueue.main)
.sink { [weak self] translatedFromLanguage in
guard let self = self else { return }
if let translatedFromLanguage = translatedFromLanguage {
self.translatedInfoLabel.text = L10n.Common.Controls.Status.Translation.translatedFrom(Locale.current.localizedString(forIdentifier: translatedFromLanguage) ?? L10n.Common.Controls.Status.Translation.unknownLanguage)
self.translatedInfoView.isHidden = false
} else {
self.translatedInfoView.isHidden = true
}
}
.store(in: &disposeBag)
}
}
#if DEBUG
import SwiftUI

View File

@ -40,6 +40,7 @@ public enum MastodonMenu {
extension MastodonMenu {
public enum Action {
case translateStatus(TranslateStatusActionContext)
case muteUser(MuteUserActionContext)
case blockUser(BlockUserActionContext)
case reportUser(ReportUserActionContext)
@ -126,6 +127,15 @@ extension MastodonMenu {
delegate.menuAction(self)
}
return deleteAction
case let .translateStatus(context):
let translateAction = BuiltAction(
title: L10n.Common.Controls.Actions.TranslatePost.title(Locale.current.localizedString(forIdentifier: context.language) ?? L10n.Common.Controls.Actions.TranslatePost.unknownLanguage),
image: UIImage(systemName: "character.book.closed")
) { [weak delegate] in
guard let delegate = delegate else { return }
delegate.menuAction(self)
}
return translateAction
} // end switch
} // end func build
} // end enum Action
@ -225,4 +235,12 @@ extension MastodonMenu {
self.showReblogs = showReblogs
}
}
public struct TranslateStatusActionContext {
public let language: String
public init(language: String) {
self.language = language
}
}
}