Compare commits

...
34 Commits
Author SHA1 Message Date
Vitor Pamplona a38087a9b9 v0.14.2 2023-02-10 09:13:45 -05:00
Vitor Pamplona 29dc677148 Allows the user to leave and join their own groups 2023-02-10 09:13:01 -05:00
Vitor Pamplona 50bc55e84b More Performant UpdateFollows 2023-02-09 17:52:18 -05:00
Vitor Pamplona ae8d39df92 Download only metadata in the Search for authors 2023-02-09 16:42:30 -05:00
Vitor Pamplona 09919e99b9 Fixes: https://github.com/vitorpamplona/amethyst/issues/91 2023-02-09 15:25:32 -05:00
Vitor Pamplona 5069c30a4a Fixes https://github.com/vitorpamplona/amethyst/issues/103 Doesn't do anything if the content in the field is not a URI. 2023-02-09 15:11:20 -05:00
Vitor Pamplona 4097263c12 Activating Proguard 2023-02-09 15:02:13 -05:00
Vitor Pamplona 88e91e37fa Fixing the height of the nav bar icon 2023-02-09 14:56:54 -05:00
Vitor Pamplona a003e111eb Fixing QR Code screen for smaller screens. 2023-02-09 14:45:00 -05:00
Vitor Pamplona 4cde2fe8e6 Refactoring of RelaySetupInfo away from ModelView 2023-02-09 14:25:58 -05:00
Vitor Pamplona ffe5e79474 Making sure once proguard is used, it doesn't break json serialization 2023-02-09 14:24:56 -05:00
Vitor Pamplona 19a9e7a949 language-id seems necessary for some phones. unsure why 2023-02-09 14:24:28 -05:00
Vitor Pamplona 757e566774 Fixes permissions issue in Image Upload 2023-02-09 11:22:49 -05:00
Vitor Pamplona 7fff535b74 v0.14.1:
- Save image
- LNURL edit in the profile
- Bugfix crashing the Lightning Tips with non numeric values in the amount.
2023-02-09 10:42:08 -05:00
Vitor Pamplona 65ef34511a Adding LNURL setup in the User Metadata 2023-02-09 10:36:43 -05:00
Vitor Pamplona 644c6b4d76 Merge remote-tracking branch 'origin/main' 2023-02-09 09:47:34 -05:00
Vitor Pamplona dec17e162e Avoids crashing on a non-numeric amount in Lightning Tips 2023-02-09 09:47:23 -05:00
Vitor PamplonaandGitHub df49c63936 Merge pull request #99 from Radiokot/feature/save_images
Add Save option to the image view screen
2023-02-09 09:22:46 -05:00
Vitor PamplonaandGitHub f0d4ad38a5 Merge pull request #98 from bleetube/offchain.pub
Public relay name change
2023-02-09 09:22:27 -05:00
Vitor Pamplona 7cb146bb50 v0.14.0 2023-02-08 18:27:23 -05:00
Vitor Pamplona 5195ddf63e Sending Lightning Tips 2023-02-08 18:26:49 -05:00
Oleg Koretsky 3fde6b4b1f Add Save option to the image view screen 2023-02-08 22:06:12 +02:00
Brian Lee 5102a15532 Public relay name change 2023-02-08 12:05:56 -08:00
Vitor Pamplona 00981ef15c Refactoring RichTextViewer file 2023-02-08 14:02:42 -05:00
Vitor Pamplona f2badce3b8 v0.13.3 2023-02-08 13:55:25 -05:00
Vitor Pamplona e9f0fb82e9 New options to choose which language to translate to and which languages to block translations from 2023-02-08 11:57:36 -05:00
Vitor Pamplona abf217b71d Fixes: https://github.com/vitorpamplona/amethyst/issues/95 2023-02-07 18:03:48 -05:00
Vitor PamplonaandGitHub 9faf6a2fbc Merge pull request #94 from Radiokot/fix/ui_issues
Fix some UI issues
2023-02-07 15:59:26 -05:00
Vitor PamplonaandGitHub f61b894ddf Merge branch 'main' into fix/ui_issues 2023-02-07 15:59:19 -05:00
Vitor PamplonaandGitHub b0dda37023 Merge pull request #90 from greenart7c3/main
fix dropDown color on light theme
2023-02-07 15:56:17 -05:00
Oleg Koretsky d600d1c3e5 Fix invisible user profile menu button (white on white) 2023-02-07 12:01:23 +02:00
Oleg Koretsky 79947524cc Fix login screen buttons crop when the keyboard is opened 2023-02-07 11:53:46 +02:00
Oleg Koretsky 2e49bbc606 Make post translation label wrap words properly, fix #86 2023-02-07 11:20:45 +02:00
greenart7c3 3a7228712c fix dropDown color on light theme 2023-02-06 17:30:35 -03:00
35 changed files with 1423 additions and 609 deletions
+5 -4
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 38
versionName "0.13.2"
versionCode 42
versionName "0.14.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -22,8 +22,8 @@ android {
buildTypes {
release {
//minifyEnabled false
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
resValue "string", "app_name", "@string/app_name_release"
}
debug {
@@ -130,6 +130,7 @@ dependencies {
// Use this dependency to use the translate text
implementation 'com.google.mlkit:translate:17.0.1'
implementation 'com.google.mlkit:language-id:17.0.4'
implementation 'com.google.android.gms:play-services-mlkit-language-id:17.0.0'
testImplementation 'junit:junit:4.13.2'
+4
View File
@@ -26,6 +26,10 @@
# For the NostrPostr library
-keep class nostr.postr.** { *; }
-keep class nostr.postr.events.** { *; }
# Json parsing
-keep class com.google.gson.reflect.** { *; }
-keep class * extends com.google.gson.reflect.TypeToken
-keep public class * implements java.lang.reflect.Type
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }
+5
View File
@@ -4,8 +4,13 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" />
<!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -4,15 +4,10 @@ import android.content.Context
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import java.util.Locale
import nostr.postr.Persona
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.toHex
class LocalPreferences(context: Context) {
@@ -26,6 +21,8 @@ class LocalPreferences(context: Context) {
remove("following_channels")
remove("hidden_users")
remove("relays")
remove("dontTranslateFrom")
remove("translateTo")
}.apply()
}
@@ -36,6 +33,8 @@ class LocalPreferences(context: Context) {
account.followingChannels.let { putStringSet("following_channels", it) }
account.hiddenUsers.let { putStringSet("hidden_users", it) }
account.localRelays.let { putString("relays", gson.toJson(it)) }
account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) }
account.translateTo.let { putString("translateTo", it) }
}.apply()
}
@@ -43,19 +42,24 @@ class LocalPreferences(context: Context) {
encryptedPreferences.apply {
val privKey = getString("nostr_privkey", null)
val pubKey = getString("nostr_pubkey", null)
val followingChannels = getStringSet("following_channels", null)?.toMutableSet() ?: mutableSetOf()
val hiddenUsers = getStringSet("hidden_users", emptySet())?.toMutableSet() ?: mutableSetOf()
val followingChannels = getStringSet("following_channels", null) ?: setOf()
val hiddenUsers = getStringSet("hidden_users", emptySet()) ?: setOf()
val localRelays = gson.fromJson(
getString("relays", "[]"),
object : TypeToken<Set<NewRelayListViewModel.Relay>>() {}.type
) ?: setOf<NewRelayListViewModel.Relay>()
object : TypeToken<Set<RelaySetupInfo>>() {}.type
) ?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf()
val translateTo = getString("translateTo", null) ?: Locale.getDefault().language
if (pubKey != null) {
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers,
localRelays
localRelays,
dontTranslateFrom,
translateTo
)
} else {
return null
@@ -0,0 +1,106 @@
package com.vitorpamplona.amethyst.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlin.math.ln
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class LightningAddressResolver {
val client = OkHttpClient.Builder().build()
fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@")
if (parts.size != 2) {
return null
}
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
}
fun fetchLightningAddressJson(lnaddress: String, onSucess: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningAddressJsonSuspend(lnaddress, onSucess)
}
}
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSucess: (String) -> Unit) {
val url = assembleUrl(lnaddress) ?: return
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
onSucess(response.body.string())
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
e.printStackTrace()
}
})
}
}
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, onSucess: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, onSucess)
}
}
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, onSucess: (String) -> Unit) {
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
val url = "$lnCallback${urlBinder}amount=$milliSats&comment=$message"
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
onSucess(response.body.string())
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
e.printStackTrace()
}
})
}
}
fun lnAddressToLnUrl(lnaddress: String, onSucess: (String) -> Unit) {
fetchLightningAddressJson(lnaddress) {
onSucess(Bech32.encodeBytes("lnurl",it.toByteArray(), Bech32.Encoding.Bech32))
}
}
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, onSucess: (String) -> Unit) {
val mapper = jacksonObjectMapper()
fetchLightningAddressJson(lnaddress) {
mapper.readTree(it)?.get("callback")?.asText()?.let { callback ->
fetchLightningInvoice(callback, milliSats, message,
onSucess = {
mapper.readTree(it)?.get("pr")?.asText()?.let { pr ->
onSucess(pr)
}
}
)
}
}
}
}
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
@@ -12,8 +14,9 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -34,13 +37,23 @@ val DefaultChannels = setOf(
"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(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<NewRelayListViewModel.Relay> = Constants.defaultRelays.toSet()
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var translateTo: String = Locale.getDefault().language
) {
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
}
@@ -265,22 +278,22 @@ class Account(
fun joinChannel(idHex: String) {
followingChannels = followingChannels + idHex
invalidateData()
invalidateData(live)
}
fun leaveChannel(idHex: String) {
followingChannels = followingChannels - idHex
invalidateData()
invalidateData(live)
}
fun hideUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers + pubkeyHex
invalidateData()
invalidateData(live)
}
fun showUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers - pubkeyHex
invalidateData()
invalidateData(live)
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
@@ -332,6 +345,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>? {
return userProfile().relays?.map {
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
@@ -357,19 +380,20 @@ class Account(
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
val liveLanguages: AccountLiveData = AccountLiveData(this)
var handlerWaiting = AtomicBoolean()
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
private fun invalidateData(live: AccountLiveData) {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
handlerWaiting.set(false)
}
}
@@ -408,11 +432,10 @@ class Account(
innerReports).toSet()
}
fun saveRelayList(value: List<NewRelayListViewModel.Relay>) {
fun saveRelayList(value: List<RelaySetupInfo>) {
localRelays = value.toSet()
sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
}
}
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
@@ -17,6 +17,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.service.relays.Relay
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Matcher
import java.util.regex.Pattern
import nostr.postr.events.Event
@@ -194,17 +195,19 @@ class Note(val idHex: String) {
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
var handlerWaiting = false
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
handlerWaiting.set(false)
}
}
@@ -0,0 +1,13 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.relays.FeedType
data class RelaySetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val feedTypes: Set<FeedType>
)
@@ -10,6 +10,9 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -81,7 +84,11 @@ class User(val pubkeyHex: String) {
liveFollows.invalidateData()
user.liveFollows.invalidateData()
listeners.forEach {
updateSubscribers {
it.onFollowsChange()
}
user.updateSubscribers {
it.onFollowsChange()
}
}
@@ -95,6 +102,40 @@ class User(val pubkeyHex: String) {
updateSubscribers {
it.onFollowsChange()
}
user.updateSubscribers {
it.onFollowsChange()
}
}
fun follow(users: Set<User>, followedAt: Long) {
follows = follows + users
users.forEach {
it.followers = it.followers + this
it.liveFollows.invalidateData()
it.updateSubscribers {
it.onFollowsChange()
}
}
liveFollows.invalidateData()
updateSubscribers {
it.onFollowsChange()
}
}
fun unfollow(users: Set<User>) {
follows = follows - users
users.forEach {
it.followers = it.followers - this
it.liveFollows.invalidateData()
it.updateSubscribers {
it.onFollowsChange()
}
}
liveFollows.invalidateData()
updateSubscribers {
it.onFollowsChange()
}
}
fun addTaggedPost(note: Note) {
@@ -164,17 +205,13 @@ class User(val pubkeyHex: String) {
updateSubscribers { it.onNewRelayInfo() }
liveRelayInfo.invalidateData()
}
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows
toBeAdded.forEach {
follow(it, updateAt)
}
toBeRemoved.forEach {
unfollow(it)
}
follow(toBeAdded, updateAt)
unfollow(toBeRemoved)
updatedFollowsAt = updateAt
}
@@ -239,19 +276,20 @@ class User(val pubkeyHex: String) {
}
// Refreshes observers in batches.
var modelHandlerWaiting = false
var modelHandlerWaiting = AtomicBoolean()
@Synchronized
fun updateSubscribers(on: (Listener) -> Unit) {
if (modelHandlerWaiting) return
if (modelHandlerWaiting.getAndSet(true)) return
modelHandlerWaiting = true
modelHandlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
listeners.forEach {
on(it)
}
modelHandlerWaiting = false
modelHandlerWaiting.set(false)
}
}
@@ -292,17 +330,19 @@ class UserMetadata {
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
var handlerWaiting = false
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
handlerWaiting.set(false)
}
}
@@ -2,10 +2,13 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.bechToBytes
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed") {
@@ -21,13 +24,16 @@ object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed"
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = listOfNotNull(hexToWatch)
)),
ids = listOfNotNull(hexToWatch)
)
),
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
authors = listOfNotNull(hexToWatch)
))
kinds = listOf(MetadataEvent.kind),
authors = listOfNotNull(hexToWatch)
)
)
)
}
@@ -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()
}
}
}
}
@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import com.vitorpamplona.amethyst.model.RelaySetupInfo
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
@@ -14,15 +14,15 @@ object Constants {
val defaultRelays = arrayOf(
// Free relays
NewRelayListViewModel.Relay("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
// Less Reliable
//NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
@@ -35,14 +35,14 @@ object Constants {
//NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
// Paid relays
NewRelayListViewModel.Relay("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
)
}
@@ -0,0 +1,147 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import okhttp3.*
import okio.BufferedSource
import okio.IOException
import okio.sink
import java.io.File
object ImageSaver {
/**
* Saves the image to the gallery.
* May require a storage permission.
*
* @see PICTURES_SUBDIRECTORY
*/
fun saveImage(
url: String,
context: Context,
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
val client = OkHttpClient.Builder().build()
val request = Request.Builder()
.get()
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
onError(e)
}
override fun onResponse(call: Call, response: Response) {
try {
check(response.isSuccessful)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentType = response.header("Content-Type")
checkNotNull(contentType) {
"Can't find out the content type"
}
saveContentQ(
displayName = File(url).nameWithoutExtension,
contentType = contentType,
contentSource = response.body.source(),
contentResolver = context.contentResolver,
)
} else {
saveContentDefault(
fileName = File(url).name,
contentSource = response.body.source(),
context = context,
)
}
onSuccess()
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
})
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveContentQ(
displayName: String,
contentType: String,
contentSource: BufferedSource,
contentResolver: ContentResolver,
) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, contentType)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY
)
}
val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
}
try {
val outputStream = contentResolver.openOutputStream(uri)
checkNotNull(outputStream) {
"Can't open the content output stream"
}
outputStream.use {
contentSource.readAll(it.sink())
}
} catch (e: Exception) {
contentResolver.delete(uri, null, null)
throw e
}
}
private fun saveContentDefault(
fileName: String,
contentSource: BufferedSource,
context: Context,
) {
val subdirectory = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
PICTURES_SUBDIRECTORY
)
if (!subdirectory.exists()) {
subdirectory.mkdirs()
}
val outputFile = File(subdirectory, fileName)
outputFile
.outputStream()
.use {
contentSource.readAll(it.sink())
}
// Call the media scanner manually, so the image
// appears in the gallery faster.
context.sendBroadcast(
Intent(
Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
outputFile.toUri()
)
)
}
private const val PICTURES_SUBDIRECTORY = "Amethyst"
}
@@ -1,25 +1,19 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Checkbox
import androidx.compose.material.Colors
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
@@ -28,18 +22,12 @@ import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -48,12 +36,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -62,6 +48,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import java.lang.Math.round
@@ -206,16 +193,16 @@ fun ServerConfigHeader() {
@Composable
fun ServerConfig(
item: NewRelayListViewModel.Relay,
onToggleDownload: (NewRelayListViewModel.Relay) -> Unit,
onToggleUpload: (NewRelayListViewModel.Relay) -> Unit,
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (NewRelayListViewModel.Relay) -> Unit,
onTogglePrivateDMs: (NewRelayListViewModel.Relay) -> Unit,
onTogglePublicChats: (NewRelayListViewModel.Relay) -> Unit,
onToggleGlobal: (NewRelayListViewModel.Relay) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onDelete: (NewRelayListViewModel.Relay) -> Unit) {
onDelete: (RelaySetupInfo) -> Unit) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -376,7 +363,7 @@ fun ServerConfig(
}
@Composable
fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Unit) {
var url by remember { mutableStateOf<String>(relayToAdd) }
var read by remember { mutableStateOf(true) }
var write by remember { mutableStateOf(true) }
@@ -422,9 +409,9 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
Button(
onClick = {
if (url.isNotBlank() && url != "/") {
var addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url
var addedWSS = if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
onNewRelay(NewRelayListViewModel.Relay(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
url = ""
write = true
read = true
@@ -1,32 +1,20 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import nostr.postr.events.ContactListEvent
class NewRelayListViewModel: ViewModel() {
private lateinit var account: Account
data class Relay(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val feedTypes: Set<FeedType>
)
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
fun load(account: Account, ctx: Context) {
@@ -56,7 +44,7 @@ class NewRelayListViewModel: ViewModel() {
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed()
else
account.localRelays.map {
@@ -66,12 +54,12 @@ class NewRelayListViewModel: ViewModel() {
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed()
}
}
fun addRelay(relay: Relay) {
fun addRelay(relay: RelaySetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update {
@@ -79,46 +67,46 @@ class NewRelayListViewModel: ViewModel() {
}
}
fun deleteRelay(relay: Relay) {
fun deleteRelay(relay: RelaySetupInfo) {
_relays.update {
it.minus(relay)
}
}
fun toggleDownload(relay: Relay) {
fun toggleDownload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(read = !relay.read))
}
}
fun toggleUpload(relay: Relay) {
fun toggleUpload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(write = !relay.write))
}
}
fun toggleFollows(relay: Relay) {
fun toggleFollows(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleMessages(relay: Relay) {
fun toggleMessages(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun togglePublicChats(relay: Relay) {
fun togglePublicChats(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleGlobal(relay: Relay) {
fun toggleGlobal(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
_relays.update {
it.updated(relay, relay.copy( feedTypes = newTypes ))
@@ -205,6 +205,22 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "LN URL (outdated)") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnURL.value,
onValueChange = { postViewModel.lnURL.value = it },
placeholder = {
Text(
text = "LNURL...",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
}
}
@@ -21,6 +21,7 @@ class NewUserMetadataViewModel: ViewModel() {
val website = mutableStateOf("")
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
val lnURL = mutableStateOf("")
fun load(account: Account) {
this.account = account
@@ -34,6 +35,7 @@ class NewUserMetadataViewModel: ViewModel() {
website.value = it.info.website ?: ""
nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
lnURL.value = it.info.lud06 ?: ""
}
}
@@ -57,6 +59,7 @@ class NewUserMetadataViewModel: ViewModel() {
currentJson.put("about", about.value)
currentJson.put("nip05", nip05.value)
currentJson.put("lud16", lnAddress.value)
currentJson.put("lud06", lnURL.value)
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
@@ -75,5 +78,6 @@ class NewUserMetadataViewModel: ViewModel() {
website.value = ""
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
}
}
@@ -0,0 +1,83 @@
package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.launch
/**
* A button to save the remote image to the gallery.
* May require a storage permission.
*
* @param url URL of the image
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SaveToGallery(url: String) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
fun saveImage() {
ImageSaver.saveImage(
context = localContext,
url = url,
onSuccess = {
scope.launch {
Toast.makeText(
localContext,
"Image saved to the gallery",
Toast.LENGTH_SHORT
)
.show()
}
},
onError = {
scope.launch {
Toast.makeText(
localContext,
"Failed to save the image",
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
val writeStoragePermissionState = rememberPermissionState(
Manifest.permission.WRITE_EXTERNAL_STORAGE
) { isGranted ->
if (isGranted) {
saveImage()
}
}
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
saveImage()
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
)
) {
Text(text = "Save", color = Color.White)
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
@@ -24,9 +25,14 @@ import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
val cameraPermissionState = rememberPermissionState(
android.Manifest.permission.READ_MEDIA_IMAGES
)
val cameraPermissionState =
rememberPermissionState(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_IMAGES
} else {
android.Manifest.permission.READ_EXTERNAL_STORAGE
}
)
if (cameraPermissionState.status.isGranted) {
var showGallerySelect by remember { mutableStateOf(false) }
@@ -0,0 +1,70 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@Composable
fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
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(
colors = listOf(
MaterialTheme.colors.background.copy(alpha = 0f),
MaterialTheme.colors.background
)
)
)
) {
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)
) {
Text(text = "Show More", color = Color.White)
}
}
}
}
}
@@ -9,13 +9,20 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -23,11 +30,15 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import java.text.NumberFormat
@Composable
fun InvoicePreview(lnInvoice: String) {
@@ -78,20 +89,21 @@ fun InvoicePreview(lnInvoice: String) {
amount?.let {
Text(
text = "${amount.toInt()} sats",
text = "${NumberFormat.getInstance().format(amount)} sats",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
)
}
}
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning://$lnInvoice"))
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice"))
startActivity(context, intent, null)
}
},
@@ -0,0 +1,144 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
@Composable
fun InvoiceRequest(lud16: String, onClose: () -> Unit ) {
val context = LocalContext.current
Column(modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Icon(
painter = painterResource(R.drawable.lightning),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
Text(
text = "Lightning Tips",
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
}
Divider()
var message by remember { mutableStateOf("") }
var amount by remember { mutableStateOf(1000L) }
OutlinedTextField(
label = { Text(text = "Note to Receiver") },
modifier = Modifier.fillMaxWidth(),
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = "Thank you so much!",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true
)
OutlinedTextField(
label = { Text(text = "Amount in Sats") },
modifier = Modifier.fillMaxWidth(),
value = amount.toString(),
onValueChange = {
runCatching {
if (it.isEmpty())
amount = 0
else
amount = it.toLong()
}
},
placeholder = {
Text(
text = "1000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number
),
singleLine = true
)
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
LightningAddressResolver().lnAddressInvoice(lud16, amount * 1000, message) {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
startActivity(context, intent, null)
}
onClose()
}
},
shape = RoundedCornerShape(15.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Send Sats", color = Color.White, fontSize = 20.sp)
}
}
}
}
@@ -1,59 +1,25 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources
import android.util.LruCache
import android.util.Patterns
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
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 com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import nostr.postr.toNpub
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.Locale
import java.util.regex.Pattern
import nostr.postr.toNpub
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm)$")
@@ -76,150 +42,79 @@ fun isValidURL(url: String?): Boolean {
}
@Composable
fun RichTextViewer(content: String, canPreview: Boolean, tags: List<List<String>>?, navController: NavController) {
val translatedTextState = remember {
mutableStateOf(ResultOrError(content, null, null, null))
}
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 ->
var showOriginal by remember { mutableStateOf(false) }
var showFullText by remember { mutableStateOf(false) }
FlowRow() {
paragraph.split(' ').forEach { word: String ->
LaunchedEffect(Unit) {
LanguageTranslatorService.autoTranslate(content).addOnCompleteListener { task ->
if (task.isSuccessful) {
translatedTextState.value = task.result
}
}
}
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)) {
Box(contentAlignment = Alignment.BottomCenter) {
Column(Modifier.fillMaxWidth().animateContentSize()) {
// FlowRow doesn't work well with paragraphs. So we need to split them
text.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),
)
}
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 {
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),
)
}
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),
)
}
}
}
}
if (toBeViewed.length > 350 && !showFullText) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colors.background.copy(alpha = 0f),
MaterialTheme.colors.background
)
)
)
) {
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)
) {
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) {
FlowRow() {
Text(
text = "Auto-translated from ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
ClickableText(
text = AnnotatedString(Locale(source).displayName),
onClick = { showOriginal = true },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
)
Text(
text = " to ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
ClickableText(
text = AnnotatedString(Locale(target).displayName),
onClick = { showOriginal = false },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
)
}
}
}
}
}
fun isBechLink(word: String): Boolean {
return word.startsWith("nostr:", true)
|| word.startsWith("npub1", true)
@@ -293,89 +188,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)
}
}
@@ -0,0 +1,151 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.util.Locale
@Composable
fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
tags: List<List<String>>?,
accountViewModel: AccountViewModel,
navController: NavController
) {
val translatedTextState = remember {
mutableStateOf(ResultOrError(content, null, null, null))
}
var showOriginal by remember { mutableStateOf(false) }
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current
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) {
translatedTextState.value = task.result
} else {
translatedTextState.value = ResultOrError(content, null, null, null)
}
}
}
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
Column(modifier = Modifier.padding(top = 5.dp)) {
ExpandableRichTextViewer(
toBeViewed,
canPreview,
tags,
navController
)
val target = translatedTextState.value.targetLang
val source = translatedTextState.value.sourceLang
if (source != null && target != null) {
if (source != target) {
Row(modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
val clickableTextStyle =
SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
val annotatedTranslationString = buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append("Auto")
}
append("-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 ->
if (span.tag == "showOriginal")
showOriginal = span.item.toBoolean()
else
langSettingsPopupExpanded = !langSettingsPopupExpanded
}
}
DropdownMenu(
expanded = langSettingsPopupExpanded,
onDismissRequest = { langSettingsPopupExpanded = false }
) {
DropdownMenuItem(onClick = {
accountViewModel.dontTranslateFrom(source, context)
langSettingsPopupExpanded = false
}) {
Text("Never translate from ${Locale(source).displayName}")
}
Divider()
val languageList =
ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { lang ->
DropdownMenuItem(onClick = {
accountViewModel.translateTo(lang, context)
langSettingsPopupExpanded = false
}) {
Text("Always translate to ${lang.displayName}")
}
}
}
}
}
}
}
}
}
@@ -2,35 +2,26 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import nostr.postr.toNpub
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
@Composable
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
@@ -75,6 +66,8 @@ fun ZoomableImageView(word: String) {
CloseButton(onCancel = {
dialogOpen = false
})
SaveToGallery(url = word)
}
ZoomableAsyncImage(word)
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.util.Log
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -36,6 +38,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
@@ -72,6 +75,7 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac
}
}
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -104,12 +108,13 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box() {
Box(Modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxWidth().fillMaxHeight()
.padding(start = 0.dp, end = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
IconButton(
onClick = {
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
@@ -221,17 +222,19 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
|| !noteForReports.hasAnyReports()
if (eventContent != null) {
RichTextViewer(
TranslateableRichTextViewer(
eventContent,
canPreview,
note.event?.tags,
accountViewModel,
navController
)
} else {
RichTextViewer(
TranslateableRichTextViewer(
"Could Not decrypt the message",
true,
note.event?.tags,
accountViewModel,
navController
)
}
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
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.theme.Following
import nostr.postr.events.TextNoteEvent
@@ -271,7 +272,13 @@ fun NoteCompose(
|| !noteForReports.hasAnyReports()
if (eventContent != null) {
RichTextViewer(eventContent, canPreview, note.event?.tags, navController)
TranslateableRichTextViewer(
eventContent,
canPreview,
note.event?.tags,
accountViewModel,
navController
)
}
ReactionsRow(note, accountViewModel)
@@ -62,46 +62,55 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
}
Column(
modifier = Modifier.fillMaxSize().padding(10.dp),
verticalArrangement = Arrangement.Center
modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
if (presenting) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = user.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
modifier = Modifier
.width(120.dp)
.height(120.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(" @${user.bestUsername()}", color = Color.LightGray)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
) {
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = user.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(" @${user.bestUsername()}", color = Color.LightGray)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 35.dp, vertical = 10.dp)
) {
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
}
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
) {
Button(
@@ -121,6 +130,15 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
} else {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
"Point to the QR Code",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
@@ -128,18 +146,24 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
QrCodeScanner(onScan)
}
Button(
onClick = { presenting = true },
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth().padding(30.dp)
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
) {
Text(text = "Show QR")
Button(
onClick = { presenting = true },
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Show QR")
}
}
}
@@ -42,6 +42,7 @@ import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.Note
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.HiddenNote
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
@@ -226,7 +227,13 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
|| !noteForReports.hasAnyReports()
if (eventContent != null) {
RichTextViewer(eventContent, canPreview, note.event?.tags, navController)
TranslateableRichTextViewer(
eventContent,
canPreview,
note.event?.tags,
accountViewModel,
navController
)
}
ReactionsRow(note, accountViewModel)
@@ -10,9 +10,11 @@ import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
import java.util.Locale
class AccountViewModel(private val account: Account): ViewModel() {
val accountLiveData: LiveData<AccountState> = account.live.map { it }
val accountLanguagesLiveData: LiveData<AccountState> = account.liveLanguages.map { it }
fun reactTo(note: Note) {
account.reactTo(note)
@@ -47,4 +49,14 @@ class AccountViewModel(private val account: Account): ViewModel() {
account.showUser(user.pubkeyHex)
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)
}
}
@@ -190,12 +190,12 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel:
channel?.let {
if (channel.creator == account.userProfile()) {
EditButton(account, it)
}
if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account, channel, navController)
} else {
if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account, channel, navController)
} else {
JoinButton(account, channel, navController)
}
JoinButton(account, channel, navController)
}
}
}
@@ -1,14 +1,20 @@
package com.vitorpamplona.amethyst.ui.screen
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.*
@@ -25,6 +31,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -32,6 +39,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
@@ -41,6 +49,7 @@ import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
@@ -48,8 +57,10 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.launch
import nostr.postr.toNpub
import nostr.postr.toNsec
@@ -228,7 +239,7 @@ private fun ProfileHeader(
contentPadding = PaddingValues(0.dp)
) {
Icon(
tint = Color.White,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
imageVector = Icons.Default.MoreVert,
contentDescription = "More Options",
)
@@ -301,6 +312,8 @@ private fun DrawAdditionalInfo(baseUser: User) {
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
val uri = LocalUriHandler.current
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
@@ -308,9 +321,60 @@ private fun DrawAdditionalInfo(baseUser: User) {
fontSize = 25.sp
)
Text(
" @${user.bestUsername()}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
"@${user.bestUsername()}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
val website = user.info.website
if (!website.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
imageVector = Icons.Default.Link,
contentDescription = "Website",
modifier = Modifier.size(16.dp)
)
ClickableText(
text = AnnotatedString(website.removePrefix("https://")),
onClick = { user.info.website?.let { runCatching { uri.openUri(it) } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
}
}
var ZapExpanded by remember { mutableStateOf(false) }
val lud16 = user.info.lud16
if (!lud16.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = BitcoinOrange,
imageVector = Icons.Default.Bolt,
contentDescription = "Lightning Address",
modifier = Modifier.size(16.dp)
)
ClickableText(
text = AnnotatedString(lud16),
onClick = { ZapExpanded = !ZapExpanded },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f)
)
}
if (ZapExpanded) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest(lud16) {
ZapExpanded = false
}
}
}
}
Text(
"${user.info.about}",
color = MaterialTheme.colors.onSurface,
@@ -1,37 +1,18 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Checkbox
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -40,16 +21,13 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.sendMail
@Composable
fun LoginPage(accountViewModel: AccountStateViewModel) {
@@ -59,12 +37,144 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
var termsAcceptanceIsRequired by remember { mutableStateOf("") }
val uri = LocalUriHandler.current
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween
) {
// The first child is glued to the top.
// Hence we have nothing at the top, an empty box is used.
Box(modifier = Modifier.height(0.dp))
// The second child, this column, is centered vertically.
Column(
modifier = Modifier
.padding(20.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painterResource(id = R.drawable.amethyst),
contentDescription = "App Logo",
modifier = Modifier.size(200.dp),
contentScale = ContentScale.Inside
)
Spacer(modifier = Modifier.height(40.dp))
var showPassword by remember {
mutableStateOf(false)
}
OutlinedTextField(
value = key.value,
onValueChange = { key.value = it },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Go
),
placeholder = {
Text(
text = "nsec / npub / hex private key",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) "Show Password" else "Hide Password"
)
}
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardActions = KeyboardActions(
onGo = {
try {
accountViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = "Invalid key"
}
}
)
)
if (errorMessage.isNotBlank()) {
Text(
text = errorMessage,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption
)
}
Spacer(modifier = Modifier.height(20.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = acceptedTerms.value,
onCheckedChange = { acceptedTerms.value = it }
)
Text(text = "I accept the ")
ClickableText(
text = AnnotatedString("terms of use"),
onClick = { runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}
if (termsAcceptanceIsRequired.isNotBlank()) {
Text(
text = termsAcceptanceIsRequired,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption
)
}
Spacer(modifier = Modifier.height(20.dp))
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
Button(
onClick = {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = "Acceptance of terms is required"
}
if (key.value.text.isBlank()) {
errorMessage = "Key is required"
}
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = "Invalid key"
}
}
},
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (acceptedTerms.value) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Login")
}
}
}
// The last child is glued to the bottom.
ClickableText(
text = AnnotatedString("Generate a new key"),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(20.dp),
.padding(20.dp)
.fillMaxWidth(),
onClick = {
if (acceptedTerms.value) {
accountViewModel.newKey()
@@ -75,127 +185,9 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
style = TextStyle(
fontSize = 14.sp,
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
color = MaterialTheme.colors.primary,
textAlign = TextAlign.Center
)
)
}
Column(
modifier = Modifier.padding(20.dp).fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painterResource(id = R.drawable.amethyst),
contentDescription = "App Logo",
modifier = Modifier.size(200.dp),
contentScale = ContentScale.Inside
)
Spacer(modifier = Modifier.height(40.dp))
var showPassword by remember {
mutableStateOf(false)
}
OutlinedTextField(
value = key.value,
onValueChange = { key.value = it },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Go
),
placeholder = {
Text(
text = "nsec / npub / hex private key",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) "Show Password" else "Hide Password"
)
}
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardActions = KeyboardActions(
onGo = {
try {
accountViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = "Invalid key"
}
}
)
)
if (errorMessage.isNotBlank()) {
Text(
text = errorMessage,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption
)
}
Spacer(modifier = Modifier.height(20.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = acceptedTerms.value,
onCheckedChange = { acceptedTerms.value = it }
)
Text(text = "I accept the ")
ClickableText(
text = AnnotatedString("terms of use"),
onClick = { runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}
if (termsAcceptanceIsRequired.isNotBlank()) {
Text(
text = termsAcceptanceIsRequired,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption
)
}
Spacer(modifier = Modifier.height(20.dp))
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
Button(
onClick = {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = "Acceptance of terms is required"
}
if (key.value.text.isBlank()) {
errorMessage = "Key is required"
}
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = "Invalid key"
}
}
},
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (acceptedTerms.value) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Login")
}
}
}
}
@@ -6,6 +6,7 @@ val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
val BitcoinOrange = Color (0xFFF7931A)
val Following = Color(0xFF03DAC5)
val FollowsFollow = Color.Yellow