mirror of
https://github.com/mastodon/mastodon-ios.git
synced 2024-12-17 03:09:19 +01:00
Merge pull request #741 from mastodon/translate_status
[Feature] Translate Posts
This commit is contained in:
commit
367b52bf64
@ -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": {
|
||||
|
@ -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 */,
|
||||
|
@ -76,6 +76,7 @@ extension NotificationSection {
|
||||
viewModel: NotificationTableViewCell.ViewModel,
|
||||
configuration: Configuration
|
||||
) {
|
||||
cell.notificationView.viewModel.context = context
|
||||
cell.notificationView.viewModel.authContext = configuration.authContext
|
||||
|
||||
StatusSection.setupStatusPollDataSource(
|
||||
|
@ -107,6 +107,7 @@ extension ReportSection {
|
||||
statusView: cell.statusView
|
||||
)
|
||||
|
||||
cell.statusView.viewModel.context = context
|
||||
cell.statusView.viewModel.authContext = configuration.authContext
|
||||
|
||||
cell.configure(
|
||||
|
@ -104,6 +104,7 @@ extension SearchResultSection {
|
||||
statusView: cell.statusView
|
||||
)
|
||||
|
||||
cell.statusView.viewModel.context = context
|
||||
cell.statusView.viewModel.authContext = configuration.authContext
|
||||
|
||||
cell.configure(
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
}
|
||||
|
70
Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift
Normal file
70
Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -18,6 +18,7 @@ extension DiscoveryCommunityViewModel {
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
configuration: StatusSection.Configuration(
|
||||
context: context,
|
||||
authContext: authContext,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
|
@ -18,6 +18,7 @@ extension DiscoveryPostsViewModel {
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
configuration: StatusSection.Configuration(
|
||||
context: context,
|
||||
authContext: authContext,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
|
@ -20,6 +20,7 @@ extension HashtagTimelineViewModel {
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
configuration: StatusSection.Configuration(
|
||||
context: context,
|
||||
authContext: authContext,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
|
@ -22,6 +22,7 @@ extension HomeTimelineViewModel {
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
configuration: StatusSection.Configuration(
|
||||
context: context,
|
||||
authContext: authContext,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate,
|
||||
|
@ -17,6 +17,7 @@ extension BookmarkViewModel {
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
configuration: StatusSection.Configuration(
|
||||
context: context,
|
||||
authContext: authContext,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
|
@ -17,6 +17,7 @@ extension FavoriteViewModel {
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
configuration: StatusSection.Configuration(
|
||||
context: context,
|
||||
authContext: authContext,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
|
@ -18,6 +18,7 @@ extension UserTimelineViewModel {
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
configuration: StatusSection.Configuration(
|
||||
context: context,
|
||||
authContext: authContext,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
|
@ -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?) {
|
||||
|
@ -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?) {
|
||||
|
@ -24,6 +24,7 @@ extension ThreadViewModel {
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
configuration: StatusSection.Configuration(
|
||||
context: context,
|
||||
authContext: authContext,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
|
@ -3,6 +3,6 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>_XCCurrentVersionName</key>
|
||||
<string>CoreData 5.xcdatamodel</string>
|
||||
<string>CoreData 6.xcdatamodel</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -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>
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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 {
|
||||
|
@ -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.")
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
@ -126,6 +126,7 @@ extension Mastodon.API.V2 {
|
||||
public enum Search { }
|
||||
public enum Suggestions { }
|
||||
public enum Media { }
|
||||
public enum Instance { }
|
||||
}
|
||||
|
||||
extension Mastodon.API {
|
||||
|
@ -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?
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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: "")
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user