IceCubesApp/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift
Paul Schuetz 48faddebea
Implement Apple Translate (#2065)
* Implement a first version of Apple's Translation

The user can now choose between his instance's server, DeepL (with API
key) and Apple's Translation framework. A translation is cleared if
the translation type is changed. The strings aren't yet written, but
the translations settings view's inconsistent background is now fixed.

* Transfer the old "always_use_deepl" setting

The "always_use_deepl"-setting is now deleted, but its content is
transferred to the equivalent value in "preferred_translation_type".

* Show the user if the DeepL-API key is still stored

The user is now shown a prompt if they've switched away from
.useDeepl, but there's still an API key stored. The API key is not
deleted if the user doesn't instruct the app to do so, so this change
makes it more transparent, since a user might not expect the key to
be stored and might not want this to be the case.

* Localize Labels

The labels for the buttons and options are now localized. "DeepL API Key" is written consistently (with uppercase Key)

* Run all the strings through localization

The strings "DeepL" and "Apple Translate" are now also saved in
localizable.strings and addressed through keys. They were taken
directly previously, which was inconsistent.

* Fix storage

The selected value for preferredTranslationType wasn't stored, the
synchronization between UserPreferences and Storage is now in place.

* Hide Apple Translate if not yet on iOS 17.4

The Apple Translate option is hidden if the user hasn't updated their
phone to at least iOS 17.4. If the Apple Translate option is selected
but the user has downgraded to before iOS 17.4, the standard instance
option is selected.

* Consistently show Apple Translate

Apple Translate was previously only shown if the standard translate
button was visible, that is now fixed. It's now attached to the
StatusRowView, which is always present.

* Animate the removal of translations

The reset of a translation when the translation type is changed is now
animated, which is important for iPad users if they've translated a
post in the sidebar.

* Add support for the Mac Catalyst build

The Mac Catalyst Version doesn't allow the import of the api, so
compiler flags now check if the import isn't allowed and then remove
all references to Apple Translate.

* Swift Format

* Revert "Run all the strings through localization"

This reverts commit 86c5099662.

# Conflicts:
#	Packages/Env/Sources/Env/TranslationType.swift

* Remove the DeepL fallback

The DeepL fallback for the instance translation service is removed,
error messages are shown if a translation fails.

* Allow for the use of an User API Key as fallback

The DeepL fallback is reinstated if the user has put in their own API
Key

* Make the localization keys clear strings

* Make Apple and the instance a fallback

Apple Translate is now a fallback for both other translation types,
the instance service is a fallback for DeepL.
2024-05-13 13:27:21 +02:00

418 lines
12 KiB
Swift

