New options to choose which language to translate to and which languages to block translations from

This commit is contained in:
Vitor Pamplona
2023-02-08 11:57:36 -05:00
parent abf217b71d
commit e9f0fb82e9
8 changed files with 368 additions and 228 deletions

View File

@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import java.util.Locale
import nostr.postr.Persona import nostr.postr.Persona
import nostr.postr.events.ContactListEvent import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event import nostr.postr.events.Event
@@ -26,6 +27,8 @@ class LocalPreferences(context: Context) {
remove("following_channels") remove("following_channels")
remove("hidden_users") remove("hidden_users")
remove("relays") remove("relays")
remove("dontTranslateFrom")
remove("translateTo")
}.apply() }.apply()
} }
@@ -36,6 +39,8 @@ class LocalPreferences(context: Context) {
account.followingChannels.let { putStringSet("following_channels", it) } account.followingChannels.let { putStringSet("following_channels", it) }
account.hiddenUsers.let { putStringSet("hidden_users", it) } account.hiddenUsers.let { putStringSet("hidden_users", it) }
account.localRelays.let { putString("relays", gson.toJson(it)) } account.localRelays.let { putString("relays", gson.toJson(it)) }
account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) }
account.translateTo.let { putString("translateTo", it) }
}.apply() }.apply()
} }
@@ -43,19 +48,24 @@ class LocalPreferences(context: Context) {
encryptedPreferences.apply { encryptedPreferences.apply {
val privKey = getString("nostr_privkey", null) val privKey = getString("nostr_privkey", null)
val pubKey = getString("nostr_pubkey", null) val pubKey = getString("nostr_pubkey", null)
val followingChannels = getStringSet("following_channels", null)?.toMutableSet() ?: mutableSetOf() val followingChannels = getStringSet("following_channels", null) ?: setOf()
val hiddenUsers = getStringSet("hidden_users", emptySet())?.toMutableSet() ?: mutableSetOf() val hiddenUsers = getStringSet("hidden_users", emptySet()) ?: setOf()
val localRelays = gson.fromJson( val localRelays = gson.fromJson(
getString("relays", "[]"), getString("relays", "[]"),
object : TypeToken<Set<NewRelayListViewModel.Relay>>() {}.type object : TypeToken<Set<NewRelayListViewModel.Relay>>() {}.type
) ?: setOf<NewRelayListViewModel.Relay>() ) ?: setOf<NewRelayListViewModel.Relay>()
val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf()
val translateTo = getString("translateTo", null) ?: Locale.getDefault().language
if (pubKey != null) { if (pubKey != null) {
return Account( return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels, followingChannels,
hiddenUsers, hiddenUsers,
localRelays localRelays,
dontTranslateFrom,
translateTo
) )
} else { } else {
return null return null

View File

@@ -1,5 +1,9 @@
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Text
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.relays.Constants import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
@@ -14,6 +18,8 @@ import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import java.util.Date import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -34,13 +40,23 @@ val DefaultChannels = setOf(
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
) )
fun getLanguagesSpokenByUser(): Set<String> {
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
val codedList = mutableSetOf<String>()
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { codedList.add(it.language) }
}
return codedList
}
class Account( class Account(
val loggedIn: Persona, val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels, var followingChannels: Set<String> = DefaultChannels,
var hiddenUsers: Set<String> = setOf(), var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<NewRelayListViewModel.Relay> = Constants.defaultRelays.toSet() var localRelays: Set<NewRelayListViewModel.Relay> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var translateTo: String = Locale.getDefault().language
) { ) {
fun userProfile(): User { fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey()) return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
} }
@@ -265,22 +281,22 @@ class Account(
fun joinChannel(idHex: String) { fun joinChannel(idHex: String) {
followingChannels = followingChannels + idHex followingChannels = followingChannels + idHex
invalidateData() invalidateData(live)
} }
fun leaveChannel(idHex: String) { fun leaveChannel(idHex: String) {
followingChannels = followingChannels - idHex followingChannels = followingChannels - idHex
invalidateData() invalidateData(live)
} }
fun hideUser(pubkeyHex: String) { fun hideUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers + pubkeyHex hiddenUsers = hiddenUsers + pubkeyHex
invalidateData() invalidateData(live)
} }
fun showUser(pubkeyHex: String) { fun showUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers - pubkeyHex hiddenUsers = hiddenUsers - pubkeyHex
invalidateData() invalidateData(live)
} }
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
@@ -332,6 +348,16 @@ class Account(
} }
} }
fun addDontTranslateFrom(languageCode: String) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
invalidateData(liveLanguages)
}
fun updateTranslateTo(languageCode: String) {
translateTo = languageCode
invalidateData(liveLanguages)
}
fun activeRelays(): Array<Relay>? { fun activeRelays(): Array<Relay>? {
return userProfile().relays?.map { return userProfile().relays?.map {
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet() val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
@@ -357,19 +383,20 @@ class Account(
// Observers line up here. // Observers line up here.
val live: AccountLiveData = AccountLiveData(this) val live: AccountLiveData = AccountLiveData(this)
val liveLanguages: AccountLiveData = AccountLiveData(this)
var handlerWaiting = AtomicBoolean()
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized @Synchronized
fun invalidateData() { private fun invalidateData(live: AccountLiveData) {
if (handlerWaiting) return if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default) val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch { scope.launch {
delay(100) delay(100)
live.refresh() live.refresh()
handlerWaiting = false handlerWaiting.set(false)
} }
} }
@@ -412,7 +439,6 @@ class Account(
localRelays = value.toSet() localRelays = value.toSet()
sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } ) sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
} }
} }
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) { class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {

View File

@@ -0,0 +1,84 @@
package com.vitorpamplona.amethyst.service.lang
import android.util.LruCache
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.Translator
import com.google.mlkit.nl.translate.TranslatorOptions
import java.util.ArrayList
class ResultOrError(
var result: String?,
var sourceLang: String?,
var targetLang: String?,
var error: Exception?
)
object LanguageTranslatorService {
private val languageIdentification = LanguageIdentification.getClient()
private val translators =
object : LruCache<TranslatorOptions, Translator>(10) {
override fun create(options: TranslatorOptions): Translator {
return Translation.getClient(options)
}
override fun entryRemoved(
evicted: Boolean,
key: TranslatorOptions,
oldValue: Translator,
newValue: Translator?
) {
oldValue.close()
}
}
fun identifyLanguage(text: String): Task<String> {
return languageIdentification.identifyLanguage(text)
}
fun translate(text: String, source: String, target: String): Task<ResultOrError> {
val sourceLangCode = TranslateLanguage.fromLanguageTag(source)
val targetLangCode = TranslateLanguage.fromLanguageTag(target)
if (sourceLangCode == null || targetLangCode == null) {
return Tasks.forCanceled()
}
val options = TranslatorOptions.Builder()
.setSourceLanguage(sourceLangCode)
.setTargetLanguage(targetLangCode)
.build()
val translator = translators[options]
return translator.downloadModelIfNeeded().onSuccessTask {
val tasks = mutableListOf<Task<String>>()
for (paragraph in text.split("\n")) {
tasks.add(translator.translate(paragraph))
}
Tasks.whenAll(tasks).continueWith {
val results: MutableList<String> = ArrayList()
for (task in tasks) {
results.add(task.result)
}
ResultOrError(results.joinToString("\n"), source, target, null)
}
}
}
fun autoTranslate(text: String, dontTranslateFrom: Set<String>, translateTo: String): Task<ResultOrError> {
return identifyLanguage(text).onSuccessTask {
if (it == translateTo) {
Tasks.forCanceled()
} else if (it != "und" && !dontTranslateFrom.contains(it)) {
translate(text, it, translateTo)
} else {
Tasks.forCanceled()
}
}
}
}

View File

@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources import android.content.res.Resources
import android.util.LruCache
import android.util.Patterns import android.util.Patterns
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -10,10 +9,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
@@ -23,19 +24,17 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat import androidx.core.os.ConfigurationCompat
import androidx.navigation.NavController import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.flowlayout.FlowRow
import com.google.android.gms.tasks.Task import com.vitorpamplona.amethyst.LocalPreferences
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.Translator
import com.google.mlkit.nl.translate.TranslatorOptions
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toNote import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.Nip19 import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toNpub import nostr.postr.toNpub
import java.net.MalformedURLException import java.net.MalformedURLException
import java.net.URISyntaxException import java.net.URISyntaxException
@@ -64,96 +63,139 @@ fun isValidURL(url: String?): Boolean {
} }
@Composable @Composable
fun RichTextViewer(content: String, canPreview: Boolean, tags: List<List<String>>?, navController: NavController) { fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
tags: List<List<String>>?,
accountViewModel: AccountViewModel,
navController: NavController
) {
val translatedTextState = remember { val translatedTextState = remember {
mutableStateOf(ResultOrError(content, null, null, null)) mutableStateOf(ResultOrError(content, null, null, null))
} }
var showOriginal by remember { mutableStateOf(false) } var showOriginal by remember { mutableStateOf(false) }
var showFullText by remember { mutableStateOf(false) } var langSettingsPopupExpanded by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { val context = LocalContext.current
LanguageTranslatorService.autoTranslate(content).addOnCompleteListener { task ->
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = accountState?.account ?: return
LaunchedEffect(accountState) {
LanguageTranslatorService.autoTranslate(content, account.dontTranslateFrom, account.translateTo).addOnCompleteListener { task ->
if (task.isSuccessful) { if (task.isSuccessful) {
translatedTextState.value = task.result translatedTextState.value = task.result
} else {
translatedTextState.value = ResultOrError(content, null, null, null)
} }
} }
} }
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
val text = if (showFullText) toBeViewed else toBeViewed.take(350)
Column(modifier = Modifier.padding(top = 5.dp)) { Column(modifier = Modifier.padding(top = 5.dp)) {
ExpandableRichTextViewer(
toBeViewed,
canPreview,
tags,
navController
)
Box(contentAlignment = Alignment.BottomCenter) { val target = translatedTextState.value.targetLang
val source = translatedTextState.value.sourceLang
Column(Modifier.fillMaxWidth().animateContentSize()) { if (source != null && target != null) {
// FlowRow doesn't work well with paragraphs. So we need to split them if (source != target) {
text.split('\n').forEach { paragraph -> Row(modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
val clickableTextStyle = SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
FlowRow() { val annotatedTranslationString= buildAnnotatedString {
paragraph.split(' ').forEach { word: String -> withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append("Auto")
}
if (canPreview) { append("-translated from ")
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word) withStyle(clickableTextStyle) {
if (lnInvoice != null) { pushStringAnnotation("showOriginal", true.toString())
InvoicePreview(lnInvoice) append(Locale(source).displayName)
} else if (isValidURL(word)) { }
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) { append(" to ")
ZoomableImageView(word)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) { withStyle(clickableTextStyle) {
VideoView(word) pushStringAnnotation("showOriginal", false.toString())
} else { append(Locale(target).displayName)
UrlPreview(word, word) }
} }
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word) ClickableText(
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { text = annotatedTranslationString,
ClickablePhone(word) style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
} else if (noProtocolUrlValidator.matcher(word).matches()) { overflow = TextOverflow.Visible,
UrlPreview("https://$word", word) maxLines = 3
} else if (tagIndex.matcher(word).matches() && tags != null) { ) { spanOffset -> annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset)
TagLink(word, tags, navController) .firstOrNull()
} else if (isBechLink(word)) { ?.also { span ->
BechLink(word, navController) if (span.tag == "showOriginal")
} else { showOriginal = span.item.toBoolean()
Text( else
text = "$word ", langSettingsPopupExpanded = !langSettingsPopupExpanded
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), }
) }
}
} else { DropdownMenu(
if (isValidURL(word)) { expanded = langSettingsPopupExpanded,
ClickableUrl("$word ", word) onDismissRequest = { langSettingsPopupExpanded = false }
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { ) {
ClickableEmail(word) DropdownMenuItem(onClick = {
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { accountViewModel.dontTranslateFrom(source, context)
ClickablePhone(word) langSettingsPopupExpanded = false
} else if (noProtocolUrlValidator.matcher(word).matches()) { }) {
ClickableUrl(word, "https://$word") Text("Never translate from ${Locale(source).displayName}")
} else if (tagIndex.matcher(word).matches() && tags != null) { }
TagLink(word, tags, navController) Divider()
} else if (isBechLink(word)) { val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
BechLink(word, navController) for (i in 0 until languageList.size()) {
} else { languageList.get(i)?.let { lang ->
Text( DropdownMenuItem(onClick = {
text = "$word ", accountViewModel.translateTo(lang, context)
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), langSettingsPopupExpanded = false
) }) {
Text("Always translate to ${lang.displayName}")
} }
} }
} }
} }
} }
} }
}
}
}
if (toBeViewed.length > 350 && !showFullText) { @Composable
Row( fun ExpandableRichTextViewer(
verticalAlignment = Alignment.CenterVertically, content: String,
horizontalArrangement = Arrangement.Center, canPreview: Boolean,
modifier = Modifier.fillMaxWidth().background( tags: List<List<String>>?,
navController: NavController
) {
var showFullText by remember { mutableStateOf(false) }
val text = if (showFullText) content else content.take(350)
Box(contentAlignment = Alignment.BottomCenter) {
RichTextViewer(text, canPreview, tags, navController)
if (content.length > 350 && !showFullText) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient( brush = Brush.verticalGradient(
colors = listOf( colors = listOf(
MaterialTheme.colors.background.copy(alpha = 0f), MaterialTheme.colors.background.copy(alpha = 0f),
@@ -161,63 +203,98 @@ fun RichTextViewer(content: String, canPreview: Boolean, tags: List<List<String>
) )
) )
) )
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { showFullText = !showFullText },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) { ) {
Button( Text(text = "Show More", color = Color.White)
modifier = Modifier.padding(top = 10.dp),
onClick = { showFullText = !showFullText },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Show More", color = Color.White)
}
}
}
}
val target = translatedTextState.value.targetLang
val source = translatedTextState.value.sourceLang
if (source != null && target != null) {
if (source != target) {
val clickableTextStyle = SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
val annotatedTranslationString= buildAnnotatedString {
append("Auto-translated from ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", true.toString())
append(Locale(source).displayName)
}
append(" to ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", false.toString())
append(Locale(target).displayName)
}
}
ClickableText(
text = annotatedTranslationString,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
overflow = TextOverflow.Visible,
maxLines = 3
) { spanOffset ->
annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset)
.firstOrNull()
?.also { span ->
showOriginal = span.item.toBoolean()
}
} }
} }
} }
} }
} }
@Composable
fun RichTextViewer(
content: String,
canPreview: Boolean,
tags: List<List<String>>?,
navController: NavController
) {
Column(
Modifier
.fillMaxWidth()
.animateContentSize()) {
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
paragraph.split(' ').forEach { word: String ->
if (canPreview) {
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
} else {
UrlPreview(word, word)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
} else {
if (isValidURL(word)) {
ClickableUrl("$word ", word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
ClickableUrl(word, "https://$word")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
}
}
}
}
}
}
fun isBechLink(word: String): Boolean { fun isBechLink(word: String): Boolean {
return word.startsWith("nostr:", true) return word.startsWith("nostr:", true)
|| word.startsWith("npub1", true) || word.startsWith("npub1", true)
@@ -291,89 +368,3 @@ fun TagLink(word: String, tags: List<List<String>>, navController: NavController
} }
class ResultOrError(
var result: String?,
var sourceLang: String?,
var targetLang: String?,
var error: Exception?
)
object LanguageTranslatorService {
private val languageIdentification = LanguageIdentification.getClient()
private val languagesSpokenByTheUser = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()).toLanguageTags()
private val usersPreferredLanguage = Locale.getDefault().language
init {
println("LanguagesAAA: ${languagesSpokenByTheUser}")
}
private val translators =
object : LruCache<TranslatorOptions, Translator>(10) {
override fun create(options: TranslatorOptions): Translator {
return Translation.getClient(options)
}
override fun entryRemoved(
evicted: Boolean,
key: TranslatorOptions,
oldValue: Translator,
newValue: Translator?
) {
oldValue.close()
}
}
fun identifyLanguage(text: String): Task<String> {
return languageIdentification.identifyLanguage(text)
}
fun translate(text: String, source: String, target: String): Task<ResultOrError> {
val sourceLangCode = TranslateLanguage.fromLanguageTag(source)
val targetLangCode = TranslateLanguage.fromLanguageTag(target)
if (sourceLangCode == null || targetLangCode == null) {
return Tasks.forCanceled()
}
val options = TranslatorOptions.Builder()
.setSourceLanguage(sourceLangCode)
.setTargetLanguage(targetLangCode)
.build()
val translator = translators[options]
return translator.downloadModelIfNeeded().onSuccessTask {
val tasks = mutableListOf<Task<String>>()
for (paragraph in text.split("\n")) {
tasks.add(translator.translate(paragraph))
}
Tasks.whenAll(tasks).continueWith {
val results: MutableList<String> = ArrayList()
for (task in tasks) {
results.add(task.result)
}
ResultOrError(results.joinToString("\n"), source, target, null)
}
}
}
fun autoTranslate(text: String, target: String): Task<ResultOrError> {
return identifyLanguage(text).onSuccessTask {
if (it == target) {
Tasks.forCanceled()
} else if (it != "und" && !languagesSpokenByTheUser.contains(it)) {
translate(text, it, target)
} else {
Tasks.forCanceled()
}
}
}
fun autoTranslate(text: String): Task<ResultOrError> {
return autoTranslate(text, usersPreferredLanguage)
}
}

