Merge branch 'main' into main

This commit is contained in:
greenart7c3
2023-05-08 10:48:48 -03:00
committed by GitHub
76 changed files with 2018 additions and 1239 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 33 targetSdk 33
versionCode 144 versionCode 150
versionName "0.42.2" versionName "0.43.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -4,12 +4,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.ImageUploader import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.amethyst.ui.actions.ImgurServer import com.vitorpamplona.amethyst.ui.actions.ImgurServer
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail import junit.framework.TestCase.fail
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Ignore
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.util.Base64 import java.util.Base64
@@ -21,12 +21,12 @@ class ImageUploadTesting {
@Test() @Test()
fun testImgurUpload() = runBlocking { fun testImgurUpload() = runBlocking {
val inputStream = Base64.getDecoder().decode(image).inputStream() val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
println("Uploading")
ImageUploader.uploadImage( ImageUploader.uploadImage(
inputStream, inputStream,
bytes.size.toLong(),
"image/gif", "image/gif",
ImgurServer(), ImgurServer(),
onSuccess = { url, contentType -> onSuccess = { url, contentType ->
@@ -39,18 +39,17 @@ class ImageUploadTesting {
} }
) )
delay(1000) delay(5000)
} }
@Test() @Test()
@Ignore
fun testNostrBuildUpload() = runBlocking { fun testNostrBuildUpload() = runBlocking {
val inputStream = Base64.getDecoder().decode(image).inputStream() val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
println("Uploading")
ImageUploader.uploadImage( ImageUploader.uploadImage(
inputStream, inputStream,
bytes.size.toLong(),
"image/gif", "image/gif",
NostrBuildServer(), NostrBuildServer(),
onSuccess = { url, contentType -> onSuccess = { url, contentType ->
@@ -68,12 +67,12 @@ class ImageUploadTesting {
@Test() @Test()
fun testNostrImgUpload() = runBlocking { fun testNostrImgUpload() = runBlocking {
val inputStream = Base64.getDecoder().decode(image).inputStream() val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
println("Uploading")
ImageUploader.uploadImage( ImageUploader.uploadImage(
inputStream, inputStream,
bytes.size.toLong(),
"image/gif", "image/gif",
NostrImgServer(), NostrImgServer(),
onSuccess = { url, contentType -> onSuccess = { url, contentType ->
@@ -88,4 +87,27 @@ class ImageUploadTesting {
delay(1000) delay(1000)
} }
@Test()
fun testNostrFilesDevUpload() = runBlocking {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrFilesDevServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(5000)
}
} }
@@ -78,7 +78,7 @@ class PrivateZapTests {
if (recepientPK != null && recepientPost != null) { if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt) val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap = val decodedPrivateZap =
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK) privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
println(decodedPrivateZap?.toJson()) println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap) assertNotNull(decodedPrivateZap)
@@ -127,8 +127,7 @@ class PrivateZapTests {
if (recepientPK != null && recepientPost != null) { if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt) val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap = val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
println(decodedPrivateZap?.toJson()) println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap) assertNotNull(decodedPrivateZap)
@@ -9,7 +9,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event
@@ -284,7 +284,7 @@ object LocalPreferences {
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
val a = Account( val a = Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels, followingChannels,
hiddenUsers, hiddenUsers,
localRelays, localRelays,
@@ -181,44 +181,49 @@ class Account(
} }
fun isNIP47Author(pubkeyHex: String?): Boolean { fun isNIP47Author(pubkeyHex: String?): Boolean {
val privKey = zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!! val privKey = zapPaymentRequest?.secret?.hexToByteArray() ?: loggedIn.privKey
if (privKey == null) return false
val pubKey = Utils.pubkeyCreate(privKey).toHexKey() val pubKey = Utils.pubkeyCreate(privKey).toHexKey()
return (pubKey == pubkeyHex) return (pubKey == pubkeyHex)
} }
fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? { fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? {
val myNip47 = zapPaymentRequest ?: return null val myNip47 = zapPaymentRequest ?: return null
return zapResponseEvent.response(
myNip47.secret?.toByteArray() ?: loggedIn.privKey!!, val privKey = myNip47.secret?.hexToByteArray() ?: loggedIn.privKey
myNip47.pubKeyHex.toByteArray() val pubKey = myNip47.pubKeyHex.hexToByteArray()
)
if (privKey == null) return null
return zapResponseEvent.response(privKey, pubKey)
} }
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean { fun calculateIfNoteWasZappedByAccount(zappedNote: Note?): Boolean {
return zappedNote.isZappedBy(userProfile(), this) == true return zappedNote?.isZappedBy(userProfile(), this) == true
} }
fun calculateZappedAmount(zappedNote: Note?): BigDecimal { fun calculateZappedAmount(zappedNote: Note?): BigDecimal {
return zappedNote?.zappedAmount( val privKey = zapPaymentRequest?.secret?.hexToByteArray() ?: loggedIn.privKey
zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!, val pubKey = zapPaymentRequest?.pubKeyHex?.hexToByteArray()
zapPaymentRequest?.pubKeyHex?.toByteArray() return zappedNote?.zappedAmount(privKey, pubKey) ?: BigDecimal.ZERO
) ?: BigDecimal.ZERO
} }
fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) { fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) {
if (!isWriteable()) return if (!isWriteable()) return
zapPaymentRequest?.let { nip47 -> zapPaymentRequest?.let { nip47 ->
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.toByteArray() ?: loggedIn.privKey!!) val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!)
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id) val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
wcListener.start() wcListener.start()
LocalCache.consume(event, zappedNote) { LocalCache.consume(event, zappedNote) {
// After the response is received. // After the response is received.
val privKey = nip47.secret?.toByteArray() val privKey = nip47.secret?.hexToByteArray()
if (privKey != null) { if (privKey != null) {
onResponse(it.response(privKey, nip47.pubKeyHex.toByteArray())) onResponse(it.response(privKey, nip47.pubKeyHex.hexToByteArray()))
} }
} }
@@ -818,13 +823,13 @@ class Account(
return if (event is PrivateDmEvent && loggedIn.privKey != null) { return if (event is PrivateDmEvent && loggedIn.privKey != null) {
var pubkeyToUse = event.pubKey var pubkeyToUse = event.pubKey
val recepientPK = event.recipientPubKey() val recepientPK = event.verifiedRecipientPubKey()
if (note.author == userProfile() && recepientPK != null) { if (note.author == userProfile() && recepientPK != null) {
pubkeyToUse = recepientPK pubkeyToUse = recepientPK
} }
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) event.plainContent(loggedIn.privKey!!, pubkeyToUse.hexToByteArray())
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) { } else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
decryptZapContentAuthor(note)?.content() decryptZapContentAuthor(note)?.content()
} else { } else {
@@ -845,7 +850,7 @@ class Account(
val privateKeyToUse = loggedInPrivateKey val privateKeyToUse = loggedInPrivateKey
val pubkeyToUse = event.pubKey val pubkeyToUse = event.pubKey
LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse) event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse)
} else { } else {
// if the sender is logged in, these are the params // if the sender is logged in, these are the params
val altPubkeyToUse = recipientPK val altPubkeyToUse = recipientPK
@@ -866,14 +871,24 @@ class Account(
} }
if (altPrivateKeyToUse != null && altPubkeyToUse != null) { if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
val result = LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse) val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
if (altPubKeyFromPrivate == event.pubKey) {
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
if (result == null) { if (result == null) {
Log.w("Private ZAP Decrypt", "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}") Log.w(
"Private ZAP Decrypt",
"Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}"
)
} }
result result
} else { } else {
null null
} }
} else {
null
}
} }
} else { } else {
null null
@@ -29,7 +29,7 @@ fun ByteArray.toHexKey(): HexKey {
return toHex() return toHex()
} }
fun HexKey.toByteArray(): ByteArray { fun HexKey.hexToByteArray(): ByteArray {
return Hex.decode(this) return Hex.decode(this)
} }
@@ -39,7 +39,7 @@ fun HexKey.toDisplayHexKey(): String {
fun decodePublicKey(key: String): ByteArray { fun decodePublicKey(key: String): ByteArray {
val parsed = Nip19.uriToRoute(key) val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.toByteArray() val pubKeyParsed = parsed?.hex?.hexToByteArray()
return if (key.startsWith("nsec")) { return if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey Persona(privKey = key.bechToBytes()).pubKey
@@ -359,7 +359,7 @@ object LocalCache {
// Already processed this event. // Already processed this event.
if (note.event != null) return if (note.event != null) return
val recipient = event.recipientPubKey()?.let { getOrCreateUser(it) } val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
@@ -407,7 +407,7 @@ object LocalCache {
if (deleteNote.event is PrivateDmEvent) { if (deleteNote.event is PrivateDmEvent) {
val author = deleteNote.author val author = deleteNote.author
val recipient = (deleteNote.event as? PrivateDmEvent)?.recipientPubKey()?.let { checkGetOrCreateUser(it) } val recipient = (deleteNote.event as? PrivateDmEvent)?.verifiedRecipientPubKey()?.let { checkGetOrCreateUser(it) }
if (recipient != null && author != null) { if (recipient != null && author != null) {
author.removeMessage(recipient, deleteNote) author.removeMessage(recipient, deleteNote)
@@ -6,7 +6,7 @@ import kotlin.time.measureTimedValue
class ThreadAssembler { class ThreadAssembler {
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? { private fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
testedNotes.add(note) testedNotes.add(note)
@@ -181,7 +181,7 @@ class User(val pubkeyHex: String) {
} }
@Synchronized @Synchronized
fun getOrCreatePrivateChatroom(user: User): Chatroom { private fun getOrCreatePrivateChatroom(user: User): Chatroom {
return privateChatrooms[user] ?: run { return privateChatrooms[user] ?: run {
val privateChatroom = Chatroom(setOf<Note>()) val privateChatroom = Chatroom(setOf<Note>())
privateChatrooms = privateChatrooms + Pair(user, privateChatroom) privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
@@ -59,7 +59,6 @@ object BlurHashRequester {
.Builder(context) .Builder(context)
.data("bluehash:$encodedMessage") .data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory) .fetcherFactory(BlurHashFetcher.Factory)
.crossfade(100)
.build() .build()
} }
} }
@@ -4,8 +4,8 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import io.trbl.blurhash.BlurHash import io.trbl.blurhash.BlurHash
import java.net.URL
import java.security.MessageDigest import java.security.MessageDigest
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -19,11 +19,15 @@ class FileHeader(
val description: String? = null val description: String? = null
) { ) {
companion object { companion object {
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try { try {
val imageData = URL(fileUrl).readBytes() val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, description, onReady, onError) prepare(imageData, fileUrl, mimeType, description, onReady, onError)
} else {
onError()
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError() onError()
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
@@ -10,13 +9,8 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
var user: User? = null var user: User? = null
fun loadUserProfile(userId: String?) { fun loadUserProfile(user: User?) {
if (userId != null) { this.user = user
user = LocalCache.getOrCreateUser(userId)
} else {
user = null
}
resetFilters() resetFilters()
} }
@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service.model package com.vitorpamplona.amethyst.service.model
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.nip19.Tlv import com.vitorpamplona.amethyst.service.nip19.Tlv
import fr.acinq.secp256k1.Hex import fr.acinq.secp256k1.Hex
@@ -14,7 +14,7 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val rela
fun toNAddr(): String { fun toNAddr(): String {
val kind = kind.toByteArray() val kind = kind.toByteArray()
val author = pubKeyHex.toByteArray() val author = pubKeyHex.hexToByteArray()
val dTag = dTag.toByteArray(Charsets.UTF_8) val dTag = dTag.toByteArray(Charsets.UTF_8)
val relay = relay?.toByteArray(Charsets.UTF_8) val relay = relay?.toByteArray(Charsets.UTF_8)
@@ -10,13 +10,9 @@ class BadgeAwardEvent(
content: String, content: String,
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } fun awardees() = taggedUsers()
fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null fun awardDefinition() = taggedAddresses()
}
companion object { companion object {
const val kind = 8 const val kind = 8
@@ -10,13 +10,13 @@ class BadgeDefinitionEvent(
content: String, content: String,
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
fun address() = ATag(kind, pubKey, dTag(), null) fun address() = ATag(kind, pubKey, dTag(), null)
fun name() = tags.filter { it.firstOrNull() == "name" }.mapNotNull { it.getOrNull(1) }.firstOrNull() fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
fun thumb() = tags.filter { it.firstOrNull() == "thumb" }.mapNotNull { it.getOrNull(1) }.firstOrNull() fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)
fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull() fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull() fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
companion object { companion object {
const val kind = 30009 const val kind = 30009
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.tagSearch import com.vitorpamplona.amethyst.model.tagSearch
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
@@ -41,17 +42,18 @@ open class BaseTextNoteEvent(
val key = matcher2.group(3) // bech32 val key = matcher2.group(3) // bech32
val additionalChars = matcher2.group(4) // additional chars val additionalChars = matcher2.group(4) // additional chars
try {
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
if (parsed != null) { if (parsed != null) {
try {
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
if (tag != null && tag[0] == "p") { if (tag != null && tag[0] == "p") {
returningList.add(tag[1]) returningList.add(tag[1])
} }
} catch (e: Exception) {
} }
} catch (e: Exception) {
Log.w("Unable to parse cited users that matched a NIP19 regex", e)
} }
} }
@@ -38,6 +38,8 @@ open class Event(
override fun toJson(): String = gson.toJson(this) override fun toJson(): String = gson.toJson(this)
fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
@@ -83,7 +85,7 @@ open class Event(
override fun getReward(): BigDecimal? { override fun getReward(): BigDecimal? {
return try { return try {
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { it.getOrNull(1)?.let { BigDecimal(it) } }.firstOrNull() tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) }
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log import android.util.Log
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.hexToByteArray
import nostr.postr.Utils import nostr.postr.Utils
abstract class GeneralListEvent( abstract class GeneralListEvent(
@@ -24,7 +24,7 @@ abstract class GeneralListEvent(
fun plainContent(privKey: ByteArray): String? { fun plainContent(privKey: ByteArray): String? {
return try { return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
return Utils.decrypt(content, sharedSecret) return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) { } catch (e: Exception) {
@@ -6,7 +6,7 @@ import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement import com.google.gson.JsonElement
import com.google.gson.JsonParseException import com.google.gson.JsonParseException
import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils import nostr.postr.Utils
import java.lang.reflect.Type import java.lang.reflect.Type
@@ -21,9 +21,17 @@ class LnZapPaymentRequestEvent(
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
// Once one of an app user decrypts the payment, all users else can see it.
@Transient
private var lnInvoice: String? = null
fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? { fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? {
if (lnInvoice != null) {
return lnInvoice
}
return try { return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubkey) val sharedSecret = Utils.getSharedSecret(privKey, pubkey)
@@ -31,7 +39,9 @@ class LnZapPaymentRequestEvent(
val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java) val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java)
return (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
return lnInvoice
} catch (e: Exception) { } catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}") Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null null
@@ -53,7 +63,7 @@ class LnZapPaymentRequestEvent(
val content = Utils.encrypt( val content = Utils.encrypt(
serializedRequest, serializedRequest,
privateKey, privateKey,
walletServicePubkey.toByteArray() walletServicePubkey.hexToByteArray()
) )
val tags = mutableListOf<List<String>>() val tags = mutableListOf<List<String>>()
@@ -19,10 +19,14 @@ class LnZapPaymentResponseEvent(
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
// Once one of an app user decrypts the payment, all users else can see it.
@Transient
private var response: Response? = null
fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1) fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1)
fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? { private fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
return try { return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey) val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
@@ -39,10 +43,14 @@ class LnZapPaymentResponseEvent(
} }
} }
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try { fun response(privKey: ByteArray, pubKey: ByteArray): Response? {
if (response != null) response
return try {
if (content.isNotEmpty()) { if (content.isNotEmpty()) {
val decrypted = decrypt(privKey, pubKey) val decrypted = decrypt(privKey, pubKey)
gson.fromJson(decrypted, Response::class.java) response = gson.fromJson(decrypted, Response::class.java)
response
} else { } else {
null null
} }
@@ -50,6 +58,7 @@ class LnZapPaymentResponseEvent(
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e) Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
null null
} }
}
companion object { companion object {
const val kind = 23195 const val kind = 23195
@@ -20,12 +20,37 @@ class LnZapRequestEvent(
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient
private var privateZapEvent: Event? = null
fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() } fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
fun getPrivateZapEvent(loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
if (privateZapEvent != null) return privateZapEvent
val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
if (anonTag != null) {
val encnote = anonTag[1]
if (encnote.isNotBlank()) {
try {
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.hexToByteArray())
val decryptedEvent = fromJson(note)
if (decryptedEvent.kind == 9733) {
privateZapEvent = decryptedEvent
return privateZapEvent
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
return null
}
companion object { companion object {
const val kind = 9734 const val kind = 9734
@@ -59,7 +84,7 @@ class LnZapRequestEvent(
} else if (zapType == LnZapEvent.ZapType.PRIVATE) { } else if (zapType == LnZapEvent.ZapType.PRIVATE) {
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt) var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt)
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson() var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().toByteArray()) var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().hexToByteArray())
tags = tags + listOf(listOf("anon", encryptedContent)) tags = tags + listOf(listOf("anon", encryptedContent))
content = "" // make sure public content is empty, as the content is encrypted content = "" // make sure public content is empty, as the content is encrypted
privkey = encryptionPrivateKey // sign event with generated privkey privkey = encryptionPrivateKey // sign event with generated privkey
@@ -92,7 +117,7 @@ class LnZapRequestEvent(
} else if (zapType == LnZapEvent.ZapType.PRIVATE) { } else if (zapType == LnZapEvent.ZapType.PRIVATE) {
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt) var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt)
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson() var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.toByteArray()) var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.hexToByteArray())
tags = tags + listOf(listOf("anon", encryptedContent)) tags = tags + listOf(listOf("anon", encryptedContent))
content = "" content = ""
privkey = encryptionPrivateKey privkey = encryptionPrivateKey
@@ -109,7 +134,7 @@ class LnZapRequestEvent(
return sha256.digest(strbyte) return sha256.digest(strbyte)
} }
fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String { private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = Utils.getSharedSecret(privkey, pubkey) var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
val iv = ByteArray(16) val iv = ByteArray(16)
SecureRandom().nextBytes(iv) SecureRandom().nextBytes(iv)
@@ -128,7 +153,7 @@ class LnZapRequestEvent(
return encryptedMsgBech32 + "_" + ivBech32 return encryptedMsgBech32 + "_" + ivBech32
} }
fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String { private fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = Utils.getSharedSecret(privkey, pubkey) var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
if (sharedSecret.size != 16 && sharedSecret.size != 32) { if (sharedSecret.size != 16 && sharedSecret.size != 32) {
throw IllegalArgumentException("Invalid shared secret size") throw IllegalArgumentException("Invalid shared secret size")
@@ -150,61 +175,5 @@ class LnZapRequestEvent(
throw IllegalArgumentException("Bad padding: ${ex.message}") throw IllegalArgumentException("Bad padding: ${ex.message}")
} }
} }
fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
if (anonTag != null) {
val encnote = anonTag[1]
if (encnote.isNotBlank()) {
try {
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray())
val decryptedEvent = fromJson(note)
if (decryptedEvent.kind == 9733) {
return decryptedEvent
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
return null
}
} }
} }
/*
{
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
"content": "",
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
"created_at": 1674164539,
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
"kind": 9734,
"tags": [
[
"e",
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
],
[
"p",
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
],
[
"relays",
"wss://relay.damus.io",
"wss://nostr-relay.wlvs.space",
"wss://nostr.fmt.wiz.biz",
"wss://relay.nostr.bg",
"wss://nostr.oxtr.dev",
"wss://nostr.v0l.io",
"wss://brb.io",
"wss://nostr.bitcoiner.social",
"ws://monad.jb55.com:8080",
"wss://relay.snort.social"
],
[
"poll_option", "n"
]
],
"ots": <base64-encoded OTS file data> // TODO
}
*/
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log import android.util.Log
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils import nostr.postr.Utils
import java.util.Date import java.util.Date
@@ -21,7 +21,7 @@ class MuteListEvent(
fun plainContent(privKey: ByteArray): String? { fun plainContent(privKey: ByteArray): String? {
return try { return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
return Utils.decrypt(content, sharedSecret) return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) { } catch (e: Exception) {
@@ -21,7 +21,11 @@ class PrivateDmEvent(
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages. * for initial messages.
*/ */
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
/** /**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18. * To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
@@ -31,6 +35,11 @@ class PrivateDmEvent(
*/ */
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun with(pubkeyHex: String): Boolean {
return pubkeyHex == pubKey ||
tags.firstOrNull { it.size > 1 && it[0] == "p" }?.getOrNull(1) == pubkeyHex
}
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? { fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
return try { return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey) val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service.nip19 package com.vitorpamplona.amethyst.service.nip19
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Bech32 import nostr.postr.Bech32
import nostr.postr.bechToBytes import nostr.postr.bechToBytes
@@ -147,8 +147,8 @@ object Nip19 {
public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String { public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String {
val kind = kind?.toByteArray() val kind = kind?.toByteArray()
val author = author?.toByteArray() val author = author?.hexToByteArray()
val idHex = idHex.toByteArray() val idHex = idHex.hexToByteArray()
val relay = relay?.toByteArray(Charsets.UTF_8) val relay = relay?.toByteArray(Charsets.UTF_8)
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex
@@ -0,0 +1,47 @@
package com.vitorpamplona.amethyst.ui.actions
import kotlinx.coroutines.delay
import java.net.HttpURLConnection
import java.net.URL
class ImageDownloader {
suspend fun waitAndGetImage(imageUrl: String): ByteArray? {
var imageData: ByteArray? = null
var tentatives = 0
// Servers are usually not ready.. so tries to download it for 15 times/seconds.
while (imageData == null && tentatives < 15) {
imageData = try {
HttpURLConnection.setFollowRedirects(true)
var url = URL(imageUrl)
var huc = url.openConnection() as HttpURLConnection
huc.instanceFollowRedirects = true
var responseCode = huc.responseCode
if (responseCode in 300..400) {
val newUrl: String = huc.getHeaderField("Location")
// open the new connnection again
url = URL(newUrl)
huc = url.openConnection() as HttpURLConnection
responseCode = huc.responseCode
}
if (responseCode in 200..300) {
huc.inputStream.use { it.readBytes() }
} else {
tentatives++
delay(1000)
null
}
} catch (e: Exception) {
tentatives++
delay(1000)
null
}
}
return imageData
}
}
@@ -1,21 +1,16 @@
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.asRequestBody
import okio.BufferedSink import okio.BufferedSink
import okio.source import okio.source
import java.io.File
import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
@@ -28,43 +23,51 @@ object ImageUploader {
fun uploadImage( fun uploadImage(
uri: Uri, uri: Uri,
server: ServersAvailable, server: ServersAvailable,
context: Context,
contentResolver: ContentResolver, contentResolver: ContentResolver,
onSuccess: (String, String?) -> Unit, onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit onError: (Throwable) -> Unit
) { ) {
val contentType = contentResolver.getType(uri) val contentType = contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri) val imageInputStream = contentResolver.openInputStream(uri)
val length = contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: 0
checkNotNull(imageInputStream) { checkNotNull(imageInputStream) {
"Can't open the image input stream" "Can't open the image input stream"
} }
val myServer = if (server == ServersAvailable.IMGUR) { val myServer = when (server) {
ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
ImgurServer() ImgurServer()
} else if (server == ServersAvailable.NOSTRIMG) { }
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
NostrImgServer() NostrImgServer()
} else if (server == ServersAvailable.NOSTR_BUILD) { }
ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> {
NostrBuildServer() NostrBuildServer()
} else { }
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
NostrFilesDevServer()
}
else -> {
ImgurServer() ImgurServer()
} }
}
val file = getRealPathFromURI(uri, context)?.let { File(it) } // create path from uri uploadImage(imageInputStream, length, contentType, myServer, onSuccess, onError)
if (file != null) {
uploadImage(file, imageInputStream, contentType, myServer, server, onSuccess, onError)
}
} }
fun uploadImage( fun uploadImage(
file: File,
inputStream: InputStream, inputStream: InputStream,
length: Long,
contentType: String?, contentType: String?,
server: FileServer, server: FileServer,
serverType: ServersAvailable,
onSuccess: (String, String?) -> Unit, onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit onError: (Throwable) -> Unit
) { ) {
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
val fileName = randomChars() val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
@@ -72,27 +75,16 @@ object ImageUploader {
val requestBody: RequestBody val requestBody: RequestBody
val requestBuilder = Request.Builder() val requestBuilder = Request.Builder()
if (serverType == ServersAvailable.NOSTR_BUILD) {
requestBuilder.addHeader("Content-Type", "multipart/form-data")
requestBody = MultipartBody.Builder() requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
.addFormDataPart( .addFormDataPart(
"fileToUpload", server.inputParameterName(contentType),
"$fileName.$extension",
file.asRequestBody(contentType?.toMediaType())
)
.build()
} else {
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
category,
"$fileName.$extension", "$fileName.$extension",
object : RequestBody() { object : RequestBody() {
override fun contentType(): MediaType? = override fun contentType() = contentType?.toMediaType()
contentType?.toMediaType()
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) { override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll) inputStream.source().use(sink::writeAll)
@@ -100,7 +92,6 @@ object ImageUploader {
} }
) )
.build() .build()
}
server.clientID()?.let { server.clientID()?.let {
requestBuilder.addHeader("Authorization", it) requestBuilder.addHeader("Authorization", it)
@@ -117,7 +108,7 @@ object ImageUploader {
try { try {
check(response.isSuccessful) check(response.isSuccessful)
response.body.use { body -> response.body.use { body ->
val url = server.parseUrlFromSucess(body.string()) val url = server.parseUrlFromSuccess(body.string())
checkNotNull(url) { checkNotNull(url) {
"There must be an uploaded image URL in the response" "There must be an uploaded image URL in the response"
} }
@@ -138,41 +129,10 @@ object ImageUploader {
} }
} }
fun getRealPathFromURI(uri: Uri, context: Context): String? {
val returnCursor = context.contentResolver.query(uri, null, null, null, null)
val nameIndex = returnCursor!!.getColumnIndex(OpenableColumns.DISPLAY_NAME)
returnCursor.moveToFirst()
val name = returnCursor.getString(nameIndex)
val file = File(context.filesDir, name)
try {
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
val outputStream = FileOutputStream(file)
var read = 0
val maxBufferSize = 1 * 1024 * 1024
val bytesAvailable: Int = inputStream?.available() ?: 0
val bufferSize = Math.min(bytesAvailable, maxBufferSize)
val buffers = ByteArray(bufferSize)
while (inputStream?.read(buffers).also {
if (it != null) {
read = it
}
} != -1
) {
outputStream.write(buffers, 0, read)
}
Log.e("File Size", "Size " + file.length())
inputStream?.close()
outputStream.close()
Log.e("File Path", "Path " + file.path)
} catch (e: java.lang.Exception) {
Log.e("Exception", e.message!!)
}
return file.path
}
abstract class FileServer { abstract class FileServer {
abstract fun postUrl(contentType: String?): String abstract fun postUrl(contentType: String?): String
abstract fun parseUrlFromSucess(body: String): String? abstract fun parseUrlFromSuccess(body: String): String?
abstract fun inputParameterName(contentType: String?): String
open fun clientID(): String? = null open fun clientID(): String? = null
} }
@@ -180,12 +140,16 @@ abstract class FileServer {
class NostrImgServer : FileServer() { class NostrImgServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload" override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
override fun parseUrlFromSucess(body: String): String? { override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body) val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText() val url = tree?.get("data")?.get("link")?.asText()
return url return url
} }
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID() = null override fun clientID() = null
} }
@@ -195,21 +159,43 @@ class ImgurServer : FileServer() {
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload" return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
} }
override fun parseUrlFromSucess(body: String): String? { override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body) val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText() val url = tree?.get("data")?.get("link")?.asText()
return url return url
} }
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID() = "Client-ID e6aea87296f3f96" override fun clientID() = "Client-ID e6aea87296f3f96"
} }
class NostrBuildServer : FileServer() { class NostrBuildServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php" override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
override fun parseUrlFromSucess(body: String): String? { override fun parseUrlFromSuccess(body: String): String? {
val url = jacksonObjectMapper().readTree(body) // return url.toString() val url = jacksonObjectMapper().readTree(body) // return url.toString()
return url.toString().replace("\"", "") return url.toString().replace("\"", "")
} }
override fun inputParameterName(contentType: String?): String {
return "fileToUpload"
}
override fun clientID() = null
}
class NostrFilesDevServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
return tree?.get("url")?.asText()
}
override fun inputParameterName(contentType: String?): String {
return "file"
}
override fun clientID() = null override fun clientID() = null
} }
@@ -11,10 +11,8 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URL
open class NewMediaModel : ViewModel() { open class NewMediaModel : ViewModel() {
var account: Account? = null var account: Account? = null
@@ -46,6 +44,8 @@ open class NewMediaModel : ViewModel() {
selectedServer = ServersAvailable.NOSTRIMG_NIP_94 selectedServer = ServersAvailable.NOSTRIMG_NIP_94
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) { } else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94 selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
} }
} }
@@ -77,7 +77,6 @@ open class NewMediaModel : ViewModel() {
ImageUploader.uploadImage( ImageUploader.uploadImage(
uri = uri, uri = uri,
server = serverToUse, server = serverToUse,
context = context,
contentResolver = contentResolver, contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType -> onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description) createNIP94Record(imageUrl, mimeType, description)
@@ -115,18 +114,12 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = "Server Processing" uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload // Images don't seem to be ready immediately after upload
if (mimeType?.startsWith("image/") == true) { val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
delay(2000)
} else {
delay(15000)
}
uploadingDescription.value = "Downloading" uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f uploadingPercentage.value = 0.60f
try { if (imageData != null) {
val imageData = URL(imageUrl).readBytes()
uploadingPercentage.value = 0.80f uploadingPercentage.value = 0.80f
uploadingDescription.value = "Hashing" uploadingDescription.value = "Hashing"
@@ -154,8 +147,8 @@ open class NewMediaModel : ViewModel() {
} }
} }
) )
} catch (e: Exception) { } else {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") Log.e("ImageDownload", "Couldn't download image from server")
cancel() cancel()
uploadingPercentage.value = 0.00f uploadingPercentage.value = 0.00f
uploadingDescription.value = null uploadingDescription.value = null
@@ -109,6 +109,13 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
} }
} }
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94
}
@Composable @Composable
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) { fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -117,6 +124,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)), Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer)) Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
) )
@@ -191,9 +199,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
) )
} }
if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 || if (isNIP94Server(postViewModel.selectedServer) ||
postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 ||
postViewModel.selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
postViewModel.selectedServer == ServersAvailable.NIP95 postViewModel.selectedServer == ServersAvailable.NIP95
) { ) {
Row( Row(
@@ -588,9 +588,11 @@ enum class ServersAvailable {
IMGUR, IMGUR,
NOSTR_BUILD, NOSTR_BUILD,
NOSTRIMG, NOSTRIMG,
NOSTRFILES_DEV,
IMGUR_NIP_94, IMGUR_NIP_94,
NOSTRIMG_NIP_94, NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94, NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
NIP95 NIP95
} }
@@ -612,9 +614,11 @@ fun ImageVideoDescription(
Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)), Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)), Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)), Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)), Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer)) Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
) )
@@ -742,9 +746,7 @@ fun ImageVideoDescription(
) )
} }
if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 || if (isNIP94Server(selectedServer) ||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
selectedServer == ServersAvailable.NIP95 selectedServer == ServersAvailable.NIP95
) { ) {
Row( Row(
@@ -19,7 +19,6 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -145,10 +144,9 @@ open class NewPostViewModel : ViewModel() {
ImageUploader.uploadImage( ImageUploader.uploadImage(
uri = it, uri = it,
server = server, server = server,
context = context,
contentResolver = contentResolver, contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType -> onSuccess = { imageUrl, mimeType ->
if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94 || server == ServersAvailable.NOSTR_BUILD_NIP_94) { if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, description) createNIP94Record(imageUrl, mimeType, description)
} else { } else {
isUploadingImage = false isUploadingImage = false
@@ -288,13 +286,6 @@ open class NewPostViewModel : ViewModel() {
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) { fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload // Images don't seem to be ready immediately after upload
if (mimeType?.startsWith("image/") == true) {
delay(2000)
} else {
delay(5000)
}
FileHeader.prepare( FileHeader.prepare(
imageUrl, imageUrl,
mimeType, mimeType,
@@ -171,7 +171,6 @@ class NewUserMetadataViewModel : ViewModel() {
ImageUploader.uploadImage( ImageUploader.uploadImage(
uri = it, uri = it,
server = account.defaultFileServer, server = account.defaultFileServer,
context = context,
contentResolver = context.contentResolver, contentResolver = context.contentResolver,
onSuccess = { imageUrl, mimeType -> onSuccess = { imageUrl, mimeType ->
onUploading(false) onUploading(false)
@@ -5,15 +5,23 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState 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.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.navigation.NavController import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable @Composable
fun ClickableRoute( fun ClickableRoute(
@@ -21,56 +29,190 @@ fun ClickableRoute(
navController: NavController navController: NavController
) { ) {
if (nip19.type == Nip19.Type.USER) { if (nip19.type == Nip19.Type.USER) {
val userBase = LocalCache.getOrCreateUser(nip19.hex) DisplayUser(nip19, navController)
val userState by userBase.live().metadata.observeAsState()
val user = userState?.user ?: return
CreateClickableText(user.toBestDisplayName(), nip19.additionalChars, "User/${nip19.hex}", navController)
} else if (nip19.type == Nip19.Type.ADDRESS) { } else if (nip19.type == Nip19.Type.ADDRESS) {
val noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex) DisplayAddress(nip19, navController)
} else if (nip19.type == Nip19.Type.NOTE) {
DisplayNote(nip19, navController)
} else if (nip19.type == Nip19.Type.EVENT) {
DisplayEvent(nip19, navController)
} else {
Text(
"@${nip19.hex}${nip19.additionalChars} "
)
}
}
@Composable
private fun DisplayEvent(
nip19: Nip19.Return,
navController: NavController
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
}
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Channel/${nip19.hex}",
navController
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Room/${note.author?.pubkeyHex}",
navController
)
} else if (channel != null) {
CreateClickableText(
channel.toBestDisplayName(),
nip19.additionalChars,
"Channel/${note.channel()?.idHex}",
navController
)
} else {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Event/${nip19.hex}",
navController
)
}
}
if (noteBase == null) { if (noteBase == null) {
Text( Text(
"@${nip19.hex}${nip19.additionalChars} " "@${nip19.hex}${nip19.additionalChars} "
) )
} else {
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
} }
} else if (nip19.type == Nip19.Type.NOTE) { }
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live().metadata.observeAsState() @Composable
private fun DisplayNote(
nip19: Nip19.Return,
navController: NavController
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
}
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return val note = noteState?.note ?: return
val channel = note.channel() val channel = note.channel()
if (note.event is ChannelCreateEvent) { if (note.event is ChannelCreateEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController) CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Channel/${nip19.hex}",
navController
)
} else if (note.event is PrivateDmEvent) { } else if (note.event is PrivateDmEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController) CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Room/${note.author?.pubkeyHex}",
navController
)
} else if (channel != null) { } else if (channel != null) {
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController) CreateClickableText(
channel.toBestDisplayName(),
nip19.additionalChars,
"Channel/${note.channel()?.idHex}",
navController
)
} else { } else {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController) CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Note/${nip19.hex}",
navController
)
}
} }
} else if (nip19.type == Nip19.Type.EVENT) {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) { if (noteBase == null) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController) Text(
} else if (note.event is PrivateDmEvent) { "@${nip19.hex}${nip19.additionalChars} "
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController) )
} else if (channel != null) {
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
} else {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Event/${nip19.hex}", navController)
} }
} else { }
@Composable
private fun DisplayAddress(
nip19: Nip19.Return,
navController: NavController
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
}
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Note/${nip19.hex}",
navController
)
}
if (noteBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
)
}
}
@Composable
private fun DisplayUser(
nip19: Nip19.Return,
navController: NavController
) {
var userBase by remember { mutableStateOf<User?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
}
}
userBase?.let {
val userState by it.live().metadata.observeAsState()
val user = userState?.user ?: return
CreateClickableText(
user.toBestDisplayName(),
nip19.additionalChars,
"User/${nip19.hex}",
navController
)
}
if (userBase == null) {
Text( Text(
"@${nip19.hex}${nip19.additionalChars} " "@${nip19.hex}${nip19.additionalChars} "
) )
@@ -42,11 +42,13 @@ fun ExpandableRichTextViewer(
) { ) {
var showFullText by remember { mutableStateOf(false) } var showFullText by remember { mutableStateOf(false) }
val whereToCut = remember(content) {
// Cuts the text in the first space after 350 // Cuts the text in the first space after 350
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val whereToCut = minOf(firstSpaceAfterCut, firstNewLineAfterCut) minOf(firstSpaceAfterCut, firstNewLineAfterCut)
}
val text = if (showFullText) { val text = if (showFullText) {
content content
@@ -75,6 +75,14 @@ fun isValidURL(url: String?): Boolean {
val richTextDefaults = RichTextStyle().resolveDefaults() val richTextDefaults = RichTextStyle().resolveDefaults()
fun isMarkdown(content: String): Boolean {
return content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("```")
}
@Composable @Composable
fun RichTextViewer( fun RichTextViewer(
content: String, content: String,
@@ -85,58 +93,39 @@ fun RichTextViewer(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
navController: NavController navController: NavController
) { ) {
val isMarkdown = remember { isMarkdown(content) }
Column(modifier = modifier) { Column(modifier = modifier) {
if (content.startsWith("> ") || if (isMarkdown) {
content.startsWith("# ") || RenderContentAsMarkdown(content, backgroundColor)
content.contains("##") ||
content.contains("__") ||
content.contains("```")
) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor)
)
),
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
)
)
)
val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
MaterialRichText(
style = myMarkDownStyle
) {
Markdown(
content = markdownWithSpecialContent,
markdownParseOptions = MarkdownParseOptions.Default
)
}
} else { } else {
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, navController)
}
}
}
class RichTextViewerState(
val content: String,
val urlSet: LinkedHashSet<String>,
val imagesForPager: Map<String, ZoomableUrlContent>,
val imageList: List<ZoomableUrlContent>
)
@Composable
private fun RenderRegular(
content: String,
tags: List<List<String>>?,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
var processedState by remember {
mutableStateOf<RichTextViewerState?>(null)
}
LaunchedEffect(key1 = content) {
withContext(Dispatchers.IO) {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
val imagesForPager = urlSet.mapNotNull { fullUrl -> val imagesForPager = urlSet.mapNotNull { fullUrl ->
@@ -151,17 +140,27 @@ fun RichTextViewer(
}.associateBy { it.url } }.associateBy { it.url }
val imageList = imagesForPager.values.toList() val imageList = imagesForPager.values.toList()
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
}
}
// FlowRow doesn't work well with paragraphs. So we need to split them // FlowRow doesn't work well with paragraphs. So we need to split them
processedState?.let { state ->
content.split('\n').forEach { paragraph -> content.split('\n').forEach { paragraph ->
FlowRow() { FlowRow() {
val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ') val s = if (isArabic(paragraph)) {
paragraph.trim().split(' ')
.reversed()
} else {
paragraph.trim().split(' ')
}
s.forEach { word: String -> s.forEach { word: String ->
if (canPreview) { if (canPreview) {
// Explicit URL // Explicit URL
val img = imagesForPager[word] val img = state.imagesForPager[word]
if (img != null) { if (img != null) {
ZoomableContentView(img, imageList) ZoomableContentView(img, state.imageList)
} else if (urlSet.contains(word)) { } else if (state.urlSet.contains(word)) {
UrlPreview(word, "$word ") UrlPreview(word, "$word ")
} else if (word.startsWith("lnbc", true)) { } else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word) MayBeInvoicePreview(word)
@@ -219,7 +218,7 @@ fun RichTextViewer(
) )
} }
} else { } else {
if (urlSet.contains(word)) { if (state.urlSet.contains(word)) {
ClickableUrl("$word ", word) ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) { } else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word) val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
@@ -287,25 +286,140 @@ fun RichTextViewer(
} }
} }
} }
}
} }
@Composable @Composable
private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? { private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
if (parsedNip19.type == Nip19.Type.USER) { val myMarkDownStyle = richTextDefaults.copy(
val userHex = parsedNip19.hex codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
val userBase = LocalCache.getOrCreateUser(userHex) textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor)
)
),
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f)
.compositeOver(backgroundColor)
)
)
)
val userState by userBase.live().metadata.observeAsState() var markdownWithSpecialContent by remember { mutableStateOf<String?>(null) }
val displayName = userState?.user?.bestDisplayName() var nip19References by remember { mutableStateOf<List<Nip19.Return>>(emptyList()) }
if (displayName !== null) { var refresh by remember { mutableStateOf(0) }
return displayName
LaunchedEffect(key1 = content) {
withContext(Dispatchers.IO) {
nip19References = returnNIP19References(content)
markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
} }
} }
LaunchedEffect(key1 = refresh) {
withContext(Dispatchers.IO) {
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
}
}
nip19References.forEach {
var baseUser by remember { mutableStateOf<User?>(null) }
var baseNote by remember { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = it.hex) {
withContext(Dispatchers.IO) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNote = note
}
}
if (it.type == Nip19.Type.USER) {
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
baseUser = user
}
}
}
}
baseNote?.let {
val noteState by it.live().metadata.observeAsState()
if (noteState?.note?.event != null) {
refresh++
}
}
baseUser?.let {
val userState by it.live().metadata.observeAsState()
if (userState?.user?.info != null) {
refresh++
}
}
}
markdownWithSpecialContent?.let {
MaterialRichText(
style = myMarkDownStyle
) {
Markdown(
content = it,
markdownParseOptions = MarkdownParseOptions.Default
)
}
}
}
private fun getDisplayNameFromNip19(nip19: Nip19.Return): String? {
if (nip19.type == Nip19.Type.USER) {
return LocalCache.users[nip19.hex]?.bestDisplayName()
} else if (nip19.type == Nip19.Type.NOTE) {
return LocalCache.notes[nip19.hex]?.idDisplayNote()
} else if (nip19.type == Nip19.Type.ADDRESS) {
return LocalCache.addressables[nip19.hex]?.idDisplayNote()
} else if (nip19.type == Nip19.Type.EVENT) {
return LocalCache.notes[nip19.hex]?.idDisplayNote() ?: LocalCache.addressables[nip19.hex]?.idDisplayNote()
} else {
return null return null
}
}
private fun returnNIP19References(content: String): List<Nip19.Return> {
val listOfReferences = mutableListOf<Nip19.Return>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isBechLink(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
parsedNip19?.let {
listOfReferences.add(it)
}
}
}
}
return listOfReferences
} }
@Composable
private fun returnMarkdownWithSpecialContent(content: String): String { private fun returnMarkdownWithSpecialContent(content: String): String {
var returnContent = "" var returnContent = ""
content.split('\n').forEach { paragraph -> content.split('\n').forEach { paragraph ->
@@ -319,7 +433,7 @@ private fun returnMarkdownWithSpecialContent(content: String): String {
} else if (isBechLink(word)) { } else if (isBechLink(word)) {
val parsedNip19 = Nip19.uriToRoute(word) val parsedNip19 = Nip19.uriToRoute(word)
returnContent += if (parsedNip19 !== null) { returnContent += if (parsedNip19 !== null) {
val displayName = getDisplayNameFromUserNip19(parsedNip19) val displayName = getDisplayNameFromNip19(parsedNip19)
if (displayName != null) { if (displayName != null) {
"[@$displayName](nostr://$word) " "[@$displayName](nostr://$word) "
} else { } else {
@@ -359,9 +473,9 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
LocalCache.checkGetOrCreateNote(it.hex)?.let { note -> LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNotePair = Pair(note, it.additionalChars) baseNotePair = Pair(note, it.additionalChars)
} }
} else {
nip19Route = it
} }
nip19Route = it
} }
} }
} }
@@ -380,8 +494,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
MaterialTheme.colors.onSurface.copy(alpha = 0.12f), MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp) RoundedCornerShape(15.dp)
), ),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f) parentBackgroundColor = backgroundColor,
.compositeOver(backgroundColor),
isQuotedNote = true, isQuotedNote = true,
navController = navController navController = navController
) )
@@ -530,8 +643,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
MaterialTheme.colors.onSurface.copy(alpha = 0.12f), MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp) RoundedCornerShape(15.dp)
), ),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f) parentBackgroundColor = backgroundColor,
.compositeOver(backgroundColor),
isQuotedNote = true, isQuotedNote = true,
navController = navController navController = navController
) )
@@ -92,7 +92,6 @@ object Robohash {
.data("robohash:$message") .data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory) .fetcherFactory(HashImageFetcher.Factory)
.size(robotSize) .size(robotSize)
.crossfade(100)
.build() .build()
} }
} }
@@ -7,6 +7,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.baha.url.preview.IUrlPreviewCallback import com.baha.url.preview.IUrlPreviewCallback
@@ -14,7 +15,7 @@ import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.launch
@Composable @Composable
fun UrlPreview(url: String, urlText: String) { fun UrlPreview(url: String, urlText: String) {
@@ -28,11 +29,12 @@ fun UrlPreview(url: String, urlText: String) {
val context = LocalContext.current val context = LocalContext.current
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(default) } var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(default) }
val scope = rememberCoroutineScope()
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created). // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) { LaunchedEffect(url) {
if (urlPreviewState == UrlPreviewState.Loading) { if (urlPreviewState == UrlPreviewState.Loading) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo( UrlCachedPreviewer.previewInfo(
url, url,
object : IUrlPreviewCallback { object : IUrlPreviewCallback {
@@ -60,8 +60,10 @@ import java.io.File
public var muted = mutableStateOf(true) public var muted = mutableStateOf(true)
@Composable @Composable
fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
if (localFile != null) {
VideoView(localFile.toUri(), description, onDialog) VideoView(localFile.toUri(), description, onDialog)
}
} }
@Composable @Composable
@@ -75,7 +75,6 @@ import com.vitorpamplona.amethyst.ui.theme.Nip05
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable import net.engawapg.lib.zoomable.zoomable
import java.io.File import java.io.File
@@ -112,7 +111,7 @@ class ZoomableUrlVideo(
) : ZoomableUrlContent(url, description, hash, dim, uri) ) : ZoomableUrlContent(url, description, hash, dim, uri)
abstract class ZoomablePreloadedContent( abstract class ZoomablePreloadedContent(
val localFile: File, val localFile: File?,
description: String? = null, description: String? = null,
val mimeType: String? = null, val mimeType: String? = null,
val isVerified: Boolean? = null, val isVerified: Boolean? = null,
@@ -121,7 +120,7 @@ abstract class ZoomablePreloadedContent(
) : ZoomableContent(description, dim) ) : ZoomableContent(description, dim)
class ZoomableLocalImage( class ZoomableLocalImage(
localFile: File, localFile: File?,
mimeType: String? = null, mimeType: String? = null,
description: String? = null, description: String? = null,
val blurhash: String? = null, val blurhash: String? = null,
@@ -131,7 +130,7 @@ class ZoomableLocalImage(
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) ) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
class ZoomableLocalVideo( class ZoomableLocalVideo(
localFile: File, localFile: File?,
mimeType: String? = null, mimeType: String? = null,
description: String? = null, description: String? = null,
dim: String? = null, dim: String? = null,
@@ -210,7 +209,9 @@ private fun LocalImageView(
mutableStateOf<AsyncImagePainter.State?>(null) mutableStateOf<AsyncImagePainter.State?>(null)
} }
val ratio = aspectRatio(content.dim) val ratio = remember {
aspectRatio(content.dim)
}
BoxWithConstraints(contentAlignment = Alignment.Center) { BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.also { val myModifier = mainImageModifier.also {
@@ -220,7 +221,7 @@ private fun LocalImageView(
} }
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
if (content.localFile.exists()) { if (content.localFile != null && content.localFile.exists()) {
AsyncImage( AsyncImage(
model = content.localFile, model = content.localFile,
contentDescription = content.description, contentDescription = content.description,
@@ -247,7 +248,7 @@ private fun LocalImageView(
} }
} }
if (imageState is AsyncImagePainter.State.Error || !content.localFile.exists()) { if (imageState is AsyncImagePainter.State.Error || content.localFile == null || !content.localFile.exists()) {
BlankNote() BlankNote()
} }
} }
@@ -270,7 +271,9 @@ private fun UrlImageView(
mutableStateOf<Boolean?>(null) mutableStateOf<Boolean?>(null)
} }
val ratio = aspectRatio(content.dim) val ratio = remember {
aspectRatio(content.dim)
}
LaunchedEffect(key1 = content.url, key2 = imageState) { LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) { if (imageState is AsyncImagePainter.State.Success) {
@@ -337,9 +340,10 @@ private fun aspectRatio(dim: String?): Float? {
@Composable @Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
var cnt by remember { mutableStateOf<ZoomableContent?>(null) } var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
delay(200) delay(200)
cnt = content cnt = content
} }
@@ -1,21 +1,23 @@
package com.vitorpamplona.amethyst.ui.dal package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
object ChannelFeedFilter : AdditiveFeedFilter<Note>() { object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account lateinit var account: Account
lateinit var channel: Channel var channelId: String? = null
fun loadMessagesBetween(accountLoggedIn: Account, channelId: String) { fun loadMessagesBetween(accountLoggedIn: Account, channelId: String?) {
account = accountLoggedIn this.account = accountLoggedIn
channel = LocalCache.getOrCreateChannel(channelId) this.channelId = channelId
} }
// returns the last Note of each user. // returns the last Note of each user.
override fun feed(): List<Note> { override fun feed(): List<Note> {
val processingChannel = channelId ?: return emptyList()
val channel = LocalCache.getOrCreateChannel(processingChannel)
return channel.notes return channel.notes
.values .values
.filter { account.isAcceptable(it) } .filter { account.isAcceptable(it) }
@@ -24,6 +26,9 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
} }
override fun applyFilter(collection: Set<Note>): Set<Note> { override fun applyFilter(collection: Set<Note>): Set<Note> {
val processingChannel = channelId ?: return emptySet()
val channel = LocalCache.getOrCreateChannel(processingChannel)
return collection return collection
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) } .filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
.toSet() .toSet()
@@ -3,21 +3,22 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object ChatroomFeedFilter : AdditiveFeedFilter<Note>() { object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
var account: Account? = null var account: Account? = null
var withUser: User? = null var withUser: String? = null
fun loadMessagesBetween(accountIn: Account, userId: String) { fun loadMessagesBetween(accountIn: Account, userId: String) {
account = accountIn account = accountIn
withUser = LocalCache.checkGetOrCreateUser(userId) withUser = userId
} }
// returns the last Note of each user. // returns the last Note of each user.
override fun feed(): List<Note> { override fun feed(): List<Note> {
val processingUser = withUser ?: return emptyList()
val myAccount = account val myAccount = account
val myUser = withUser val myUser = LocalCache.checkGetOrCreateUser(processingUser)
if (myAccount == null || myUser == null) return emptyList() if (myAccount == null || myUser == null) return emptyList()
@@ -32,8 +33,10 @@ object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
} }
override fun applyFilter(collection: Set<Note>): Set<Note> { override fun applyFilter(collection: Set<Note>): Set<Note> {
val processingUser = withUser ?: return emptySet()
val myAccount = account val myAccount = account
val myUser = withUser val myUser = LocalCache.checkGetOrCreateUser(processingUser)
if (myAccount == null || myUser == null) return emptySet() if (myAccount == null || myUser == null) return emptySet()
@@ -9,9 +9,9 @@ object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
lateinit var account: Account lateinit var account: Account
var user: User? = null var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) { fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn account = accountLoggedIn
user = LocalCache.users[userId] this.user = user
} }
override fun feed(): List<Note> { override fun feed(): List<Note> {
@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.ui.dal package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
@@ -9,9 +8,9 @@ object UserProfileConversationsFeedFilter : FeedFilter<Note>() {
var account: Account? = null var account: Account? = null
var user: User? = null var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) { fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn account = accountLoggedIn
user = LocalCache.checkGetOrCreateUser(userId) this.user = user
} }
override fun feed(): List<Note> { override fun feed(): List<Note> {
@@ -8,9 +8,9 @@ object UserProfileFollowersFeedFilter : FeedFilter<User>() {
lateinit var account: Account lateinit var account: Account
var user: User? = null var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) { fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn account = accountLoggedIn
user = LocalCache.users[userId] this.user = user
} }
override fun feed(): List<User> { override fun feed(): List<User> {
@@ -8,9 +8,9 @@ object UserProfileFollowsFeedFilter : FeedFilter<User>() {
lateinit var account: Account lateinit var account: Account
var user: User? = null var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) { fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn account = accountLoggedIn
user = LocalCache.users[userId] this.user = user
} }
override fun feed(): List<User> { override fun feed(): List<User> {
@@ -9,9 +9,9 @@ object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
var account: Account? = null var account: Account? = null
var user: User? = null var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) { fun loadUserProfile(accountLoggedIn: Account, user: User) {
account = accountLoggedIn account = accountLoggedIn
user = LocalCache.checkGetOrCreateUser(userId) this.user = user
} }
override fun feed(): List<Note> { override fun feed(): List<Note> {
@@ -1,14 +1,13 @@
package com.vitorpamplona.amethyst.ui.dal package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
object UserProfileReportsFeedFilter : FeedFilter<Note>() { object UserProfileReportsFeedFilter : FeedFilter<Note>() {
var user: User? = null var user: User? = null
fun loadUserProfile(userId: String) { fun loadUserProfile(user: User?) {
user = LocalCache.checkGetOrCreateUser(userId) this.user = user
} }
override fun feed(): List<Note> { override fun feed(): List<Note> {
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.dal package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.zaps.UserZaps import com.vitorpamplona.amethyst.service.model.zaps.UserZaps
@@ -8,8 +7,8 @@ import com.vitorpamplona.amethyst.service.model.zaps.UserZaps
object UserProfileZapsFeedFilter : FeedFilter<Pair<Note, Note>>() { object UserProfileZapsFeedFilter : FeedFilter<Pair<Note, Note>>() {
var user: User? = null var user: User? = null
fun loadUserProfile(userId: String) { fun loadUserProfile(user: User?) {
user = LocalCache.checkGetOrCreateUser(userId) this.user = user
} }
override fun feed(): List<Pair<Note, Note>> { override fun feed(): List<Pair<Note, Note>> {
@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
val bottomNavigationItems = listOf( val bottomNavigationItems = listOf(
@@ -159,15 +158,16 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
val notif = notifState.value ?: return val notif = notifState.value ?: return
var hasNewItems by remember { mutableStateOf<Boolean>(false) } var hasNewItems by remember { mutableStateOf<Boolean>(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = notif) { LaunchedEffect(key1 = notif) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache, emptySet()) hasNewItems = route.hasNewItems(account, notif.cache, emptySet())
} }
} }
LaunchedEffect(key1 = db) { LaunchedEffect(key1 = db) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache, db) hasNewItems = route.hasNewItems(account, notif.cache, db)
} }
} }
@@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@@ -87,7 +88,7 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac
@Composable @Composable
fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account -> GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultStoriesFollowList, true) { listName -> FollowList(account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
account.changeDefaultStoriesFollowList(listName) account.changeDefaultStoriesFollowList(listName)
} }
} }
@@ -96,7 +97,7 @@ fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
@Composable @Composable
fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account -> GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultHomeFollowList, false) { listName -> FollowList(account.defaultHomeFollowList, account.userProfile(), false) { listName ->
account.changeDefaultHomeFollowList(listName) account.changeDefaultHomeFollowList(listName)
} }
} }
@@ -226,7 +227,7 @@ private fun LoggedInUserPictureDrawer(
} }
@Composable @Composable
fun FollowList(listName: String, withGlobal: Boolean, onChange: (String) -> Unit) { fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) {
// Notification // Notification
val dbState = LocalCache.live.observeAsState() val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return val db = dbState.value ?: return
@@ -244,7 +245,7 @@ fun FollowList(listName: String, withGlobal: Boolean, onChange: (String) -> Unit
followLists = defaultOptions + LocalCache.addressables.mapNotNull { followLists = defaultOptions + LocalCache.addressables.mapNotNull {
val event = (it.value.event as? PeopleListEvent) val event = (it.value.event as? PeopleListEvent)
// Has to have an list // Has to have an list
if (event != null && (event.tags.size > 1 || event.content.length > 50)) { if (event != null && event.pubKey == loggedIn.pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
Pair(event.dTag(), event.dTag()) Pair(event.dTag(), event.dTag())
} else { } else {
null null
@@ -254,7 +255,7 @@ fun FollowList(listName: String, withGlobal: Boolean, onChange: (String) -> Unit
} }
SimpleTextSpinner( SimpleTextSpinner(
placeholder = followLists.firstOrNull { it.first == listName }?.first ?: KIND3_FOLLOWS, placeholder = followLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
options = followNames.value, options = followNames.value,
onSelect = { onSelect = {
onChange(followLists.getOrNull(it)?.first ?: KIND3_FOLLOWS) onChange(followLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
@@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -34,11 +35,10 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.BadgeCard import com.vitorpamplona.amethyst.ui.screen.BadgeCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -48,8 +48,8 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
val context = LocalContext.current.applicationContext val context = LocalContext.current.applicationContext
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) } var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note == null) { if (note == null) {
BlankNote(Modifier, isInnerNote) BlankNote(Modifier, isInnerNote)
@@ -57,7 +57,7 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
var isNew by remember { mutableStateOf<Boolean>(false) } var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = likeSetCard) { LaunchedEffect(key1 = likeSetCard) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead) isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt()) NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt())
@@ -71,17 +71,14 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
} }
Column( Column(
modifier = Modifier.background(backgroundColor).combinedClickable( modifier = Modifier
.background(backgroundColor)
.combinedClickable(
onClick = { onClick = {
if (noteEvent !is ChannelMessageEvent) { routeFor(
navController.navigate("Note/${note.idHex}") { note,
launchSingleTop = true accountViewModel.userProfile()
} )?.let { navController.navigate(it) }
} else {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}
}, },
onLongClick = { popupExpanded = true } onLongClick = { popupExpanded = true }
) )
@@ -104,7 +101,9 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
Icon( Icon(
imageVector = Icons.Default.MilitaryTech, imageVector = Icons.Default.MilitaryTech,
null, null,
modifier = Modifier.size(25.dp).align(Alignment.TopEnd), modifier = Modifier
.size(25.dp)
.align(Alignment.TopEnd),
tint = MaterialTheme.colors.primary tint = MaterialTheme.colors.primary
) )
} }
@@ -115,7 +114,9 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
Text( Text(
stringResource(R.string.new_badge_award_notif), stringResource(R.string.new_badge_award_notif),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 5.dp).weight(1f) modifier = Modifier
.padding(bottom = 5.dp)
.weight(1f)
) )
Text( Text(
@@ -74,7 +74,7 @@ fun HiddenNote(reports: Set<Note>, loggedIn: User, modifier: Modifier = Modifier
FlowRow(modifier = Modifier.padding(top = 10.dp)) { FlowRow(modifier = Modifier.padding(top = 10.dp)) {
reports.forEach { reports.forEach {
NoteAuthorPicture( NoteAuthorPicture(
note = it, baseNote = it,
navController = navController, navController = navController,
userAccount = loggedIn, userAccount = loggedIn,
size = 35.dp size = 35.dp
@@ -28,7 +28,6 @@ import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -68,15 +67,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
Column( Column(
modifier = Modifier.background(backgroundColor).combinedClickable( modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = { onClick = {
if (noteEvent !is ChannelMessageEvent) { routeFor(note, account.userProfile())?.let { navController.navigate(it) }
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}
}, },
onLongClick = { popupExpanded = true } onLongClick = { popupExpanded = true }
) )
@@ -109,7 +100,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
FlowRow() { FlowRow() {
boostSetCard.boostEvents.forEach { boostSetCard.boostEvents.forEach {
NoteAuthorPicture( NoteAuthorPicture(
note = it, baseNote = it,
navController = navController, navController = navController,
userAccount = account.userProfile(), userAccount = account.userProfile(),
size = 35.dp size = 35.dp
@@ -22,6 +22,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -50,7 +51,7 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.launch
@Composable @Composable
fun ChatroomCompose( fun ChatroomCompose(
@@ -64,6 +65,8 @@ fun ChatroomCompose(
val notificationCacheState = NotificationCache.live.observeAsState() val notificationCacheState = NotificationCache.live.observeAsState()
val notificationCache = notificationCacheState.value ?: return val notificationCache = notificationCacheState.value ?: return
val scope = rememberCoroutineScope()
if (note?.event == null) { if (note?.event == null) {
BlankNote(Modifier) BlankNote(Modifier)
} else if (note.channel() != null) { } else if (note.channel() != null) {
@@ -86,7 +89,7 @@ fun ChatroomCompose(
var hasNewMessages by remember { mutableStateOf<Boolean>(false) } var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = notificationCache, key2 = note) { LaunchedEffect(key1 = notificationCache, key2 = note) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
note.createdAt()?.let { timestamp -> note.createdAt()?.let { timestamp ->
hasNewMessages = hasNewMessages =
timestamp > notificationCache.cache.load("Channel/${chan.idHex}") timestamp > notificationCache.cache.load("Channel/${chan.idHex}")
@@ -131,7 +134,7 @@ fun ChatroomCompose(
} else { } else {
val replyAuthorBase = val replyAuthorBase =
(note.event as? PrivateDmEvent) (note.event as? PrivateDmEvent)
?.recipientPubKey() ?.verifiedRecipientPubKey()
?.let { LocalCache.getOrCreateUser(it) } ?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!! var userToComposeOn = note.author!!
@@ -148,7 +151,7 @@ fun ChatroomCompose(
var hasNewMessages by remember { mutableStateOf<Boolean>(false) } var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = notificationCache, key2 = note) { LaunchedEffect(key1 = notificationCache, key2 = note) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
noteEvent?.let { noteEvent?.let {
hasNewMessages = it.createdAt() > notificationCache.cache.load( hasNewMessages = it.createdAt() > notificationCache.cache.load(
"Room/${userToComposeOn.pubkeyHex}" "Room/${userToComposeOn.pubkeyHex}"
@@ -31,6 +31,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -63,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.launch
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp) val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp) val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@@ -94,6 +95,7 @@ fun ChatroomMessageCompose(
var showHiddenNote by remember { mutableStateOf(false) } var showHiddenNote by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext val context = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
if (note?.event == null) { if (note?.event == null) {
BlankNote(Modifier) BlankNote(Modifier)
@@ -135,7 +137,7 @@ fun ChatroomMessageCompose(
LaunchedEffect(key1 = routeForLastRead) { LaunchedEffect(key1 = routeForLastRead) {
routeForLastRead?.let { routeForLastRead?.let {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val lastTime = NotificationCache.load(it) val lastTime = NotificationCache.load(it)
val createdAt = note.createdAt() val createdAt = note.createdAt()
@@ -28,7 +28,6 @@ import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -68,15 +67,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
Column( Column(
modifier = Modifier.background(backgroundColor).combinedClickable( modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = { onClick = {
if (noteEvent !is ChannelMessageEvent) { routeFor(note, account.userProfile())?.let { navController.navigate(it) }
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}
}, },
onLongClick = { popupExpanded = true } onLongClick = { popupExpanded = true }
) )
@@ -109,7 +100,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
FlowRow() { FlowRow() {
likeSetCard.likeEvents.forEach { likeSetCard.likeEvents.forEach {
NoteAuthorPicture( NoteAuthorPicture(
note = it, baseNote = it,
navController = navController, navController = navController,
userAccount = account.userProfile(), userAccount = account.userProfile(),
size = 35.dp size = 35.dp
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -26,13 +27,10 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -40,20 +38,25 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
val noteState by messageSetCard.note.live().metadata.observeAsState() val noteState by messageSetCard.note.live().metadata.observeAsState()
val note = noteState?.note val note = noteState?.note
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) } var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note == null) { if (note == null) {
BlankNote(Modifier, isInnerNote) BlankNote(Modifier, isInnerNote)
} else { } else {
var isNew by remember { mutableStateOf<Boolean>(false) } var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = messageSetCard.createdAt()) { LaunchedEffect(key1 = messageSetCard.createdAt()) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
isNew = val newIsNew =
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead) messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, messageSetCard.createdAt()) NotificationCache.markAsRead(routeForLastRead, messageSetCard.createdAt())
if (newIsNew != isNew) {
isNew = newIsNew
}
} }
} }
@@ -66,30 +69,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
Column( Column(
modifier = Modifier.background(backgroundColor).combinedClickable( modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = { onClick = {
if (noteEvent is ChannelMessageEvent) { routeFor(note, accountViewModel.userProfile())?.let { navController.navigate(it) }
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
} else {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
}
}, },
onLongClick = { popupExpanded = true } onLongClick = { popupExpanded = true }
) )
@@ -120,7 +100,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) { Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
NoteCompose( NoteCompose(
baseNote = note, baseNote = messageSetCard.note,
routeForLastRead = null, routeForLastRead = null,
isBoostedNote = true, isBoostedNote = true,
addMarginTop = false, addMarginTop = false,
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Bolt
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -21,6 +23,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -37,15 +40,13 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -56,19 +57,24 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return val account = accountState?.account ?: return
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) } var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note == null) { if (note == null) {
BlankNote(Modifier, false) BlankNote(Modifier, false)
} else { } else {
var isNew by remember { mutableStateOf<Boolean>(false) } var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = multiSetCard.createdAt()) { LaunchedEffect(key1 = multiSetCard.createdAt()) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead) val newIsNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt) NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt)
if (newIsNew != isNew) {
isNew = newIsNew
}
} }
} }
@@ -83,32 +89,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
.background(backgroundColor) .background(backgroundColor)
.combinedClickable( .combinedClickable(
onClick = { onClick = {
if (noteEvent is ChannelMessageEvent) { routeFor(note, account.userProfile())?.let { navController.navigate(it) }
note
.channel()
?.let {
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
} else {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
}
}, },
onLongClick = { popupExpanded = true } onLongClick = { popupExpanded = true }
) )
@@ -187,15 +168,10 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
} }
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
Box( Spacer(modifier = Modifier.width(65.dp))
modifier = Modifier
.width(65.dp)
.padding(0.dp)
) {
}
NoteCompose( NoteCompose(
baseNote = note, baseNote = multiSetCard.note,
routeForLastRead = null, routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp), modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true, isBoostedNote = true,
@@ -225,7 +201,7 @@ fun AuthorGalleryZaps(
Column(modifier = Modifier.padding(start = 10.dp)) { Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() { FlowRow() {
authorNotes.forEach { authorNotes.forEach {
AuthorPictureAndComment(it.key, it.value, navController, accountUser, accountViewModel) AuthorPictureAndComment(it.key, navController, accountUser, accountViewModel)
} }
} }
} }
@@ -234,7 +210,6 @@ fun AuthorGalleryZaps(
@Composable @Composable
private fun AuthorPictureAndComment( private fun AuthorPictureAndComment(
zapRequest: Note, zapRequest: Note,
zapEvent: Note,
navController: NavController, navController: NavController,
accountUser: User, accountUser: User,
accountViewModel: AccountViewModel accountViewModel: AccountViewModel
@@ -242,8 +217,10 @@ private fun AuthorPictureAndComment(
val author = zapRequest.author ?: return val author = zapRequest.author ?: return
var content by remember { mutableStateOf<Pair<User, String?>>(Pair(author, null)) } var content by remember { mutableStateOf<Pair<User, String?>>(Pair(author, null)) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = zapRequest.idHex) { LaunchedEffect(key1 = zapRequest.idHex) {
scope.launch(Dispatchers.IO) {
(zapRequest.event as? LnZapRequestEvent)?.let { (zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest) val decryptedContent = accountViewModel.decryptZap(zapRequest)
if (decryptedContent != null) { if (decryptedContent != null) {
@@ -256,6 +233,7 @@ private fun AuthorPictureAndComment(
} }
} }
} }
}
AuthorPictureAndComment(content.first, content.second, navController, accountUser, accountViewModel) AuthorPictureAndComment(content.first, content.second, navController, accountUser, accountViewModel)
} }
@@ -274,10 +252,14 @@ private fun AuthorPictureAndComment(
Modifier Modifier
} }
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { Row(
modifier = modifier.clickable {
navController.navigate("User/${author.pubkeyHex}")
},
verticalAlignment = Alignment.CenterVertically
) {
FastNoteAuthorPicture( FastNoteAuthorPicture(
author = author, author = author,
navController = navController,
userAccount = accountUser, userAccount = accountUser,
size = 35.dp size = 35.dp
) )
@@ -309,12 +291,16 @@ fun AuthorGallery(
Column(modifier = Modifier.padding(start = 10.dp)) { Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() { FlowRow() {
authorNotes.forEach { authorNotes.take(50).forEach {
val author = it.author val author = it.author
if (author != null) { if (author != null) {
AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel) AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel)
} }
} }
if (authorNotes.size > 50) {
Text(" and ${authorNotes.size - 50} others")
}
} }
} }
} }
@@ -322,7 +308,6 @@ fun AuthorGallery(
@Composable @Composable
fun FastNoteAuthorPicture( fun FastNoteAuthorPicture(
author: User, author: User,
navController: NavController,
userAccount: User, userAccount: User,
size: Dp, size: Dp,
pictureModifier: Modifier = Modifier pictureModifier: Modifier = Modifier
@@ -331,14 +316,12 @@ fun FastNoteAuthorPicture(
val user = userState?.user ?: return val user = userState?.user ?: return
val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount
UserPicture( UserPicture(
userHex = user.pubkeyHex, userHex = user.pubkeyHex,
userPicture = user.profilePicture(), userPicture = user.profilePicture(),
showFollowingMark = showFollowingMark, showFollowingMark = showFollowingMark,
size = size, size = size,
modifier = pictureModifier, modifier = pictureModifier
onClick = {
navController.navigate("User/${user.pubkeyHex}")
}
) )
} }
@@ -92,22 +92,36 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
val userState by baseUser.live().metadata.observeAsState() val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return val user = userState?.user ?: return
user.nip05()?.let { nip05 ->
val parts = nip05.split("@")
if (parts.size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Column(modifier = columnModifier) {
DisplayNIP05(parts[0], parts[1], nip05Verified)
}
}
}
}
@Composable
private fun DisplayNIP05(
user: String,
domain: String,
nip05Verified: Boolean?
) {
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_") { if (user != "_") {
Text( Text(
text = AnnotatedString(nip05.split("@")[0]), text = AnnotatedString(user),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
if (nip05Verified == null) { if (nip05Verified == null) {
Icon( Icon(
tint = Color.Yellow, tint = Color.Yellow,
@@ -138,16 +152,13 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
} }
ClickableText( ClickableText(
text = AnnotatedString(nip05.split("@")[1]), text = AnnotatedString(domain),
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } }, onClick = { runCatching { uri.openUri("https://$domain") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)), style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Visible overflow = TextOverflow.Visible
) )
} }
}
}
}
} }
@Composable @Composable
File diff suppressed because it is too large Load Diff
@@ -92,6 +92,7 @@ private fun OptionNote(
var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) } var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) }
LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) { LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) {
withContext(Dispatchers.IO) {
val myTally = pollViewModel.optionVoteTally(optionNumber) val myTally = pollViewModel.optionVoteTally(optionNumber)
val color = if ( val color = if (
pollViewModel.consensusThreshold != null && pollViewModel.consensusThreshold != null &&
@@ -106,6 +107,7 @@ private fun OptionNote(
optionTally = Pair(myTally, color) optionTally = Pair(myTally, color)
} }
} }
}
ZapVote( ZapVote(
baseNote, baseNote,
@@ -61,7 +61,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -319,21 +318,22 @@ fun ZapReaction(
var zappingProgress by remember { mutableStateOf(0f) } var zappingProgress by remember { mutableStateOf(0f) }
var wasZappedByLoggedInUser by remember { mutableStateOf(false) } var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) } var zapAmountTxt by remember { mutableStateOf<String>("") }
LaunchedEffect(key1 = zapsState) { LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) { if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote) wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
} }
zapAmount = account.calculateZappedAmount(zappedNote) zapAmountTxt = showAmount(account.calculateZappedAmount(zappedNote))
} }
} }
Row( Row(
verticalAlignment = CenterVertically, verticalAlignment = CenterVertically,
modifier = Modifier.size(iconSize) modifier = Modifier
.size(iconSize)
.combinedClickable( .combinedClickable(
role = Role.Button, role = Role.Button,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
@@ -456,7 +456,7 @@ fun ZapReaction(
} }
Text( Text(
showAmount(zapAmount), zapAmountTxt,
fontSize = 14.sp, fontSize = 14.sp,
color = grayTint, color = grayTint,
modifier = textModifier modifier = textModifier
@@ -5,6 +5,7 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -17,14 +18,24 @@ import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.model.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable @Composable
fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, navController: NavController) { fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, navController: NavController) {
val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } var dupMentions by remember { mutableStateOf<List<User>?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
}
}
if (dupMentions != null) {
ReplyInformation(replyTo, dupMentions, account) { ReplyInformation(replyTo, dupMentions, account) {
navController.navigate("User/${it.pubkeyHex}") navController.navigate("User/${it.pubkeyHex}")
} }
}
} }
@Composable @Composable
@@ -102,6 +113,34 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
} }
} }
@Composable
fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<String>, channel: Channel, account: Account, navController: NavController) {
var sortedMentions by remember { mutableStateOf<List<User>?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
sortedMentions = mentions
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.isFollowing(it) }
}
}
if (sortedMentions != null) {
ReplyInformationChannel(
replyTo,
sortedMentions,
channel,
onUserTagClick = {
navController.navigate("User/${it.pubkeyHex}")
},
onChannelTagClick = {
navController.navigate("Channel/${it.idHex}")
}
)
}
}
@Composable @Composable
fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<User>?, channel: Channel, navController: NavController) { fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<User>?, channel: Channel, navController: NavController) {
ReplyInformationChannel( ReplyInformationChannel(
@@ -28,41 +28,55 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState() val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return val user = userState?.user ?: return
if (user.bestUsername() != null && user.bestDisplayName() != null) { val bestUserName = user.bestUsername()
val bestDisplayName = user.bestDisplayName()
val npubDisplay = user.pubkeyDisplayHex()
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, weight)
}
@Composable
private fun UserNameDisplay(
bestUserName: String?,
bestDisplayName: String?,
npubDisplay: String,
modifier: Modifier
) {
if (bestUserName != null && bestDisplayName != null) {
Text( Text(
user.bestDisplayName() ?: "", bestDisplayName,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( Text(
"@${(user.bestUsername() ?: "")}", "@$bestUserName",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = weight modifier = modifier
) )
} else if (user.bestDisplayName() != null) { } else if (bestDisplayName != null) {
Text( Text(
user.bestDisplayName() ?: "", bestDisplayName,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = weight modifier = modifier
) )
} else if (user.bestUsername() != null) { } else if (bestUserName != null) {
Text( Text(
"@${(user.bestUsername() ?: "")}", "@$bestUserName",
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = weight modifier = modifier
) )
} else { } else {
Text( Text(
user.pubkeyDisplayHex(), npubDisplay,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = weight modifier = modifier
) )
} }
} }
@@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.UnfollowButton
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal import java.math.BigDecimal
@Composable @Composable
@@ -95,7 +94,7 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) } var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = noteZap) { LaunchedEffect(key1 = noteZap) {
withContext(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
zapAmount = (noteZap.event as? LnZapEvent)?.amount zapAmount = (noteZap.event as? LnZapEvent)?.amount
} }
} }
@@ -29,7 +29,6 @@ import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.ZapSetCard import com.vitorpamplona.amethyst.ui.screen.ZapSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -70,15 +69,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor
Column( Column(
modifier = Modifier.background(backgroundColor).combinedClickable( modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = { onClick = {
if (noteEvent !is ChannelMessageEvent) { routeFor(note, account.userProfile())?.let { navController.navigate(it) }
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}
}, },
onLongClick = { popupExpanded = true } onLongClick = { popupExpanded = true }
) )
@@ -113,7 +104,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor
FlowRow() { FlowRow() {
zapSetCard.zapEvents.forEach { zapSetCard.zapEvents.forEach {
NoteAuthorPicture( NoteAuthorPicture(
note = it.key, baseNote = it.key,
navController = navController, navController = navController,
userAccount = account.userProfile(), userAccount = account.userProfile(),
size = 35.dp size = 35.dp
@@ -94,7 +94,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
FlowRow() { FlowRow() {
zapSetCard.zapEvents.forEach { zapSetCard.zapEvents.forEach {
NoteAuthorPicture( NoteAuthorPicture(
note = it.key, baseNote = it.key,
navController = navController, navController = navController,
userAccount = account.userProfile(), userAccount = account.userProfile(),
size = 35.dp size = 35.dp
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
import fr.acinq.secp256k1.Hex import fr.acinq.secp256k1.Hex
@@ -43,7 +43,7 @@ class AccountStateViewModel() : ViewModel() {
fun startUI(key: String, useProxy: Boolean, proxyPort: Int) { fun startUI(key: String, useProxy: Boolean, proxyPort: Int) {
val pattern = Pattern.compile(".+@.+\\.[a-z]+") val pattern = Pattern.compile(".+@.+\\.[a-z]+")
val parsed = Nip19.uriToRoute(key) val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.toByteArray() val pubKeyParsed = parsed?.hex?.hexToByteArray()
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
val account = val account =
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -32,6 +33,8 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapSetCompose import com.vitorpamplona.amethyst.ui.note.ZapSetCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
@@ -85,6 +88,7 @@ fun CardFeedView(
} }
} }
@OptIn(ExperimentalTime::class)
@Composable @Composable
private fun FeedLoaded( private fun FeedLoaded(
state: CardFeedState.Loaded, state: CardFeedState.Loaded,
@@ -114,6 +118,7 @@ private fun FeedLoaded(
state = listState state = listState
) { ) {
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item -> itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
val (value, elapsed) = measureTimedValue {
when (item) { when (item) {
is NoteCard -> NoteCompose( is NoteCard -> NoteCompose(
item.note, item.note,
@@ -170,5 +175,7 @@ private fun FeedLoaded(
) )
} }
} }
Log.d("Time", "${item.javaClass.simpleName} Feed in $elapsed ${item.id()}")
}
} }
} }
@@ -175,7 +175,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
} }
} }
fun refreshFromOldState(newItems: Set<Note>) { private fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value val oldNotesState = _feedContent.value
val thisAccount = (localFilter as? NotificationFeedFilter)?.account val thisAccount = (localFilter as? NotificationFeedFilter)?.account
@@ -104,7 +104,7 @@ private fun FeedLoaded(
} else { } else {
val replyAuthorBase = val replyAuthorBase =
(note.event as? PrivateDmEvent) (note.event as? PrivateDmEvent)
?.recipientPubKey() ?.verifiedRecipientPubKey()
?.let { LocalCache.getOrCreateUser(it) } ?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!! var userToComposeOn = note.author!!
@@ -249,7 +249,7 @@ fun NoteMaster(
}) })
) { ) {
NoteAuthorPicture( NoteAuthorPicture(
note = baseNote, baseNote = baseNote,
navController = navController, navController = navController,
userAccount = account.userProfile(), userAccount = account.userProfile(),
size = 55.dp size = 55.dp
@@ -100,23 +100,42 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.math.BigDecimal import java.math.BigDecimal
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) { fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
if (userId == null) return
var userBase by remember { mutableStateOf<User?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
userBase = LocalCache.checkGetOrCreateUser(userId)
}
}
userBase?.let {
ProfileScreen(
user = it,
accountViewModel = accountViewModel,
navController = navController
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return val account = accountState?.account ?: return
if (userId == null) return UserProfileNewThreadFeedFilter.loadUserProfile(account, user)
UserProfileConversationsFeedFilter.loadUserProfile(account, user)
UserProfileFollowersFeedFilter.loadUserProfile(account, user)
UserProfileFollowsFeedFilter.loadUserProfile(account, user)
UserProfileZapsFeedFilter.loadUserProfile(user)
UserProfileReportsFeedFilter.loadUserProfile(user)
UserProfileBookmarksFeedFilter.loadUserProfile(account, user)
UserProfileNewThreadFeedFilter.loadUserProfile(account, userId) NostrUserProfileDataSource.loadUserProfile(user)
UserProfileConversationsFeedFilter.loadUserProfile(account, userId)
UserProfileFollowersFeedFilter.loadUserProfile(account, userId)
UserProfileFollowsFeedFilter.loadUserProfile(account, userId)
UserProfileZapsFeedFilter.loadUserProfile(userId)
UserProfileReportsFeedFilter.loadUserProfile(userId)
UserProfileBookmarksFeedFilter.loadUserProfile(account, userId)
NostrUserProfileDataSource.loadUserProfile(userId)
val lifeCycleOwner = LocalLifecycleOwner.current val lifeCycleOwner = LocalLifecycleOwner.current
@@ -124,7 +143,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
println("Profidle Start") println("Profidle Start")
NostrUserProfileDataSource.loadUserProfile(userId) NostrUserProfileDataSource.loadUserProfile(user)
NostrUserProfileDataSource.start() NostrUserProfileDataSource.start()
} }
if (event == Lifecycle.Event.ON_PAUSE) { if (event == Lifecycle.Event.ON_PAUSE) {
+6
View File
@@ -334,4 +334,10 @@
<string name="upload_server_relays_nip95">Saját csomópontjaid (NIP-95)</string> <string name="upload_server_relays_nip95">Saját csomópontjaid (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel</string> <string name="upload_server_relays_nip95_explainer">A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel</string>
<string name="follow_list_selection">Követek Lista</string>
<string name="follow_list_kind3follows">Mindenki akit követek</string>
<string name="follow_list_global">Globális</string>
</resources> </resources>
+8
View File
@@ -341,6 +341,10 @@
<string name="upload_server_nostrbuild">nostr.build - trusted</string> <string name="upload_server_nostrbuild">nostr.build - trusted</string>
<string name="upload_server_nostrbuild_explainer">Nostr.build can modify the file</string> <string name="upload_server_nostrbuild_explainer">Nostr.build can modify the file</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - trusted</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev can modify the file</string>
<string name="upload_server_imgur_nip94">Verifiable Imgur (NIP-94)</string> <string name="upload_server_imgur_nip94">Verifiable Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Checks if Imgur modified the file. New NIP: other clients might not see it</string> <string name="upload_server_imgur_nip94_explainer">Checks if Imgur modified the file. New NIP: other clients might not see it</string>
@@ -350,6 +354,10 @@
<string name="upload_server_nostrbuild_nip94">Verifiable Nostr.build (NIP-94)</string> <string name="upload_server_nostrbuild_nip94">Verifiable Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Checks if Nostr.build modified the file. New NIP: other clients might not see it</string> <string name="upload_server_nostrbuild_nip94_explainer">Checks if Nostr.build modified the file. New NIP: other clients might not see it</string>
<string name="upload_server_nostrfilesdev_nip94">Verifiable Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Checks if Nostrfiles.dev modified the file. New NIP: other clients might not see it</string>
<string name="upload_server_relays_nip95">Your relays (NIP-95)</string> <string name="upload_server_relays_nip95">Your relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Files are hosted by your relays. New NIP: check if they support</string> <string name="upload_server_relays_nip95_explainer">Files are hosted by your relays. New NIP: check if they support</string>
@@ -4,6 +4,7 @@ import android.util.LruCache
import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks import com.google.android.gms.tasks.Tasks
import com.google.mlkit.nl.languageid.LanguageIdentification import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.languageid.LanguageIdentificationOptions
import com.google.mlkit.nl.translate.TranslateLanguage import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.Translation import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.Translator import com.google.mlkit.nl.translate.Translator
@@ -20,7 +21,8 @@ class ResultOrError(
) )
object LanguageTranslatorService { object LanguageTranslatorService {
private val languageIdentification = LanguageIdentification.getClient() private val options = LanguageIdentificationOptions.Builder().setConfidenceThreshold(0.6f).build()
private val languageIdentification = LanguageIdentification.getClient(options)
val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE)
val tagRegex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", Pattern.CASE_INSENSITIVE) val tagRegex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", Pattern.CASE_INSENSITIVE)
@@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -34,7 +35,7 @@ import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
@Composable @Composable
@@ -57,8 +58,10 @@ fun TranslatableRichTextViewer(
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = accountState?.account ?: return val account = accountState?.account ?: return
val scope = rememberCoroutineScope()
LaunchedEffect(accountState) { LaunchedEffect(accountState) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
LanguageTranslatorService.autoTranslate( LanguageTranslatorService.autoTranslate(
content, content,
account.dontTranslateFrom, account.dontTranslateFrom,
@@ -70,8 +73,6 @@ fun TranslatableRichTextViewer(
showOriginal = preference == task.result.sourceLang showOriginal = preference == task.result.sourceLang
} }
translatedTextState.value = task.result translatedTextState.value = task.result
} else {
translatedTextState.value = ResultOrError(content, null, null, null)
} }
} }
} }