import Combine
import DesignSystem
import Env
import Models
import NaturalLanguage
import Network
import Observation
import SwiftUI
@MainActor
@Observable public class StatusRowViewModel {
let status: Status
// Whether this status is on a remote local timeline (many actions are unavailable if so)
let isRemote: Bool
let showActions: Bool
let textDisabled: Bool
let finalStatus: AnyStatus
let client: Client
let routerPath: RouterPath
private let theme = Theme.shared
private let userMentionned: Bool
var isPinned: Bool
var embeddedStatus: Status?
var displaySpoiler: Bool = false
var isEmbedLoading: Bool = false
var isFiltered: Bool = false
var translation: Translation?
var isLoadingTranslation: Bool = false
var showDeleteAlert: Bool = false
var showAppleTranslation = false
var preferredTranslationType = TranslationType.useServerIfPossible {
didSet {
if oldValue != preferredTranslationType {
translation = nil
showAppleTranslation = false
}
}
}
var deeplTranslationError = false
var instanceTranslationError = false
private(set) var actionsAccountsFetched: Bool = false
var favoriters: [Account] = []
var rebloggers: [Account] = []
var isLoadingRemoteContent: Bool = false
var localStatusId: String?
var localStatus: Status?
private var scrollToId = nil as Binding<String?>?
// The relationship our user has to the author of this post, if available
var authorRelationship: Relationship? {
didSet {
// if we are newly blocking or muting the author, force collapse post so it goes away
if let relationship = authorRelationship,
relationship.blocking || relationship.muting
{
lineLimit = 0
}
}
}
// used by the button to expand a collapsed post
var isCollapsed: Bool = true {
didSet {
recalcCollapse()
}
}
// number of lines to show, nil means show the whole post
var lineLimit: Int?
// post length determining if the post should be collapsed
@ObservationIgnored
let collapseThresholdLength: Int = 750
// number of text lines to show on a collpased post
@ObservationIgnored
let collapsedLines: Int = 8
// user preference, set in init
@ObservationIgnored
var collapseLongPosts: Bool = false
private func recalcCollapse() {
let hasContentWarning = !status.spoilerText.asRawText.isEmpty
let showCollapseButton = collapseLongPosts && isCollapsed && !hasContentWarning
&& finalStatus.content.asRawText.unicodeScalars.count > collapseThresholdLength
let newlineLimit = showCollapseButton && isCollapsed ? collapsedLines : nil
if newlineLimit != lineLimit {
lineLimit = newlineLimit
}
}
var filter: Filtered? {
finalStatus.filtered?.first
}
var isThread: Bool {
status.reblog?.inReplyToId != nil || status.reblog?.inReplyToAccountId != nil ||
status.inReplyToId != nil || status.inReplyToAccountId != nil
}
var highlightRowColor: Color {
if status.visibility == .direct {
theme.tintColor.opacity(0.15)
} else if userMentionned {
theme.secondaryBackgroundColor
} else {
theme.primaryBackgroundColor
}
}
public init(status: Status,
client: Client,
routerPath: RouterPath,
isRemote: Bool = false,
showActions: Bool = true,
textDisabled: Bool = false,
scrollToId: Binding<String?>? = nil)
{
self.status = status
finalStatus = status.reblog ?? status
self.client = client
self.routerPath = routerPath
self.isRemote = isRemote
self.showActions = showActions
self.textDisabled = textDisabled
self.scrollToId = scrollToId
if let reblog = status.reblog {
isPinned = reblog.pinned == true
} else {
isPinned = status.pinned == true
}
if UserPreferences.shared.autoExpandSpoilers {
displaySpoiler = false
} else {
displaySpoiler = !finalStatus.spoilerText.asRawText.isEmpty
}
if status.mentions.first(where: { $0.id == CurrentAccount.shared.account?.id }) != nil {
userMentionned = true
} else {
userMentionned = false
}
isFiltered = filter != nil
if let url = embededStatusURL(),
let embed = StatusEmbedCache.shared.get(url: url)
{
isEmbedLoading = false
embeddedStatus = embed
}
collapseLongPosts = UserPreferences.shared.collapseLongPosts
recalcCollapse()
}
func navigateToDetail() {
if isRemote, let url = URL(string: finalStatus.url ?? "") {
routerPath.navigate(to: .remoteStatusDetail(url: url))
} else {
routerPath.navigate(to: .statusDetailWithStatus(status: status.reblogAsAsStatus ?? status))
}
}
func navigateToAccountDetail(account: Account) {
if isRemote, let url = account.url {
withAnimation {
isLoadingRemoteContent = true
}
Task {
await routerPath.navigateToAccountFrom(url: url)
isLoadingRemoteContent = false
}
} else {
routerPath.navigate(to: .accountDetailWithAccount(account: account))
}
}
func goToParent() {
guard let id = status.inReplyToId else { return }
if let _ = scrollToId {
scrollToId?.wrappedValue = id
} else {
routerPath.navigate(to: .statusDetail(id: id))
}
}
func loadAuthorRelationship() async {
let relationships: [Relationship]? = try? await client.get(endpoint: Accounts.relationships(ids: [status.reblog?.account.id ?? status.account.id]))
authorRelationship = relationships?.first
}
private func embededStatusURL() -> URL? {
let content = finalStatus.content
if !content.statusesURLs.isEmpty,
let url = content.statusesURLs.first,
!StatusEmbedCache.shared.badStatusesURLs.contains(url),
client.hasConnection(with: url)
{
return url
}
return nil
}
func loadEmbeddedStatus() async {
guard embeddedStatus == nil,
let url = embededStatusURL()
else {
if isEmbedLoading {
isEmbedLoading = false
}
return
}
if let embed = StatusEmbedCache.shared.get(url: url) {
isEmbedLoading = false
embeddedStatus = embed
return
}
do {
isEmbedLoading = true
var embed: Status?
if url.absoluteString.contains(client.server), let id = Int(url.lastPathComponent) {
embed = try await client.get(endpoint: Statuses.status(id: String(id)))
} else {
let results: SearchResults = try await client.get(endpoint: Search.search(query: url.absoluteString,
type: "statuses",
offset: 0,
following: nil),
forceVersion: .v2)
embed = results.statuses.first
}
if let embed {
StatusEmbedCache.shared.set(url: url, status: embed)
} else {
StatusEmbedCache.shared.badStatusesURLs.insert(url)
}
withAnimation {
embeddedStatus = embed
isEmbedLoading = false
}
} catch {
isEmbedLoading = false
StatusEmbedCache.shared.badStatusesURLs.insert(url)
}
}
func pin() async {
guard client.isAuth else { return }
isPinned = true
do {
let status: Status = try await client.post(endpoint: Statuses.pin(id: finalStatus.id))
updateFromStatus(status: status)
} catch {
isPinned = false
}
}
func unPin() async {
guard client.isAuth else { return }
isPinned = false
do {
let status: Status = try await client.post(endpoint: Statuses.unpin(id: finalStatus.id))
updateFromStatus(status: status)
} catch {
isPinned = true
}
}
func delete() async {
do {
StreamWatcher.shared.emmitDeleteEvent(for: status.id)
_ = try await client.delete(endpoint: Statuses.status(id: status.id))
} catch {}
}
func fetchActionsAccounts() async {
guard !actionsAccountsFetched else { return }
do {
withAnimation(.smooth) {
actionsAccountsFetched = true
}
let favoriters: [Account] = try await client.get(endpoint: Statuses.favoritedBy(id: status.id, maxId: nil))
let rebloggers: [Account] = try await client.get(endpoint: Statuses.rebloggedBy(id: status.id, maxId: nil))
withAnimation(.smooth) {
self.favoriters = favoriters
self.rebloggers = rebloggers
}
} catch {}
}
private func updateFromStatus(status: Status) {
if let reblog = status.reblog {
isPinned = reblog.pinned == true
} else {
isPinned = status.pinned == true
}
}
func getStatusLang() -> String? {
finalStatus.language
}
func translate(userLang: String) async {
updatePreferredTranslation()
if preferredTranslationType == .useApple {
showAppleTranslation = true
return
}
if preferredTranslationType != .useDeepl {
await translateWithInstance(userLang: userLang)
if translation == nil {
await translateWithDeepL(userLang: userLang)
}
} else {
await translateWithDeepL(userLang: userLang)
if translation == nil {
await translateWithInstance(userLang: userLang)
}
}
var hasShown = false
#if canImport(_Translation_SwiftUI)
if translation == nil,
#available(iOS 17.4, *) {
showAppleTranslation = true
hasShown = true
}
#endif
if !hasShown,
translation == nil {
if preferredTranslationType == .useDeepl {
deeplTranslationError = true
} else {
instanceTranslationError = true
}
}
}
func translateWithDeepL(userLang: String) async {
withAnimation {
isLoadingTranslation = true
}
let deepLClient = getDeepLClient()
let translation = try? await deepLClient.request(target: userLang,
text: finalStatus.content.asRawText)
withAnimation {
self.translation = translation
isLoadingTranslation = false
}
}
func translateWithInstance(userLang: String) async {
withAnimation {
isLoadingTranslation = true
}
let translation: Translation? = try? await client.post(endpoint: Statuses.translate(id: finalStatus.id,
lang: userLang))
withAnimation {
self.translation = translation
isLoadingTranslation = false
}
}
private func getDeepLClient() -> DeepLClient {
let userAPIfree = UserPreferences.shared.userDeeplAPIFree
return DeepLClient(userAPIKey: userAPIKey, userAPIFree: userAPIfree)
}
private var userAPIKey: String? {
DeepLUserAPIHandler.readKey()
}
func updatePreferredTranslation() {
if DeepLUserAPIHandler.shouldAlwaysUseDeepl {
preferredTranslationType = .useDeepl
} else if UserPreferences.shared.preferredTranslationType == .useApple {
preferredTranslationType = .useApple
} else {
preferredTranslationType = .useServerIfPossible
}
}
func fetchRemoteStatus() async -> Bool {
guard isRemote, let remoteStatusURL = URL(string: finalStatus.url ?? "") else { return false }
isLoadingRemoteContent = true
let results: SearchResults? = try? await client.get(endpoint: Search.search(query: remoteStatusURL.absoluteString,
type: "statuses",
offset: nil,
following: nil),
forceVersion: .v2)
if let status = results?.statuses.first {
localStatusId = status.id
localStatus = status
isLoadingRemoteContent = false
return true
} else {
isLoadingRemoteContent = false
return false
}
}
}