View File

@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp) val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
@@ -221,17 +222,19 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
|| !noteForReports.hasAnyReports() || !noteForReports.hasAnyReports()
if (eventContent != null) { if (eventContent != null) {
RichTextViewer( TranslateableRichTextViewer(
eventContent, eventContent,
canPreview, canPreview,
note.event?.tags, note.event?.tags,
accountViewModel,
navController navController
) )
} else { } else {
RichTextViewer( TranslateableRichTextViewer(
"Could Not decrypt the message", "Could Not decrypt the message",
true, true,
note.event?.tags, note.event?.tags,
accountViewModel,
navController navController
) )
} }

View File

@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Following import com.vitorpamplona.amethyst.ui.theme.Following
import nostr.postr.events.TextNoteEvent import nostr.postr.events.TextNoteEvent
@@ -271,7 +272,13 @@ fun NoteCompose(
|| !noteForReports.hasAnyReports() || !noteForReports.hasAnyReports()
if (eventContent != null) { if (eventContent != null) {
RichTextViewer(eventContent, canPreview, note.event?.tags, navController) TranslateableRichTextViewer(
eventContent,
canPreview,
note.event?.tags,
accountViewModel,
navController
)
} }
ReactionsRow(note, accountViewModel) ReactionsRow(note, accountViewModel)

View File

@@ -42,6 +42,7 @@ import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.RichTextViewer import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.HiddenNote import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
@@ -226,7 +227,13 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
|| !noteForReports.hasAnyReports() || !noteForReports.hasAnyReports()
if (eventContent != null) { if (eventContent != null) {
RichTextViewer(eventContent, canPreview, note.event?.tags, navController) TranslateableRichTextViewer(
eventContent,
canPreview,
note.event?.tags,
accountViewModel,
navController
)
} }
ReactionsRow(note, accountViewModel) ReactionsRow(note, accountViewModel)

View File

@@ -10,9 +10,11 @@ import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.ReportEvent
import java.util.Locale
class AccountViewModel(private val account: Account): ViewModel() { class AccountViewModel(private val account: Account): ViewModel() {
val accountLiveData: LiveData<AccountState> = account.live.map { it } val accountLiveData: LiveData<AccountState> = account.live.map { it }
val accountLanguagesLiveData: LiveData<AccountState> = account.liveLanguages.map { it }
fun reactTo(note: Note) { fun reactTo(note: Note) {
account.reactTo(note) account.reactTo(note)
@@ -47,4 +49,14 @@ class AccountViewModel(private val account: Account): ViewModel() {
account.showUser(user.pubkeyHex) account.showUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account) LocalPreferences(ctx).saveToEncryptedStorage(account)
} }
fun translateTo(lang: Locale, ctx: Context) {
account.updateTranslateTo(lang.language)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
fun dontTranslateFrom(lang: String, ctx: Context) {
account.addDontTranslateFrom(lang)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} }